diff --git a/.gitignore b/.gitignore index c96d720d0..f530a3048 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,4 @@ pyrightconfig.json # spec-workflow tool artifacts .spec-workflow -# Local-only examples -examples/session_service_with_advanced_memory_sql/ -examples/session_service_with_advanced_memory_redis/ +site/* diff --git a/docs/mkdocs/en/codeact.md b/docs/mkdocs/en/codeact.md new file mode 100644 index 000000000..29d8d0819 --- /dev/null +++ b/docs/mkdocs/en/codeact.md @@ -0,0 +1,600 @@ +# CodeAct + +CodeAct is an optional execution mode for `LlmAgent`. When enabled, the model +generates Python code to orchestrate tools, process data, and submit results +instead of relying only on traditional Function Calling. + +The CodeAct design in tRPC-Agent-Python is inspired by +[NVIDIA NeMo Labs OO Agents (NOOA)](https://github.com/NVIDIA-NeMo/labs-OO-Agents) +and integrated with the existing Agent, Tool, Runner, and Event systems. + +## Overview + +CodeAct provides two capabilities: + +### CodeAct Tools + +The model calls tools configured on the Agent through `self.(...)` in a +persistent Python environment: + +```python +numbers = await self.load_numbers(count=100000) +total = sum(number * number for number in numbers if number % 7 == 0) +return_result({"value": total}) +``` + +Large tool results can remain in the local ObjectStore. Later cells continue +using the same variable without repeatedly serializing the full object into the +model context. + +For complete setup, tests, and result analysis, see the +[CodeAct Tools example README](../../../examples/codeact_with_tools/README.md). + +### Generation Method + +`CodeActTool` can dynamically implement a function whose body consists only of +`...`. The function name, parameters, type annotations, and docstring define +the implementation contract: + +```python +def analyze_order_operations( + orders: list[OrderRecord], + overdue_days: int = 3, +) -> OrderOperationsReport: + """Analyze order statuses, amounts and overdue orders.""" + ... +``` + +Successful generated code can be stored as a candidate, manually approved, and +then reused directly to reduce later model calls. + +For the complete generation, approval, reuse, and rollback flow, see the +[Generation Method example README](../../../examples/codeact_with_generation_method/README.md). + +## Features + +### Python-Based Tool Orchestration + +The model can combine multiple tools with loops, branches, comprehensions, +aggregation, exception handling, and temporary variables. You do not need to +design a separate tool for every intermediate step. + +CodeAct does not replace the existing tool system. `FunctionTool`, +`BaseToolSet`, argument conversion, `InvocationContext`, filters, and +before/after callbacks continue to work through the existing path. The +difference is that the model invokes them through the Python `self` proxy. + +### Persistent Python Variables + +All generated cells within one invocation share the same Python globals. Data +loaded in the first cell remains available in later cells without another tool +call. + +State exists only for the current invocation. The Runtime releases its globals +and ObjectStore when the invocation finishes. + +### Large Objects Retained by Reference + +Tool results pass through `CodeActObjectStore.wrap(...)`: + +- Small JSON-serializable values are returned inline. +- Large lists, dictionaries, and complex objects remain in the ObjectStore. +- The Python environment receives a `CodeActObjectRef` to the real object. +- The model sees only the type, length, and a bounded preview. + +An ObjectRef supports indexing, slicing, iteration, `len()`, and public method +calls, so generated code can still process the underlying object directly. + +ObjectRef prevents the framework from automatically transporting the entire +object. It is not a mandatory data-loss-prevention mechanism. If generated code +explicitly runs `print(list(value))`, the full contents still enter the model +context. + +### Progressive Capability Discovery + +The model can inspect available tools and objects with `doc()`: + +```python +print(doc(self)) +print(doc(self.load_numbers)) +print(doc(numbers)) +``` + +This avoids keeping every complete tool schema and large object value in every +model request. + +### Structured Result Validation + +The model submits its final result with `return_result(value)`. When +`output_schema` is configured, the framework validates the value with +Pydantic: + +```python +class CalculationResult(BaseModel): + expression: str + value: int +``` + +If validation fails, the framework emits a `codeact.validation_error` event and +allows the model to correct the value in another cell. A valid result produces +a final Event with `object="codeact.result"` and ends the Agent loop without an +extra model call for a textual summary. + +### One Cell per Model Response + +Only the first fenced `python` block in each model response is executed. Text +and additional code blocks after it are not speculatively executed. The model +must wait for the actual execution result before deciding the next action. + +`return_result(...)` immediately terminates the current cell, preventing code +after the final result from producing additional side effects. + +### Model-Implemented Functions + +`CodeActTool` accepts only functions whose body contains an optional docstring +and `...`. On invocation, it: + +1. Validates input against the function signature. +2. Creates a nested CodeAct invocation. +3. Exposes the current arguments through `self.get_function_arguments()`. +4. Allows generated code to call explicitly configured or inherited tools. +5. Validates the result against the function return type. +6. Returns the validated value as a regular Tool Response to the parent Agent. + +This feature does not modify the function body in the source file. A regular +`FunctionTool` still runs deterministic developer-written code. Only functions +explicitly wrapped in `CodeActTool` are implemented by the model. + +### Generated Implementation Approval and Reuse + +`CodeActTool` can save generated code that passes input and output validation. +After a candidate is approved, later calls execute it directly in the CodeAct +Runtime without creating a nested Agent or asking a model to regenerate it. + +The framework computes a contract hash from the function module, name, +signature, docstring, and input/output schemas. It also computes a capability +hash from callable tools. An implementation is not reused automatically after +its contract or capabilities change. + +### Separation from CodeExecutor + +CodeAct Runtime executes model-generated control code. CodeExecutor handles +ordinary code blocks, Skill scripts, and user-supplied code. They can be +configured together but have separate responsibilities: + +```text +Model control code → CodeAct Runtime +Skill / user code → Local, Container, or Cube CodeExecutor +``` + +When CodeAct is enabled, Python from model responses is sent only to +`code_act.runtime`; `code_executor` does not execute the same block again. +Business tools can still call CodeExecutor through explicit entry points to +run user code in a container or remote sandbox. + +## Workflows + +### CodeAct Tools Workflow + +```text +User request +→ LlmAgent injects the CodeAct protocol +→ Model generates one Python cell +→ CodeActResponseProcessor extracts the code +→ CodeAct Runtime executes the cell +→ self.(...) calls an existing tool +→ Large results remain as ObjectRef values +→ Execution observations return to the model +→ Later cells reuse existing variables +→ return_result(...) submits and validates the result +``` + +### Generation Method Workflow + +```text +Parent Agent calls CodeActTool +→ Validate function arguments +→ Find a compatible approved implementation +├─ Found: Runtime executes approved code directly +└─ Not found: nested CodeAct Agent generates and executes code +→ Validate the function return type +→ Save a successful candidate when a Store is configured +→ Return to the parent Agent +``` + +## Architecture + +### Request Processing + +After `LlmAgent.code_act` is configured, the framework injects the CodeAct +protocol into the model request. It instructs the model to: + +- Generate one Python code block per response. +- Call tools with `await self.(...)`. +- Inspect tools and objects with `doc()`. +- Submit intermediate observations with `print(...)`. +- Submit the final result with `return_result(...)`. +- Finish within `max_iterations`. + +When the Runtime supports tools, Agent tools are not sent as ordinary provider +Function Calling schemas. The model accesses them through the `self` proxy. + +### Response Processing + +`CodeActResponseProcessor` extracts the first Python block, injects the +`return_result(...)` protocol, and passes the code to the Runtime. The Runtime +returns stdout, stderr, and execution status, from which the Processor creates +code execution Events. + +If no final result was submitted, the observation enters history and triggers +another model call. A valid final result ends the Agent loop. + +### Persistent Runtime + +The default `InProcessCodeActRuntime` keeps this state per invocation: + +```python +class _ExecutionState: + globals: dict[str, Any] + store: CodeActObjectStore + agent_proxy: CodeActAgentProxy +``` + +- `globals` stores variables shared between cells. +- `store` retains large or complex Python objects. +- `agent_proxy` exposes Agent tools as `self.(...)`. + +Cells are compiled with `ast.PyCF_ALLOW_TOP_LEVEL_AWAIT`, so generated code can +call asynchronous tools at the top level without creating an event loop. + +### Tool Proxy + +`CodeActAgentProxy` resolves the current Agent's `BaseTool` and `BaseToolSet` +instances and maps them by name: + +```text +self.load_numbers(count=100000) +→ CodeActAgentProxy +→ BaseTool.run_async(...) +→ FunctionTool +→ load_numbers(...) +``` + +The proxy preserves existing tool behavior, including: + +- Required argument and type validation. +- Automatic `tool_context` injection. +- Synchronous and asynchronous function support. +- Filters and callbacks. +- Per-invocation tool call limits. + +Tool calls currently require keyword arguments and `await`: + +```python +numbers = await self.load_numbers(count=100000) +``` + +Positional arguments and progress-streaming tools are not currently supported. + +## Basic Usage + +### Enable CodeAct Tools + +```python +from pydantic import BaseModel + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.codeact import CodeActConfig +from trpc_agent_sdk.tools import FunctionTool + + +class CalculationResult(BaseModel): + expression: str + value: int + + +def load_numbers(count: int) -> list[int]: + """Return integers from zero up to, but excluding, count.""" + return list(range(count)) + + +agent = LlmAgent( + name="codeact_calculator", + model=model, + instruction="Use load_numbers once and calculate the requested result.", + tools=[FunctionTool(load_numbers)], + code_act=CodeActConfig(), + output_schema=CalculationResult, +) +``` + +The user instruction only needs to name the tool. It does not need to teach the +user or business prompt to write `await self.load_numbers(...)`. +`CodeActConfig` adds protocol instructions that guide the model to translate +tool names into correct Python calls. + +For runnable code, expected output, and a real Tool Calling comparison, see the +[CodeAct Tools example README](../../../examples/codeact_with_tools/README.md). + +### Enable a Generation Method + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.tools import CodeActTool + + +def analyze_order_operations( + orders: list[OrderRecord], + overdue_days: int = 3, +) -> OrderOperationsReport: + """Analyze order statuses, amounts and overdue orders.""" + ... + + +agent = LlmAgent( + name="order_agent", + model=model, + tools=[CodeActTool(analyze_order_operations)], +) +``` + +`CodeActTool` generates an implementation from the function contract and +validates its result against `OrderOperationsReport`. For complete business +inputs, generated code, and output verification, see the +[Generation Method example README](../../../examples/codeact_with_generation_method/README.md). + +## Configuration + +### CodeActConfig + +```python +from trpc_agent_sdk.codeact import CodeActConfig +from trpc_agent_sdk.codeact import InProcessCodeActRuntime + +config = CodeActConfig( + runtime=InProcessCodeActRuntime(), + max_iterations=10, + require_result=True, +) +``` + +- `runtime`: Runtime for model-generated code. The default is + `InProcessCodeActRuntime`. +- `max_iterations`: Maximum model/code iterations per invocation. The default + is `10`. +- `require_result`: Whether the model must finish through + `return_result(...)`. +- `text_only_retry_message`: Retry feedback when the model returns only text. + +### CodeActTool + +```python +tool = CodeActTool( + analyze_order_operations, + model=model, + tools=capability_tools, + code_act=CodeActConfig(), + filters=filters, + implementation_store=store, + implementation_policy="prefer_approved", +) +``` + +- `model`: Model that generates the implementation. If omitted, the model from + the calling context is used. +- `tools`: Additional tools available to generated code. +- `code_act`: CodeAct configuration for nested invocations. +- `filters`: Filters applied to the Tool. +- `implementation_store`: Candidate and approved implementation store. +- `implementation_policy`: Strategy for generation and approved-code reuse. + +## Implementation Persistence + +### Policies + +`implementation_policy` supports: + +- `dynamic`: Always generate with the model; save successful candidates when a + Store is configured. +- `prefer_approved`: Run a compatible approved implementation first, or + generate one when no approved version exists. +- `approved_only`: Run only a compatible approved implementation and fail when + none exists. + +When a non-`dynamic` policy has no explicit Store, +`InMemoryCodeActImplementationStore` is used by default. It does not survive a +process restart and is not shared across workers. Configure a persistent Store +for production. + +### Approval and Rollback + +```python +candidates = await tool.list_implementations() +candidate = candidates[-1] + +await tool.approve(candidate.version, approved_by="reviewer") +``` + +Approve an older version again to roll back: + +```python +await tool.approve(previous_version, approved_by="rollback") +``` + +Approval only selects which code version to reuse. The application must test +business correctness, security, permissions, and performance before approval. + +### Storage Backends + +The framework provides: + +- `InMemoryCodeActImplementationStore` for single-process tests. +- `FileCodeActImplementationStore` for local file persistence. +- `RedisCodeActImplementationStore` for shared multi-worker persistence. +- `SqlCodeActImplementationStore` for relational database persistence. + +```python +from trpc_agent_sdk.codeact import FileCodeActImplementationStore +from trpc_agent_sdk.codeact import RedisCodeActImplementationStore +from trpc_agent_sdk.codeact import SqlCodeActImplementationStore + +file_store = FileCodeActImplementationStore(".codeact") +redis_store = RedisCodeActImplementationStore( + redis_url="redis://localhost:6379/0", +) +sql_store = SqlCodeActImplementationStore( + db_url="sqlite:///codeact.db", +) +``` + +These backends reuse `trpc_agent_sdk.storage`. Existing `FileStorage`, +`RedisStorage`, or `SqlStorage` instances can also be injected with `storage=`. +Call `await store.close()` during application shutdown. When external Storage +is injected, its lifecycle remains the caller's responsibility. + +For policy transitions, automatic approval demonstrations, and production +guidance, see the +[Generation Method example README](../../../examples/codeact_with_generation_method/README.md). + +## Examples + +### CodeAct Tools + +[examples/codeact_with_tools/README.md](../../../examples/codeact_with_tools/README.md) +covers: + +- Calling tools through `self.(...)`. +- Reusing variables across two CodeAct cells. +- Processing large objects through ObjectRef. +- Returning structured results. +- Comparing request size, tokens, and elapsed time with real Tool Calling. +- Testing different object sizes. +- Development and production considerations. + +### Generation Method + +[examples/codeact_with_generation_method/README.md](../../../examples/codeact_with_generation_method/README.md) +covers: + +- Implementing an ellipsis-bodied function with `CodeActTool`. +- Viewing the actual model-generated code. +- Generating and approving a candidate with `prefer_approved`. +- Executing approved code without model generation under `approved_only`. +- File, Redis, and SQL Store use cases. +- Candidate testing, approval, and rollback guidance. + +Refer to the corresponding example README for commands, test inputs, output +comparisons, and acceptance criteria. This document does not duplicate +example-specific execution results. + +## Comparison with Traditional Tool Calling + +Traditional Tool Calling: + +```text +Send tool schemas to the model +→ Model returns a FunctionCall +→ Framework executes the tool +→ Serialize the complete result as a FunctionResponse +→ Send the result to the model again +``` + +CodeAct Tools: + +```text +Model generates Python +→ self.(...) invokes a tool +→ Result remains in the Python Runtime +→ Loops, filtering, and aggregation happen locally +→ Only necessary observations and the final result are submitted +``` + +The primary benefit of CodeAct is not a different function-call syntax. It: + +- Expresses complex control flow in Python. +- Avoids repeated JSON serialization of large intermediate results. +- Reduces tokens consumed by large objects in model context. +- Continues processing real Python objects within one invocation. + +For simple arguments and small results, traditional Tool Calling is more +direct and easier to approve or audit call by call. + +## Use Cases + +CodeAct is suitable for: + +- Filtering and aggregating large lists, tables, or complex objects. +- Tasks requiring loops, branches, and multi-step computation. +- Tool pipelines with dependent intermediate data. +- Large tool results that produce a small final answer. +- Generating implementations from typed function contracts. +- Managing, approving, and reusing generated implementations. + +Traditional Tool Calling is usually better for: + +- One-off API calls with simple arguments. +- Calls requiring independent human confirmation or auditing. +- High-privilege side effects such as payments or deletion. +- Small tasks that do not need Python control flow. + +## Limitations + +### Processes and Workers + +The `globals` and ObjectStore of `InProcessCodeActRuntime` live in the current +Worker process. Later model calls in the same invocation must continue on that +Worker. + +State is lost when: + +- The request moves to another Worker. +- The Worker restarts. +- The process crashes or fails over. +- The invocation completes and releases its Runtime. + +Session Events can store code and execution output, but they cannot restore +arbitrary live Python objects. Redis and SQL Implementation Stores persist +Generation Method code versions only; they do not make current invocation +variables or ObjectRef values distributed. + +Cross-worker execution requires an externally stateful +`BaseCodeActRuntime` with remote object handles, lifecycle management, and +concurrency control. + +### Current Runtime Support + +The only built-in implementation is `InProcessCodeActRuntime`. A Container or +remote sandbox CodeAct Runtime is not currently provided. Container/Cube +CodeExecutor cannot directly replace it because ordinary CodeExecutor does not +provide the `self` tool proxy, persistent live Python objects, or ObjectRef +protocol. + +### Tool Call Restrictions + +- Calls through `self` must use keyword arguments. +- Asynchronous capabilities must use `await`. +- Progress-streaming Tools are not currently supported by the CodeAct proxy. +- ObjectRef cannot be transported directly between processes. +- Generated code can still explicitly print a large object. + +## Security + +`InProcessCodeActRuntime` executes model-generated Python in the Agent host +process with the same filesystem, network, environment-variable, and process +permissions. It is not a security sandbox and is suitable only for +development, testing, and trusted input. + +Before using CodeAct in production: + +- Provide OS-level isolation with a container, VM, or remote sandbox. +- Limit CPU, memory, execution time, filesystem access, and process count. +- Restrict network egress, environment variables, and credentials. +- Expose only least-privilege tools through `self`. +- Add authorization, idempotency, auditing, and human confirmation to writes. +- Test and security-review Generation Method candidates before approval. +- Provide Worker affinity or a distributed Runtime for each invocation. +- Run Skill and user code through Container/Cube CodeExecutor instead of + mixing it with CodeAct control code. + +Do not execute untrusted model-generated code directly in a multi-tenant +production service until an isolated Runtime is available. diff --git a/docs/mkdocs/zh/codeact.md b/docs/mkdocs/zh/codeact.md new file mode 100644 index 000000000..06e493d25 --- /dev/null +++ b/docs/mkdocs/zh/codeact.md @@ -0,0 +1,556 @@ +# CodeAct + +CodeAct 是 `LlmAgent` 的可选执行模式。启用后,模型通过生成 Python 代码完成 +工具编排、数据处理和结果提交,而不是只依赖传统 Function Calling。 + +tRPC-Agent-Python 的 CodeAct 设计参考了 +[NVIDIA NeMo Labs OO Agents(NOOA)](https://github.com/NVIDIA-NeMo/labs-OO-Agents), +并结合现有 Agent、Tool、Runner 和 Event 体系实现。 + +## 概述 + +CodeAct 提供两类能力: + +### CodeAct Tools + +模型在持久 Python 环境中通过 `self.(...)` 调用 Agent 已配置的工具: + +```python +numbers = await self.load_numbers(count=100000) +total = sum(number * number for number in numbers if number % 7 == 0) +return_result({"value": total}) +``` + +工具返回的大对象可以保留在本地 ObjectStore,后续代码继续使用同一个变量, +不需要把完整对象反复序列化到模型上下文。 + +完整运行、测试和结果分析请参考 +[CodeAct Tools 示例 README](../../../examples/codeact_with_tools/README.md)。 + +### Generation Method + +`CodeActTool` 可以为函数体只有 `...` 的函数动态生成实现。函数名、参数、 +类型标注和 docstring 共同定义实现契约: + +```python +def analyze_order_operations( + orders: list[OrderRecord], + overdue_days: int = 3, +) -> OrderOperationsReport: + """Analyze order statuses, amounts and overdue orders.""" + ... +``` + +生成成功的代码可以保存为候选版本,经过人工审批后直接复用,减少后续模型调用。 + +完整的生成、审批、复用和回滚流程请参考 +[Generation Method 示例 README](../../../examples/codeact_with_generation_method/README.md)。 + +## 功能特性 + +### 使用 Python 编排工具 + +模型可以使用循环、分支、推导式、聚合、异常处理和临时变量组合多个工具, +不需要为每个中间步骤单独设计工具。 + +CodeAct 不会替换现有工具体系。`FunctionTool`、`BaseToolSet`、参数转换、 +`InvocationContext`、filters 以及 before/after callbacks 仍按原有流程工作, +只是模型改为通过 Python 中的 `self` 代理调用它们。 + +### 持久 Python 变量 + +同一次 invocation 中,每轮生成的代码共享一组 Python 全局变量。第一轮加载的 +数据可以在后续轮次继续使用,无需再次调用工具。 + +该状态只在当前 invocation 内有效。invocation 结束后,Runtime 会清理变量和 +ObjectStore。 + +### 大对象按引用保留 + +工具返回值经过 `CodeActObjectStore.wrap(...)` 处理: + +- 小型、可 JSON 序列化的值直接返回; +- 大型列表、字典和复杂对象保存在 ObjectStore; +- Python 环境获取指向真实对象的 `CodeActObjectRef`; +- 模型只看到类型、长度和有限预览。 + +`ObjectRef` 支持索引、切片、迭代、`len()` 和公开方法调用,因此生成代码仍可 +直接处理真实对象。 + +ObjectRef 的作用是避免框架自动传输完整对象,并不是强制防泄露机制。如果代码 +主动执行 `print(list(value))`,完整内容仍会进入模型上下文。 + +### 能力按需发现 + +模型可以使用 `doc()` 查看当前可用工具和对象信息: + +```python +print(doc(self)) +print(doc(self.load_numbers)) +print(doc(numbers)) +``` + +这避免了把所有工具的完整 Schema 和大型对象内容长期放在模型请求中。 + +### 结构化结果校验 + +模型使用 `return_result(value)` 提交最终结果。配置 `output_schema` 后, +框架会使用 Pydantic 校验返回值: + +```python +class CalculationResult(BaseModel): + expression: str + value: int +``` + +校验失败时,框架生成 `codeact.validation_error` 事件,并允许模型在新的代码 +单元中修正。校验成功后生成 `object="codeact.result"` 的最终 Event,不再额外 +调用模型生成总结文本。 + +### 单代码单元执行 + +每次模型响应只执行第一个 fenced `python` 代码块。代码块后的文本或其他代码块 +不会被推测性执行,模型必须等待真实执行结果后再决定下一步。 + +`return_result(...)` 会立即结束当前代码单元,防止提交最终结果后继续执行有 +副作用的代码。 + +### 生成未完成函数 + +`CodeActTool` 只接受函数体由可选 docstring 和 `...` 组成的函数。调用时会: + +1. 根据函数签名验证输入; +2. 创建嵌套 CodeAct invocation; +3. 通过 `self.get_function_arguments()` 提供本次调用参数; +4. 允许生成代码调用显式配置或继承的工具; +5. 根据函数返回类型校验结果; +6. 将结果作为普通 Tool Response 返回父 Agent。 + +该能力不会修改源文件中的函数体。普通 `FunctionTool` 仍执行开发者编写的确定性 +代码,只有显式使用 `CodeActTool` 的函数才会由模型实现。 + +### 固化与复用生成代码 + +`CodeActTool` 可以保存通过输入和输出校验的生成代码。候选版本经过审批后, +后续调用直接由 CodeAct Runtime 执行,无需再次创建嵌套 Agent 或请求模型。 + +框架会根据函数模块、名称、签名、docstring、输入/输出 Schema 计算契约哈希, +并根据可调用工具计算能力哈希。契约或工具能力发生变化后,旧实现不会自动复用。 + +### CodeAct Runtime 与 CodeExecutor 解耦 + +CodeAct Runtime 用于执行模型生成的控制代码;CodeExecutor 用于普通代码块、 +Skill 脚本或用户指定代码。两者可以同时配置,但职责不同: + +```text +模型控制代码 → CodeAct Runtime +Skill / 用户代码 → Local、Container 或 Cube CodeExecutor +``` + +启用 CodeAct 后,模型响应中的 Python 代码只交给 `code_act.runtime`, +`code_executor` 不会重复执行同一个代码块。业务工具仍可通过明确入口调用 +CodeExecutor,在容器或远端沙箱中执行用户代码。 + +## 工作流程 + +### CodeAct Tools 执行流程 + +```text +用户请求 +→ LlmAgent 注入 CodeAct 执行协议 +→ 模型生成一个 Python Cell +→ CodeActResponseProcessor 提取代码 +→ CodeAct Runtime 执行代码 +→ self.(...) 调用现有工具 +→ 大对象保留为 ObjectRef +→ 执行观察返回模型 +→ 后续 Cell 复用已有变量 +→ return_result(...) 提交并校验结果 +``` + +### Generation Method 执行流程 + +```text +父 Agent 调用 CodeActTool +→ 校验函数参数 +→ 查询兼容的审批实现 +├─ 找到:Runtime 直接执行审批代码 +└─ 未找到:嵌套 CodeAct Agent 生成并执行代码 +→ 校验函数返回类型 +→ 保存成功候选(配置 Store 时) +→ 返回父 Agent +``` + +## 架构说明 + +### 请求处理 + +配置 `LlmAgent.code_act` 后,框架向模型注入 CodeAct 协议,包括: + +- 每次响应只生成一个 Python 代码块; +- 使用 `await self.(...)` 调用工具; +- 使用 `doc()` 查看工具和对象; +- 使用 `print(...)` 提交中间观察; +- 使用 `return_result(...)` 提交最终结果; +- 在 `max_iterations` 范围内完成任务。 + +在支持工具的 Runtime 中,Agent 工具不会再作为普通 provider Function +Calling Schema 发送,模型通过 `self` 代理访问工具。 + +### 响应处理 + +`CodeActResponseProcessor` 提取首个 Python 代码块,注入 +`return_result(...)` 协议并交给 Runtime。Runtime 返回 stdout、stderr 和 +执行状态后,Processor 创建代码执行 Event。 + +未调用 `return_result(...)` 时,观察结果进入历史并触发下一次模型调用; +提交有效结果后结束当前 Agent 循环。 + +### 持久运行时 + +默认 `InProcessCodeActRuntime` 按 invocation 保存: + +```python +class _ExecutionState: + globals: dict[str, Any] + store: CodeActObjectStore + agent_proxy: CodeActAgentProxy +``` + +- `globals`:保存跨代码单元变量; +- `store`:保存大型或复杂 Python 对象; +- `agent_proxy`:将 Agent 工具暴露为 `self.(...)`。 + +代码使用 `ast.PyCF_ALLOW_TOP_LEVEL_AWAIT` 编译,因此可以直接在顶层调用异步 +工具,不需要创建事件循环。 + +### 工具代理 + +`CodeActAgentProxy` 解析当前 Agent 的 `BaseTool` 和 `BaseToolSet`,并按名称 +建立映射: + +```text +self.load_numbers(count=100000) +→ CodeActAgentProxy +→ BaseTool.run_async(...) +→ FunctionTool +→ load_numbers(...) +``` + +工具代理保留以下原有能力: + +- 必填参数与类型检查; +- `tool_context` 自动注入; +- 同步和异步函数兼容; +- filters 与 callbacks; +- invocation 工具调用次数限制。 + +当前代理要求使用关键字参数,并通过 `await` 调用: + +```python +numbers = await self.load_numbers(count=100000) +``` + +位置参数和进度流式工具暂不支持。 + +## 基本用法 + +### 启用 CodeAct Tools + +```python +from pydantic import BaseModel + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.codeact import CodeActConfig +from trpc_agent_sdk.tools import FunctionTool + + +class CalculationResult(BaseModel): + expression: str + value: int + + +def load_numbers(count: int) -> list[int]: + """Return integers from zero up to, but excluding, count.""" + return list(range(count)) + + +agent = LlmAgent( + name="codeact_calculator", + model=model, + instruction="Use load_numbers once and calculate the requested result.", + tools=[FunctionTool(load_numbers)], + code_act=CodeActConfig(), + output_schema=CalculationResult, +) +``` + +用户指令只需要说明使用哪个工具,不需要要求用户或业务提示词写出 +`await self.load_numbers(...)`。`CodeActConfig` 生成的系统协议会指导模型把 +工具名转换为正确的 Python 调用。 + +运行方式、完整代码、预期结果和真实 Tool Calling 对比请参考 +[CodeAct Tools 示例 README](../../../examples/codeact_with_tools/README.md)。 + +### 启用 Generation Method + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.tools import CodeActTool + + +def analyze_order_operations( + orders: list[OrderRecord], + overdue_days: int = 3, +) -> OrderOperationsReport: + """Analyze order statuses, amounts and overdue orders.""" + ... + + +agent = LlmAgent( + name="order_agent", + model=model, + tools=[CodeActTool(analyze_order_operations)], +) +``` + +`CodeActTool` 会根据函数契约生成实现,并按照 `OrderOperationsReport` 校验 +返回结果。完整业务输入、生成代码和执行结果请参考 +[Generation Method 示例 README](../../../examples/codeact_with_generation_method/README.md)。 + +## 配置说明 + +### CodeActConfig + +```python +from trpc_agent_sdk.codeact import CodeActConfig +from trpc_agent_sdk.codeact import InProcessCodeActRuntime + +config = CodeActConfig( + runtime=InProcessCodeActRuntime(), + max_iterations=10, + require_result=True, +) +``` + +- `runtime`:执行模型代码的 Runtime,默认是 `InProcessCodeActRuntime`; +- `max_iterations`:一次 invocation 允许的最大模型/代码循环次数,默认 `10`; +- `require_result`:是否要求模型必须通过 `return_result(...)` 结束; +- `text_only_retry_message`:模型只返回文本时使用的重试提示。 + +### CodeActTool + +```python +tool = CodeActTool( + analyze_order_operations, + model=model, + tools=capability_tools, + code_act=CodeActConfig(), + filters=filters, + implementation_store=store, + implementation_policy="prefer_approved", +) +``` + +- `model`:生成函数实现的模型;未配置时使用调用上下文中的模型; +- `tools`:生成代码可调用的额外工具; +- `code_act`:嵌套调用使用的 CodeAct 配置; +- `filters`:应用于 Tool 的过滤器; +- `implementation_store`:候选和审批实现仓库; +- `implementation_policy`:代码生成与审批版本的选择策略。 + +## 实现固化 + +### 策略 + +`implementation_policy` 支持: + +- `dynamic`:每次都由模型生成;配置 Store 时保存成功候选; +- `prefer_approved`:优先执行兼容的审批版本,没有时动态生成; +- `approved_only`:只执行兼容的审批版本,没有时直接报错。 + +非 `dynamic` 策略没有显式配置 Store 时,默认使用 +`InMemoryCodeActImplementationStore`。该默认值不会跨进程或重启保留数据, +生产环境应配置持久化 Store。 + +### 审批与回滚 + +```python +candidates = await tool.list_implementations() +candidate = candidates[-1] + +await tool.approve(candidate.version, approved_by="reviewer") +``` + +重新审批历史版本即可回滚: + +```python +await tool.approve(previous_version, approved_by="rollback") +``` + +审批只表示选择要复用的代码版本。业务正确性、安全性、权限和性能测试仍应由 +应用在审批前完成。 + +### 存储后端 + +框架提供以下实现: + +- `InMemoryCodeActImplementationStore`:单进程测试; +- `FileCodeActImplementationStore`:单机文件持久化; +- `RedisCodeActImplementationStore`:多 Worker 共享; +- `SqlCodeActImplementationStore`:关系数据库持久化。 + +```python +from trpc_agent_sdk.codeact import FileCodeActImplementationStore +from trpc_agent_sdk.codeact import RedisCodeActImplementationStore +from trpc_agent_sdk.codeact import SqlCodeActImplementationStore + +file_store = FileCodeActImplementationStore(".codeact") +redis_store = RedisCodeActImplementationStore( + redis_url="redis://localhost:6379/0", +) +sql_store = SqlCodeActImplementationStore( + db_url="sqlite:///codeact.db", +) +``` + +这些后端复用 `trpc_agent_sdk.storage`,也可以通过 `storage=` 注入已有 +`FileStorage`、`RedisStorage` 或 `SqlStorage`。应用停止时应调用 +`await store.close()`;注入外部 Storage 时,其生命周期仍由调用方管理。 + +完整的策略切换、自动审批演示和生产使用建议请参考 +[Generation Method 示例 README](../../../examples/codeact_with_generation_method/README.md)。 + +## 示例 + +### CodeAct Tools + +[examples/codeact_with_tools/README.md](../../../examples/codeact_with_tools/README.md) +包含: + +- `self.(...)` 工具调用; +- 两个 CodeAct Cell 之间复用变量; +- ObjectRef 大对象处理; +- 结构化结果返回; +- CodeAct 与真实 Tool Calling 的请求大小、Token 和耗时对比; +- 不同对象规模的测试方式; +- 测试与生产环境注意事项。 + +### Generation Method + +[examples/codeact_with_generation_method/README.md](../../../examples/codeact_with_generation_method/README.md) +包含: + +- 使用 `CodeActTool` 实现 `...` 函数; +- 查看模型生成的实际代码; +- `prefer_approved` 生成并审批候选; +- `approved_only` 不调用模型、直接执行审批代码; +- File、Redis、SQL Store 的使用场景; +- 候选测试、审批和回滚建议。 + +具体运行命令、测试输入、输出对照和验收条件以对应示例 README 为准,本文不再 +重复维护示例执行结果。 + +## 与传统 Tool Calling 的差异 + +传统 Tool Calling: + +```text +工具 Schema 发送给模型 +→ 模型返回 FunctionCall +→ 框架执行工具 +→ 完整结果序列化为 FunctionResponse +→ 下一轮模型再次接收结果 +``` + +CodeAct Tools: + +```text +模型生成 Python +→ self.(...) 调用工具 +→ 结果保留在 Python Runtime +→ 本地完成循环、过滤和聚合 +→ 只提交必要观察和最终结果 +``` + +CodeAct 的主要收益不是改变工具调用语法,而是: + +- 使用 Python 表达复杂控制流; +- 避免大型中间结果反复 JSON 序列化; +- 减少大对象进入模型上下文产生的 Token; +- 在同一次 invocation 中持续处理真实 Python 对象。 + +对于简单参数和小型返回值,传统 Tool Calling 更直观,也更容易逐次审批和审计。 + +## 适用场景 + +CodeAct 适合: + +- 大型列表、表格或复杂对象的过滤和聚合; +- 需要循环、分支和多步计算的任务; +- 多个工具之间存在中间数据依赖; +- 工具返回值很大,但最终结果较小; +- 使用函数契约生成可校验的实现; +- 对生成实现进行候选管理、审批和复用。 + +传统 Tool Calling 更适合: + +- 单次、参数简单的 API 调用; +- 每次调用都需要人工确认或独立审计; +- 支付、删除等高权限副作用操作; +- 不需要 Python 控制流的小型任务。 + +## 限制 + +### 进程与 Worker + +`InProcessCodeActRuntime` 的 `globals` 和 ObjectStore 位于当前 Worker 内存。 +同一次 invocation 的后续模型调用必须继续在同一个 Worker 执行。 + +以下情况会丢失状态: + +- 请求迁移到其他 Worker; +- Worker 重启; +- 进程崩溃或故障转移; +- invocation 已完成并释放 Runtime。 + +Session Event 只能保存代码和执行结果,不能恢复任意 Python 活对象。 +Redis/SQL Implementation Store 只负责保存 Generation Method 的代码版本, +不会将当前 invocation 的变量或 ObjectRef 变成分布式状态。 + +如需跨 Worker 执行,需要实现外部状态化的 `BaseCodeActRuntime`,并提供远程 +对象句柄、生命周期管理和并发控制。 + +### 当前 Runtime 能力 + +当前内置实现只有 `InProcessCodeActRuntime`,尚未提供 Container 或远端沙箱 +CodeAct Runtime。Container/Cube CodeExecutor 不能直接替代它,因为普通 +CodeExecutor 不具备 `self` 工具代理、持久 Python 活对象和 ObjectRef 协议。 + +### 工具调用限制 + +- 通过 `self` 调用工具时必须使用关键字参数; +- 异步能力必须使用 `await`; +- 进度流式 Tool 暂不通过 CodeAct 代理执行; +- ObjectRef 不能跨进程直接传输; +- 生成代码仍可能主动打印大型对象。 + +## 安全建议 + +`InProcessCodeActRuntime` 在 Agent 宿主进程执行模型生成的 Python,拥有与 +宿主相同的文件、网络、环境变量和进程权限。它不是安全沙箱,只适用于开发、 +测试和受信任输入。 + +生产环境使用 CodeAct 前,应至少做到: + +- 使用容器、VM 或远端沙箱提供 OS 级隔离; +- 限制 CPU、内存、执行时间、文件系统和进程数; +- 限制网络出口、环境变量和凭证访问; +- 只向 `self` 暴露最小必要权限的工具; +- 为写操作增加鉴权、幂等、审计和人工确认; +- 对 Generation Method 候选执行测试和安全审查后再审批; +- 为 invocation 提供 Worker 粘滞或分布式 Runtime; +- 将 Skill 和用户代码交给 Container/Cube CodeExecutor,不与 CodeAct + 控制代码混用。 + +在具备隔离 Runtime 前,不应在多租户生产服务中直接执行不可信模型生成代码。 diff --git a/examples/codeact_with_generation_method/.env b/examples/codeact_with_generation_method/.env new file mode 100644 index 000000000..dc791393a --- /dev/null +++ b/examples/codeact_with_generation_method/.env @@ -0,0 +1,4 @@ +# Set TRPC_AGENT_API_KEY、TRPC_AGENT_BASE_URL、TRPC_AGENT_MODEL_NAME +TRPC_AGENT_API_KEY=your-api-key +TRPC_AGENT_BASE_URL=your-base-url +TRPC_AGENT_MODEL_NAME=your-model-name diff --git a/examples/codeact_with_generation_method/README.md b/examples/codeact_with_generation_method/README.md new file mode 100644 index 000000000..1db61bbeb --- /dev/null +++ b/examples/codeact_with_generation_method/README.md @@ -0,0 +1,255 @@ +# CodeAct Generation Method 示例 + +本示例演示如何使用 `CodeActTool` 实现函数体为 `...` 的函数。模型根据函数 +签名、类型标注和 docstring 生成 Python 实现,框架执行代码并校验返回类型。 +这类函数在 NOOA 中称为 Generation Method(生成方法)。 + +示例先使用 `prefer_approved` 生成并审批候选,再重新创建 +`approved_only` Agent,共享同一个文件仓库并执行审批代码。 + +## 关键特性 + +- 使用函数签名和 docstring 定义生成契约 +- 使用 Pydantic 模型校验输入和输出 +- 保存模型生成的代码候选 +- 人工审批候选版本并支持回滚 +- 对比 `prefer_approved` 与 `approved_only` 的执行行为 +- 函数契约或可用工具变化后自动拒绝不兼容版本 +- 支持 InMemory、File、Redis 和 SQL 实现仓库 + +## Agent 层级结构说明 + +```text +root_agent (LlmAgent) +└── tools + └── analyze_order_operations (CodeActTool) + ├── no approved version: nested CodeAct Agent generates code + └── approved version: CodeAct Runtime executes stored code directly +``` + +关键文件: + +- [agent/agent.py](./agent/agent.py):订单模型、Generation Method 和 + `CodeActTool` 配置 +- [agent/config.py](./agent/config.py):模型配置 +- [run_agent.py](./run_agent.py):生成、审批和复用流程 +- [.env.example](./.env.example):环境变量模板 + +## 关键代码解释 + +Generation Method 只有函数声明,没有确定性函数体: + +```python +def analyze_order_operations( + orders: list[OrderRecord], + overdue_days: int = 3, + high_value_threshold: float = 1500.0, +) -> OrderOperationsReport: + """Analyze order operations using deterministic business rules.""" + ... +``` + +示例将它包装为 `CodeActTool`: + +```python +CodeActTool( + analyze_order_operations, + implementation_store=implementation_store, + implementation_policy=implementation_policy, +) +``` + +第一阶段使用 `prefer_approved`: + +1. 根据函数签名验证参数; +2. 查询与当前函数契约和可用工具兼容的审批版本; +3. 因测试仓库为空,启动嵌套 CodeAct Agent 生成实现; +4. 执行代码并按照 `OrderOperationsReport` 校验结果; +5. 保存、打印并审批候选版本。 + +第二阶段关闭第一阶段 Runner,重新创建 `approved_only` Agent: + +1. 从同一个 File Store 读取审批版本; +2. 不创建 Nested Agent,直接将审批代码交给 CodeAct Runtime; +3. 使用新的 `high_value_threshold` 参数执行代码; +4. 按照相同输出 Schema 校验 `return_result`; +5. 检查候选版本集合没有变化; + +`run_agent.py` 每次启动都会清理示例目录中的 `.codeact` 测试仓库,确保第一 +阶段一定从无审批版本开始。示例会自动调用 `approve()`,仅用于展示策略切换; +生产环境不能照搬自动审批逻辑。 + +## 环境要求 + +- Python 3.10+,推荐 Python 3.12 +- 可用的 OpenAI 兼容模型服务 +- 默认使用 `InProcessCodeActRuntime` + +> `InProcessCodeActRuntime` 会在 Agent 宿主进程执行模型生成的 Python, +> 不提供安全隔离,仅适用于受控开发和测试环境。 + +## 构建步骤 + +```bash +git clone https://github.com/trpc-group/trpc-agent-python.git +cd trpc-agent-python +./build.sh +source .venv/bin/activate +``` + +## 运行步骤 + +### 配置环境变量 + +```bash +cp examples/codeact_with_generation_method/.env.example \ + examples/codeact_with_generation_method/.env +``` + +在 `.env` 中填写: + +```bash +TRPC_AGENT_API_KEY=... +TRPC_AGENT_BASE_URL=... +TRPC_AGENT_MODEL_NAME=... +``` + +### 运行命令 + +```bash +cd examples/codeact_with_generation_method +python3 run_agent.py +``` + +## 测试方式 + +### 1. `prefer_approved` 与 `approved_only` 对比测试 + +直接运行示例。脚本会创建并清理专用的 `.codeact` File Store,然后依次执行: + +1. 使用阈值 `1500` 调用函数; +2. `prefer_approved` 在没有审批版本时生成候选代码; +3. 打印并审批候选; +4. 关闭第一阶段 Agent; +5. 使用相同 Store 创建 `approved_only` Agent; +6. 将阈值改为 `2100` 再次调用; +7. 只执行审批代码,并确认没有产生新候选版本。 + +预期关键输出: + +```text +=== Phase 1: prefer_approved === +Policy: prefer_approved +Store: FileCodeActImplementationStore +Candidate: +Approved: + +=== Phase 2: approved_only === +Policy: approved_only +Store: FileCodeActImplementationStore +approved_only reused: +executed directly by the CodeAct runtime +no nested Agent or generation model was called +``` + +第二阶段仍会调用父 Agent 模型生成 Tool Call 和最终回答,但 +`CodeActTool` 内部不会再次调用模型生成函数实现。 + +### 2. 纯动态生成测试 + +如果只需要验证模型能否生成正确实现,不需要保存或复用代码,可以使用: + +```python +tool = CodeActTool( + analyze_order_operations, + model=model, + implementation_policy="dynamic", +) +``` + +未显式配置 Store 时,每次调用都会动态生成,且不会保存候选。如果希望保留 +动态生成记录用于后续评估,可以显式传入实现仓库;`dynamic` 仍然不会读取 +审批版本。 + +### 3. 审批版本强制执行测试 + +先在测试环境通过 `prefer_approved` 生成、测试并审批候选,然后使用同一个 +持久化仓库创建 `approved_only` Tool: + +```python +store = FileCodeActImplementationStore("/tmp/codeact-generation-method-test") + +tool = CodeActTool( + analyze_order_operations, + implementation_store=store, + implementation_policy="approved_only", +) +``` + +存在兼容审批版本时会直接执行;不存在审批版本,或者函数签名、docstring、 +输入输出 Schema、可用工具发生变化时,会直接报错,不会调用模型兜底。 + +### 4. 单元测试 + +```bash +.venv/bin/pytest -q tests/tools/test_code_act_tool.py +.venv/bin/pytest -q tests/tools/test_code_act_implementation.py +``` + +前者验证 Generation Method 的生成、审批、复用和默认内存仓库;后者验证 +File、Redis 和 SQL 实现仓库的版本生命周期。 + +## 运行结果分析 + +示例中的五条订单预期得到: + +```json +{ + "status_counts": { + "paid": 1, + "shipped": 2, + "delivered": 1, + "cancelled": 1 + }, + "active_amount": 3000.0, + "overdue_order_ids": ["O-1001", "O-1005"], + "high_value_customer_ids": ["C-A"] +} +``` + +审批后第二次调用将 `high_value_threshold` 改为 `2100`,预期 +`high_value_customer_ids` 变为 `[]`。这说明复用的是函数实现代码,而不是 +第一次调用的计算结果。 + +需要注意,第二次调用仍会使用父 Agent 模型生成 Tool Call 和最终自然语言 +回答;省略的是 `CodeActTool` 内部用于生成函数实现的嵌套模型调用。 + +## 测试环境使用建议 + +- 优先使用 `dynamic` 或 `prefer_approved` +- 使用 InMemory Store 快速验证单进程流程 +- 使用独立 File、Redis 或 SQL 命名空间,避免污染生产审批记录 +- 保存每个候选版本的测试结果,再决定是否审批 +- 对边界输入、异常输入和外部工具失败进行回归测试 +- 函数签名、docstring 或能力列表变化后重新生成并审批 +- 示例的自动审批只用于演示,不应作为测试通过的判定条件 + +## 生产环境使用建议 + +- 使用 `approved_only`,禁止在请求链路中临时生成生产代码 +- 使用 Redis 或 SQL 作为多进程、多 Worker 的共享实现仓库 +- File Store 仅用于确认所有请求固定落在同一主机、同一文件系统的场景 +- 将候选生成、测试、审批和生产执行拆分为独立阶段 +- 审批接口必须接入身份认证、权限控制、审计记录和版本回滚 +- 审批前执行业务测试、安全审查和资源消耗检查 +- 使用明确的审批人身份,不要把 `approved_by` 当作身份认证机制 +- 固定生产依赖和可用工具,避免 `capability_hash` 意外变化 +- 不要在多租户生产服务中直接使用默认进程内运行时执行不可信模型代码 +- 使用容器、VM 或其他 OS 级隔离,并限制文件系统、网络、凭证和执行资源 + +## 适用场景建议 + +- 业务规则复杂但可通过输入输出契约明确描述 +- 希望积累和迭代模型生成的稳定实现 +- 希望审批后减少嵌套模型调用和结果波动 +- 需要对生成代码进行版本管理、回滚和跨 Worker 复用 diff --git a/examples/codeact_with_generation_method/agent/__init__.py b/examples/codeact_with_generation_method/agent/__init__.py new file mode 100644 index 000000000..bc6e483f9 --- /dev/null +++ b/examples/codeact_with_generation_method/agent/__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/examples/codeact_with_generation_method/agent/agent.py b/examples/codeact_with_generation_method/agent/agent.py new file mode 100644 index 000000000..e766ca644 --- /dev/null +++ b/examples/codeact_with_generation_method/agent/agent.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. +"""Example of a CodeAct generation method.""" + +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.codeact import ( + CodeActImplementationPolicy, + FileCodeActImplementationStore, +) +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.tools import CodeActTool + +from .config import get_model_config + + +class OrderRecord(BaseModel): + """One order included in the operations analysis.""" + + order_id: str + customer_id: str + amount: float + status: Literal["paid", "shipped", "delivered", "cancelled"] + days_since_update: int + + +class OrderOperationsReport(BaseModel): + """Structured operations report generated by the incomplete function.""" + + status_counts: dict[str, int] + active_amount: float + overdue_order_ids: list[str] + high_value_customer_ids: list[str] + + +def analyze_order_operations( + orders: list[OrderRecord], + overdue_days: int = 3, + high_value_threshold: float = 1500.0, +) -> OrderOperationsReport: + """Analyze order operations using deterministic business rules. + + Count every order by status. Exclude cancelled orders from active_amount + and customer spending. An order is overdue when its status is paid or + shipped and days_since_update is at least overdue_days. A customer is high + value when their non-cancelled order total is at least + high_value_threshold. Sort both returned ID lists. + """ + ... # noqa: PIE790 # CodeActTool implementation marker + + +def create_agent( + code_act_dir: Path, + implementation_policy: CodeActImplementationPolicy | str, +) -> LlmAgent: + """Create an Agent that can invoke an LLM-implemented function tool.""" + api_key, base_url, model_name = get_model_config() + model = OpenAIModel( + model_name=model_name, + api_key=api_key, + base_url=base_url, + ) + implementation_store = FileCodeActImplementationStore(code_act_dir) + return LlmAgent( + name="code_act_function_demo", + model=model, + instruction=( + "Use analyze_order_operations whenever the user asks to analyze " + "order operations. Pass every supplied order without omission." + ), + tools=[ + CodeActTool( + analyze_order_operations, + implementation_store=implementation_store, + implementation_policy=implementation_policy, + ) + ], + ) diff --git a/examples/codeact_with_generation_method/agent/config.py b/examples/codeact_with_generation_method/agent/config.py new file mode 100644 index 000000000..74f13f07e --- /dev/null +++ b/examples/codeact_with_generation_method/agent/config.py @@ -0,0 +1,21 @@ +# Tencent is pleased to support the open 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 configuration for the CodeAct function-implementation example.""" + +import os + + +def get_model_config() -> tuple[str, str, str]: + """Get the configured OpenAI-compatible model.""" + api_key = os.getenv("TRPC_AGENT_API_KEY", "") + url = os.getenv("TRPC_AGENT_BASE_URL", "") + model_name = os.getenv("TRPC_AGENT_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 diff --git a/examples/codeact_with_generation_method/run_agent.py b/examples/codeact_with_generation_method/run_agent.py new file mode 100644 index 000000000..172aeafaf --- /dev/null +++ b/examples/codeact_with_generation_method/run_agent.py @@ -0,0 +1,166 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Run the CodeAct generation-method example.""" + +import asyncio +import shutil +import uuid +from pathlib import Path + +from dotenv import load_dotenv + +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.tools import CodeActTool +from trpc_agent_sdk.types import Content, Part + +load_dotenv(Path(__file__).with_name(".env")) + + +async def run_once(runner: Runner, *, title: str, query: str, expected: str) -> None: + """Run one parent-Agent invocation and display its tool lifecycle.""" + print(f"\n📌 {title}") + print(f"📝 User: {query}") + print(f"🎯 Expected: {expected}") + + async for event in runner.run_async( + user_id="demo_user", + session_id=str(uuid.uuid4()), + new_message=Content(parts=[Part.from_text(text=query)]), + ): + if not event.content: + continue + for part in event.content.parts: + if part.thought: + continue + if part.function_call: + print( + f"\n🔧 [Invoke Tool: " + f"{part.function_call.name}({part.function_call.args})]" + ) + elif part.function_response: + print(f"\n📦 [Tool Response: {part.function_response.response}]") + elif part.text and event.is_final_response(): + print(f"\n✅ [Final Answer]\n{part.text}") + + +async def main() -> None: + """Generate under prefer_approved, then enforce approved_only.""" + from agent.agent import create_agent + + current_dir = Path(__file__).parent + code_act_dir = current_dir / ".codeact" + shutil.rmtree(code_act_dir, ignore_errors=True) + query = ( + "请务必调用 analyze_order_operations 分析以下订单,不要绕过工具" + "直接回答。参数 overdue_days=3、high_value_threshold=1500。" + "订单列表:" + "[" + '{"order_id":"O-1001","customer_id":"C-A","amount":1200,' + '"status":"paid","days_since_update":5},' + '{"order_id":"O-1002","customer_id":"C-B","amount":300,' + '"status":"shipped","days_since_update":1},' + '{"order_id":"O-1003","customer_id":"C-A","amount":800,' + '"status":"delivered","days_since_update":0},' + '{"order_id":"O-1004","customer_id":"C-C","amount":1500,' + '"status":"cancelled","days_since_update":2},' + '{"order_id":"O-1005","customer_id":"C-D","amount":700,' + '"status":"shipped","days_since_update":6}' + "]。" + ) + + print("🚀 TRPC Agent CodeAct Generation Method Example") + + prefer_agent = create_agent(code_act_dir, "prefer_approved") + prefer_runner = Runner( + app_name="codeact_prefer_approved_demo", + agent=prefer_agent, + session_service=InMemorySessionService(), + ) + prefer_tool = next( + tool for tool in prefer_agent.tools if isinstance(tool, CodeActTool) + ) + print("\n=== Phase 1: prefer_approved ===") + print(f"📦 Policy: {prefer_tool.implementation_policy.value}") + print(f"💾 Store: {type(prefer_tool.implementation_store).__name__}") + try: + await run_once( + prefer_runner, + title="Generate a candidate because no approval exists", + query=query, + expected=( + "active_amount = 3000; overdue = [O-1001, O-1005]; " + "high_value_customers = [C-A]" + ), + ) + + candidates = await prefer_tool.list_implementations() + if not candidates: + raise RuntimeError("No implementation candidate was saved") + candidate = candidates[-1] + print(f"\n🧾 [Candidate: {candidate.version}]") + print("\n\n".join(candidate.code_cells)) + + approved = await prefer_tool.approve( + candidate.version, + approved_by="generation-method-example", + ) + print(f"\n✅ [Approved: {approved.version}]") + candidate_versions = { + implementation.version for implementation in candidates + } + finally: + await prefer_runner.close() + if prefer_tool.implementation_store is not None: + await prefer_tool.implementation_store.close() + + approved_agent = create_agent(code_act_dir, "approved_only") + approved_runner = Runner( + app_name="codeact_approved_only_demo", + agent=approved_agent, + session_service=InMemorySessionService(), + ) + approved_tool = next( + tool for tool in approved_agent.tools if isinstance(tool, CodeActTool) + ) + print("\n=== Phase 2: approved_only ===") + print(f"📦 Policy: {approved_tool.implementation_policy.value}") + print(f"💾 Store: {type(approved_tool.implementation_store).__name__}") + try: + await run_once( + approved_runner, + title="Execute only the approved implementation", + query=query.replace( + "high_value_threshold=1500", + "high_value_threshold=2100", + ), + expected=( + "active_amount = 3000; overdue = [O-1001, O-1005]; " + "high_value_customers = []" + ), + ) + stored_versions = { + implementation.version + for implementation in await approved_tool.list_implementations() + } + if stored_versions != candidate_versions: + raise RuntimeError( + "approved_only unexpectedly changed implementation candidates" + ) + print( + f"\n🔒 [approved_only reused: {approved.version}; " + "executed directly by the CodeAct runtime; no nested Agent or " + "generation model was called]" + ) + finally: + await approved_runner.close() + if approved_tool.implementation_store is not None: + await approved_tool.implementation_store.close() + shutil.rmtree(code_act_dir, ignore_errors=True) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/codeact_with_tools/.env b/examples/codeact_with_tools/.env new file mode 100644 index 000000000..dc791393a --- /dev/null +++ b/examples/codeact_with_tools/.env @@ -0,0 +1,4 @@ +# Set TRPC_AGENT_API_KEY、TRPC_AGENT_BASE_URL、TRPC_AGENT_MODEL_NAME +TRPC_AGENT_API_KEY=your-api-key +TRPC_AGENT_BASE_URL=your-base-url +TRPC_AGENT_MODEL_NAME=your-model-name diff --git a/examples/codeact_with_tools/README.md b/examples/codeact_with_tools/README.md new file mode 100644 index 000000000..25bd91b45 --- /dev/null +++ b/examples/codeact_with_tools/README.md @@ -0,0 +1,303 @@ +# CodeAct Tools 示例 + +本示例演示模型如何生成 Python 控制代码,通过 Agent 已配置的工具获取数据, +并在同一次 invocation 中持续处理工具返回的大对象。NOOA 将这种能力称为 +CodeAct Tools。 + +用户只需要描述“使用 `load_numbers` 工具”,不需要了解 +`await self.load_numbers(...)` 等内部调用协议。CodeAct 系统指令负责指导模型 +生成正确的工具调用代码。 + +## 关键特性 + +- 通过生成的 Python 代码组合现有 Agent 工具 +- 同一次 invocation 中跨模型轮次保留 Python 变量 +- 使用 ObjectRef 将大对象保留在本地 ObjectStore +- 使用 `doc(self)` 和 `doc(value)` 渐进发现工具与对象能力 +- 使用 `return_result(...)` 返回结构化结果 +- 使用 Pydantic Schema 校验最终输出 +- 与传统 Tool Calling 真实对比请求大小、Token 和耗时 + +## Agent 层级结构说明 + +```text +root_agent (LlmAgent + CodeActConfig) +├── tool: load_numbers (FunctionTool) +└── CodeAct Runtime + ├── self.load_numbers(...) + ├── persistent Python globals + ├── ObjectStore / ObjectRef + └── return_result(...) +``` + +关键文件: + +- [agent/agent.py](./agent/agent.py):启用 CodeAct 的 Agent +- [agent/prompts.py](./agent/prompts.py):两轮 CodeAct 测试约束 +- [agent/tools.py](./agent/tools.py):`load_numbers` 工具 +- [run_agent.py](./run_agent.py):持久变量与 ObjectRef 示例 +- [compare_tool_calling.py](./compare_tool_calling.py):真实模型对比程序 +- [.env](./.env):模型配置 + +## 关键代码解释 + +Agent 将普通 `FunctionTool` 与 `CodeActConfig` 同时配置: + +```python +agent = LlmAgent( + name="codeact_calculator", + model=model, + tools=[FunctionTool(load_numbers)], + code_act=CodeActConfig(), + output_schema=CalculationResult, +) +``` + +模型可以先发现当前可用能力: + +```python +print(doc(self)) +print(doc(self.load_numbers)) +``` + +框架根据工具名称从当前 Agent 的工具列表中找到真实 `FunctionTool`。生成代码 +实际执行时,工具调用形式为: + +```python +nums = await self.load_numbers(count=100000) +``` + +`load_numbers` 返回十万个整数。该列表不会完整反馈给模型,而是保存在当前 +Runtime 的 ObjectStore。模型看到的是包含类型、长度和有限预览的 ObjectRef。 + +第一轮代码将对象保存到全局变量 `nums`: + +```python +nums = await self.load_numbers(count=100000) +print(len(nums)) +print(doc(nums)) +``` + +第二轮代码复用同一个变量,不重新调用工具: + +```python +total = sum(number * number for number in nums if number % 7 == 0) +return_result({ + "expression": "sum(n*n for n in range(100000) if n % 7 == 0)", + "value": total, +}) +``` + +预期结果为 `47616904735715`。 + +## 环境要求 + +- Python 3.10+,推荐 Python 3.12 +- 可用的 OpenAI 兼容模型服务 +- 足够容纳对比测试请求的模型上下文窗口 +- 默认使用 `InProcessCodeActRuntime` + +> `InProcessCodeActRuntime` 会在 Agent 宿主进程执行模型生成的 Python, +> 不提供 OS 级安全隔离,仅适用于受控开发和测试环境。 + +## 构建步骤 + +```bash +git clone https://github.com/trpc-group/trpc-agent-python.git +cd trpc-agent-python +./build.sh +source .venv/bin/activate +``` + +## 运行步骤 + +### 配置环境变量 + +在 [examples/codeact_with_tools/.env](./.env) 中配置,或通过 `export` 设置: + +```bash +TRPC_AGENT_API_KEY=... +TRPC_AGENT_BASE_URL=... +TRPC_AGENT_MODEL_NAME=... +``` + +### 运行基础示例 + +```bash +cd examples/codeact_with_tools +python3 run_agent.py +``` + +### 运行真实模型对比 + +从仓库根目录执行: + +```bash +python3 examples/codeact_with_tools/compare_tool_calling.py +``` + +## 测试方式 + +### 1. 工具发现测试 + +运行基础示例后,第一轮代码执行结果应包含 `load_numbers` 能力或返回对象描述。 +如果模型不确定工具名称,可以通过以下代码发现能力: + +```python +print(doc(self)) +``` + +预期能力列表中包含: + +```json +{ + "name": "load_numbers", + "description": "Return integers from zero up to, but excluding, count." +} +``` + +### 2. 持久变量测试 + +`run_agent.py` 要求模型使用两个 CodeAct Cell: + +1. 第一轮调用一次 `load_numbers`,将结果保存为 `nums`; +2. 第二轮复用 `nums` 完成计算; +3. 第二轮不能再次调用工具或重新创建数据。 + +运行输出应只出现一次 `load_numbers` 调用,最终结果应为: + +```json +{ + "expression": "sum(n*n for n in range(100000) if n % 7 == 0)", + "value": 47616904735715 +} +``` + +如果第二轮提示 `nums` 未定义,说明两轮代码没有运行在同一个 Runtime +invocation 中,不符合本测试要求。 + +### 3. ObjectRef 大对象测试 + +第一轮执行: + +```python +print(len(nums)) +print(doc(nums)) +``` + +预期输出包含: + +- `length: 100000` +- 对象类型 +- 有限长度的预览 +- 不包含十万个整数的完整序列化结果 + +这说明数据保留在本地 ObjectStore,模型只接收有界观察结果。 + +### 4. 真实 Tool Calling 对比测试 + +`compare_tool_calling.py` 使用: + +- 同一个真实模型 +- 同一个 `load_numbers` 工具 +- 同一个查询和输出 Schema +- 传统 Tool Calling 与 CodeAct 两条路径 + +默认对象规模为四万个整数: + +```bash +python3 examples/codeact_with_tools/compare_tool_calling.py +``` + +程序会输出: + +- `LLM calls`:真实模型请求次数 +- `Tool calls`:工具实际执行次数 +- `2nd request chars`:第二轮完整请求字符数 +- `Prompt/Completion`:Provider 返回的 Token 数据 +- `Elapsed ms`:包含网络调用的总耗时 +- `Status`:结果是否正确 + +CodeAct 的第二轮请求不应包含完整整数列表,因此 `2nd request chars` 和 Prompt +Token 应显著低于传统 Tool Calling。 + +### 5. 不同对象规模测试 + +可以调整数据规模: + +```bash +CODEACT_COMPARISON_COUNT=20000 \ +python3 examples/codeact_with_tools/compare_tool_calling.py +``` + +建议依次测试: + +- `1000`:小对象基线,差距通常不明显 +- `20000`:可观察明显的请求大小差异 +- `40000`:默认大对象测试 +- 更大值:用于验证传统路径的上下文窗口边界 + +对象过大时,传统 Tool Calling 可能因完整 FunctionResponse 超过上下文窗口而 +失败;CodeAct 仍只反馈 ObjectRef 的有界描述。 + +### 6. 单元测试 + +```bash +.venv/bin/pytest -q tests/agents/test_codeact.py +.venv/bin/pytest -q tests/tools/test_code_act_tool.py +``` + +这些测试覆盖 CodeAct 协议、Runtime 状态、工具代理、结构化结果和 +Generation Method。真实模型的大对象 Token 对比必须运行示例脚本,不能由 +Mock 模型替代。 + +## 运行结果分析 + +基础示例满足以下条件时才算通过: + +- `load_numbers` 只执行一次 +- 第一轮创建 `nums` +- 第二轮复用同一个 `nums` +- ObjectRef 输出不包含完整大对象 +- `return_result` 返回正确结构 +- 最终值为 `47616904735715` + +对比测试主要验证数据传输方式,而不是证明 CodeAct 在所有任务中都更快: + +- 传统 Tool Calling 会把完整工具结果序列化到下一轮模型上下文; +- CodeAct 将大对象留在 Runtime,只返回代码执行观察; +- 对于小对象或已经封装好计算逻辑的高层工具,CodeAct 不一定更快; +- 网络延迟会波动,但大对象是否进入模型上下文是稳定的结构差异。 + +## 测试环境使用建议 + +- 使用无敏感数据和无副作用的测试工具 +- 为 Agent 设置合理的模型调用、工具调用和迭代上限 +- 从小对象开始,逐步增加 `CODEACT_COMPARISON_COUNT` +- 同时记录请求字符数和 Provider Token,避免只比较墙钟时间 +- 检查模型是否重复调用工具、重新创建变量或伪造执行结果 +- 每个并发任务使用独立 invocation,不要依赖跨 invocation 的 Python 变量 +- 测试失败后检查 executable code 和 code execution result,而不只看最终回答 + +## 生产环境使用建议 + +- 不要在多租户服务中直接执行不可信模型代码 +- 使用容器、VM 或其他 OS 级隔离,并限制 CPU、内存、执行时间和进程数 +- 限制文件系统、网络出口、环境变量和凭证访问 +- 只暴露最小必要工具,避免数据库连接、存储管理器或高权限客户端直接可见 +- 工具名称必须唯一,并为参数和返回值提供明确类型与 docstring +- 对有副作用的工具增加权限校验、幂等控制、审计和人工确认 +- 当前进程内 Runtime 状态不能跨进程或跨 Worker 迁移 +- 同一次 invocation 必须固定在同一个 Worker +- ObjectRef 是进程内引用,不能作为分布式消息直接传输 +- Skill 或用户提供的代码应继续通过 Container/Cube CodeExecutor 执行,不要与 + CodeAct 控制代码混用 +- 生产使用前必须替换或扩展为具备 OS 级隔离的 CodeAct Runtime + +## 适用场景建议 + +- 工具返回大量数据,但只需要在本地完成过滤、聚合或验证 +- 任务需要多步 Python 控制流和跨轮变量状态 +- 希望减少大对象在模型上下文中的重复 JSON 往返 +- 需要组合多个窄权限工具完成复杂计算 +- 不适合简单问答、小对象调用或高风险副作用操作 diff --git a/examples/codeact_with_tools/agent/__init__.py b/examples/codeact_with_tools/agent/__init__.py new file mode 100644 index 000000000..bc6e483f9 --- /dev/null +++ b/examples/codeact_with_tools/agent/__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/examples/codeact_with_tools/agent/agent.py b/examples/codeact_with_tools/agent/agent.py new file mode 100644 index 000000000..cb99509cf --- /dev/null +++ b/examples/codeact_with_tools/agent/agent.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 Apache-2.0. +""" Agent module""" + +from pydantic import BaseModel + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.codeact import CodeActConfig +from trpc_agent_sdk.models import LLMModel, OpenAIModel +from trpc_agent_sdk.tools import FunctionTool + +from .config import get_model_config +from .prompts import INSTRUCTION +from .tools import load_numbers + + +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 + + + + +class CalculationResult(BaseModel): + expression: str + value: float + +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. + """ + agent = LlmAgent( + name="codeact_calculator", + model=_create_model(), + instruction=INSTRUCTION, + tools=[FunctionTool(load_numbers)], + code_act=CodeActConfig(), + output_schema=CalculationResult, + ) + return agent + + +root_agent = create_agent() diff --git a/examples/codeact_with_tools/agent/config.py b/examples/codeact_with_tools/agent/config.py new file mode 100644 index 000000000..db0d491b8 --- /dev/null +++ b/examples/codeact_with_tools/agent/config.py @@ -0,0 +1,19 @@ +# Tencent is pleased to support the open 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 config module""" + +import os + + +def get_model_config() -> tuple[str, str, str]: + """Get model config from environment variables""" + api_key = os.getenv('TRPC_AGENT_API_KEY', '') + url = os.getenv('TRPC_AGENT_BASE_URL', '') + model_name = os.getenv('TRPC_AGENT_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 diff --git a/examples/codeact_with_tools/agent/prompts.py b/examples/codeact_with_tools/agent/prompts.py new file mode 100644 index 000000000..0d0d89eca --- /dev/null +++ b/examples/codeact_with_tools/agent/prompts.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. +"""Prompts for the CodeAct persistence example.""" + +INSTRUCTION = """ +Complete the task in exactly two CodeAct Python cells. + +In the first cell: +- Use the available load_numbers tool once to load the requested numbers. +- Save the returned value in a global variable named nums. +- The tool returns range(count), from 0 through count - 1. +- Print len(nums) and doc(nums) so the result can be observed. +- Do not perform the final calculation. +- Do not call return_result. + +In the second cell: +- Reuse the existing nums variable from the first cell. +- Do not call the load_numbers tool again and do not recreate nums. +- Perform and verify the requested calculation. +- Ensure the expression describes the exact range. +- Call return_result with the final structured result. +""".strip() diff --git a/examples/codeact_with_tools/agent/tools.py b/examples/codeact_with_tools/agent/tools.py new file mode 100644 index 000000000..5294a5c97 --- /dev/null +++ b/examples/codeact_with_tools/agent/tools.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. +""" Tools for the agent. """ + +def load_numbers(count: int) -> list[int]: + """Return integers from zero up to, but excluding, count.""" + return list(range(count)) diff --git a/examples/codeact_with_tools/compare_tool_calling.py b/examples/codeact_with_tools/compare_tool_calling.py new file mode 100644 index 000000000..c9d42dc2c --- /dev/null +++ b/examples/codeact_with_tools/compare_tool_calling.py @@ -0,0 +1,275 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Run a real-model large-object comparison for Tool Calling and CodeAct.""" + +from __future__ import annotations + +import asyncio +import json +import os +import time +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from agent.config import get_model_config +from dotenv import load_dotenv +from pydantic import BaseModel + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.codeact import CodeActConfig +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.models import LLMModel, LlmRequest, LlmResponse, 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 + +load_dotenv(Path(__file__).with_name(".env")) + +COUNT = int(os.getenv("CODEACT_COMPARISON_COUNT", "40000")) +DIVISOR = 7 +EXPECTED_VALUE = sum( + number * number + for number in range(COUNT) + if number % DIVISOR == 0 +) +QUERY = ( + f"必须调用 load_numbers(count={COUNT}) 恰好一次。计算返回数据中所有能被 " + f"{DIVISOR} 整除数字的平方和,并返回精确的数据范围、表达式和数值。" +) + + +class CalculationResult(BaseModel): + """Structured result required from both real-model paths.""" + + expression: str + value: int + + +@dataclass +class RunMetrics: + """Observed metrics from one real Agent invocation.""" + + mode: str + llm_calls: int = 0 + tool_calls: int = 0 + request_chars: list[int] = field(default_factory=list) + prompt_tokens: int = 0 + completion_tokens: int = 0 + elapsed_ms: float = 0 + result: str = "" + error: str = "" + + @property + def total_tokens(self) -> int: + return self.prompt_tokens + self.completion_tokens + + @property + def second_request_chars(self) -> int | None: + return self.request_chars[1] if len(self.request_chars) > 1 else None + + +def create_model() -> LLMModel: + """Create the configured real OpenAI-compatible model.""" + api_key, base_url, model_name = get_model_config() + return OpenAIModel( + model_name=model_name, + api_key=api_key, + base_url=base_url, + ) + + +def create_load_numbers(metrics: RunMetrics) -> FunctionTool: + """Create the same large-object tool for both paths.""" + + def load_numbers(count: int) -> list[int]: + """Return integers from zero up to, but excluding, count.""" + metrics.tool_calls += 1 + return list(range(count)) + + return FunctionTool(load_numbers) + + +def create_callbacks(metrics: RunMetrics): + """Record actual requests and provider-reported usage.""" + + async def before_model(_context, request: LlmRequest): + metrics.llm_calls += 1 + request_data = request.model_dump(mode="json", exclude_none=True) + serialized = json.dumps( + request_data, + ensure_ascii=False, + separators=(",", ":"), + ) + metrics.request_chars.append(len(serialized)) + + async def after_model(_context, response: LlmResponse): + if response.error_code: + metrics.error = ( + f"{response.error_code}: " + f"{response.error_message or 'model call failed'}" + ) + usage = response.usage_metadata + if usage is None: + return + metrics.prompt_tokens += usage.prompt_token_count or 0 + metrics.completion_tokens += usage.candidates_token_count or 0 + + return before_model, after_model + + +def create_agent(mode: str, metrics: RunMetrics) -> LlmAgent: + """Create a real-model Agent using the same low-level tool.""" + before_model, after_model = create_callbacks(metrics) + common: dict[str, Any] = { + "name": f"{mode.lower().replace(' ', '_')}_large_object_agent", + "model": create_model(), + "tools": [create_load_numbers(metrics)], + "output_schema": CalculationResult, + "before_model_callback": before_model, + "after_model_callback": after_model, + } + if mode == "Tool Calling": + common["instruction"] = ( + "You must use ordinary function calling. Call load_numbers exactly " + "once with the requested count. Inspect the returned complete list, " + "then return the requested structured result. Do not skip the tool." + ) + else: + common.update( + instruction=( + "Complete the task in exactly two CodeAct cells. First call " + "await self.load_numbers(count=...), save the result in global " + "variable nums, print len(nums) and doc(nums), and do not call " + "return_result. In the second cell reuse nums, calculate and " + "verify the answer, then call return_result. Never load again." + ), + code_act=CodeActConfig(), + ) + return LlmAgent(**common) + + +async def run_once(mode: str) -> RunMetrics: + """Run one real invocation and preserve failures as benchmark results.""" + metrics = RunMetrics(mode=mode) + app_name = f"codeact_real_comparison_{mode.lower().replace(' ', '_')}" + user_id = "comparison_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, + ) + runner = Runner( + app_name=app_name, + agent=create_agent(mode, metrics), + session_service=session_service, + ) + + events: list[Event] = [] + started = time.perf_counter() + try: + events = [ + event + async for event in runner.run_async( + user_id=user_id, + session_id=session_id, + new_message=Content(role="user", parts=[Part(text=QUERY)]), + ) + ] + metrics.result = extract_result(events) + if metrics.result == "(no final result)" and not metrics.error: + metrics.error = "Model returned no final result" + elif not metrics.error: + parsed = json.loads(metrics.result) + if parsed.get("value") != EXPECTED_VALUE: + metrics.error = ( + f"Incorrect result: expected {EXPECTED_VALUE}, " + f"got {parsed.get('value')}" + ) + except Exception as exc: # noqa: BLE001 + metrics.error = f"{type(exc).__name__}: {exc}" + finally: + metrics.elapsed_ms = (time.perf_counter() - started) * 1000 + return metrics + + +def extract_result(events: list[Event]) -> str: + """Extract the final structured result from either path.""" + codeact_results = [ + event.get_text() for event in events if event.object == "codeact.result" + ] + if codeact_results: + return codeact_results[-1] + final_texts = [ + event.get_text() + for event in events + if event.is_final_response() and event.get_text() + ] + return final_texts[-1] if final_texts else "(no final result)" + + +def format_optional(value: int | None) -> str: + """Format a metric that may be absent after an early failure.""" + return str(value) if value is not None else "N/A" + + +def print_results(results: list[RunMetrics]) -> None: + """Render measurements from the real provider calls.""" + print(f"Real-model large-object benchmark: load_numbers(count={COUNT})") + print("Both paths use the same model, tool, query, and output schema.\n") + print( + "Mode | LLM | Tool | 2nd request chars | Prompt | " + "Completion | Total | Elapsed ms | Status" + ) + print( + "-------------+-----+------+-------------------+--------+" + "------------+-------+------------+--------" + ) + for result in results: + status = "FAILED" if result.error else "OK" + print( + f"{result.mode:<12} | {result.llm_calls:>3} | " + f"{result.tool_calls:>4} | " + f"{format_optional(result.second_request_chars):>17} | " + f"{result.prompt_tokens:>6} | {result.completion_tokens:>10} | " + f"{result.total_tokens:>5} | {result.elapsed_ms:>10.1f} | {status}" + ) + + print("\nResults:") + for result in results: + detail = result.error or result.result + print(f"- {result.mode}: {detail}") + + traditional, codeact = results + if ( + traditional.second_request_chars is not None + and codeact.second_request_chars is not None + ): + reduction = ( + 1 + - codeact.second_request_chars / traditional.second_request_chars + ) + print( + f"\nCodeAct reduced the actual second request size by " + f"{reduction:.2%}." + ) + + +async def main() -> None: + """Run both mechanisms against the configured real model.""" + results = [ + await run_once("Tool Calling"), + await run_once("CodeAct"), + ] + print_results(results) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/codeact_with_tools/run_agent.py b/examples/codeact_with_tools/run_agent.py new file mode 100644 index 000000000..27b781766 --- /dev/null +++ b/examples/codeact_with_tools/run_agent.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. + +import asyncio +import uuid +from pathlib import Path + +from dotenv import load_dotenv + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.types import Content, Part + +# Load environment variables from this example's .env file. +load_dotenv(Path(__file__).with_name(".env")) + + +async def run_agent_demo( + *, + title: str, + query: str, + expected: str, + agent: LlmAgent, +) -> None: + """Run one CodeAct-related feature demonstration.""" + + app_name = f"codeact_demo_{agent.name}" + + session_service = InMemorySessionService() + runner = Runner(app_name=app_name, agent=agent, session_service=session_service) + + user_id = "demo_user" + + print(f"\n📌 Demo: {title}") + print(f"🤖 Agent: {agent.name}") + print( + f"🔧 Mode: " + f"{type(agent.code_act).__name__ if agent.code_act else 'CodeActTool'}" + ) + print(f"🎯 Expected: {expected}") + + session_id = str(uuid.uuid4()) + + print(f"📝 User: {query}") + + user_content = Content(parts=[Part.from_text(text=query)]) + + print("🤖 Assistant: ", end="", flush=True) + printed_partial = False + try: + 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 + + if event.partial: + printed_partial = True + for part in event.content.parts: + if part.text: + print(part.text, end="", flush=True) + continue + + for part in event.content.parts: + if part.thought: + continue + if event.object == "codeact.result" and part.text: + print(f"\n✅ [Final CodeAct Result]\n{part.text}") + elif part.function_call: + print( + f"\n🔧 [Invoke Tool: " + f"{part.function_call.name}({part.function_call.args})]" + ) + elif part.function_response: + print( + f"\n📦 [Tool Response: " + f"{part.function_response.response}]" + ) + elif part.executable_code: + print( + f"\n💻 [Executable Code]\n" + f"```python\n{part.executable_code.code}\n```" + ) + elif part.code_execution_result: + print( + f"\n✅ [Code Execution Result]\n" + f"```\n{part.code_execution_result.output}\n```" + ) + elif part.text and not printed_partial: + print(f"\n✅ [Final Answer]\n{part.text}") + finally: + await runner.close() + + print("\n" + "-" * 40) + + +async def main(): + from agent.agent import root_agent + + await run_agent_demo( + title="CodeAct 持久变量、ObjectRef 与 self 工具调用", + query=( + "加载前 100000 个自然数,计算其中所有能被 7 整除数字的平方和。" + "请严格复用第一轮加载的 nums,不要再次加载数据。" + ), + expected="value = 47616904735715", + agent=root_agent, + ) + + +if __name__ == "__main__": + print("=" * 60) + print("🚀 TRPC Agent CodeAct Tools Example") + print("=" * 60) + print() + asyncio.run(main()) diff --git a/mkdocs.yml b/mkdocs.yml index d6d370c63..beac2732d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -38,6 +38,7 @@ nav: - Goal: en/goal.md - Code Executor: en/code_executor.md - OpenAPI Tools: en/openapi_tools.md + - CodeAct: en/codeact.md - Skills: en/skill.md - Session & Memory: - Session: en/session.md @@ -85,6 +86,7 @@ nav: - Goal 工具: zh/goal.md - 代码执行器: zh/code_executor.md - OpenAPI 工具: zh/openapi_tools.md + - CodeAct: zh/codeact.md - Skills: zh/skill.md - Session 与 Memory: - Session: zh/session.md diff --git a/tests/agents/test_codeact.py b/tests/agents/test_codeact.py new file mode 100644 index 000000000..805e85633 --- /dev/null +++ b/tests/agents/test_codeact.py @@ -0,0 +1,599 @@ +# Tencent is pleased to support the open 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 opt-in CodeAct execution protocol.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import Any + +import pytest +from pydantic import BaseModel, PrivateAttr +from typing_extensions import override + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.code_executors import ( + BaseCodeExecutor, + CodeBlock, + CodeExecutionInput, + CodeExecutionResult, + create_code_execution_result, +) +from trpc_agent_sdk.codeact import ( + CODEACT_RESULT_PREFIX, + BaseCodeActRuntime, + CodeActConfig, + CodeActExecutionResult, + CodeActObjectRef, + CodeActObjectStore, + CodeActResponseProcessor, + InProcessCodeActRuntime, + create_codeact_execution_result, + parse_codeact_result, + prepare_codeact_code, +) +from trpc_agent_sdk.context import AgentContext, InvocationContext +from trpc_agent_sdk.models import LLMModel, LlmRequest, LlmResponse +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, GenerateContentResponseUsageMetadata, Part + + +async def _unused_model_factory(_: dict[str, Any]): + raise AssertionError("model factory should not be called by these tests") + + +class Answer(BaseModel): + value: int + + +class RecordingRuntime(BaseCodeActRuntime): + """Runtime test double that records prepared CodeAct cells.""" + + _last_cells: list[str] | None = PrivateAttr(default=None) + _result_value: Any = PrivateAttr(default=None) + + def __init__(self, result_value: Any = None, **data: Any): + super().__init__(**data) + self._result_value = result_value + + @property + def last_cells(self) -> list[str] | None: + return self._last_cells + + @override + async def execute( + self, + invocation_context: InvocationContext, + cells: list[str], + ) -> CodeActExecutionResult: + self._last_cells = cells + payload = json.dumps(self._result_value, ensure_ascii=False, separators=(",", ":")) + return create_codeact_execution_result(stdout=f"{CODEACT_RESULT_PREFIX}{payload}") + + +class RecordingExecutor(BaseCodeExecutor): + """Code executor test double used to verify independent routing.""" + + _last_input: CodeExecutionInput | None = PrivateAttr(default=None) + + @property + def last_input(self) -> CodeExecutionInput | None: + return self._last_input + + @override + async def execute_code( + self, + invocation_context: InvocationContext, + code_execution_input: CodeExecutionInput, + ) -> CodeExecutionResult: + self._last_input = code_execution_input + return create_code_execution_result(stdout="ordinary code executor") + + +class ScriptedModel(LLMModel): + """Model test double that returns scripted complete responses.""" + + def __init__( + self, + responses: list[str], + usage_metadata: GenerateContentResponseUsageMetadata | None = None, + ): + super().__init__(model_name="codeact-test-model") + self.responses = responses + self.call_count = 0 + self.requests: list[LlmRequest] = [] + self.usage_metadata = usage_metadata + + @classmethod + def supported_models(cls) -> list[str]: + return [r"codeact-test-model"] + + @property + def calls(self) -> int: + return self.call_count + + async def _generate_async_impl(self, request: LlmRequest, stream: bool = False, ctx=None): + self.requests.append(request) + text = self.responses[self.call_count] + self.call_count += 1 + yield LlmResponse( + content=Content(role="model", parts=[Part(text=text)]), + usage_metadata=self.usage_metadata, + ) + + def validate_request(self, request: LlmRequest) -> None: + return None + + +def _make_agent(runtime: BaseCodeActRuntime, **kwargs: Any) -> LlmAgent: + return LlmAgent( + name="codeact_agent", + model=_unused_model_factory, + code_act=CodeActConfig(runtime=runtime), + **kwargs, + ) + + +def test_prepare_codeact_code_injects_return_result_helper(): + code = prepare_codeact_code("return_result({'value': 7})") + + assert "def return_result(value):" in code + assert CODEACT_RESULT_PREFIX in code + assert code.endswith("return_result({'value': 7})") + + +def test_parse_codeact_result_validates_output_schema(): + output = f"Code execution result:\n{CODEACT_RESULT_PREFIX}" + '{"value":7}\n' + + parsed = parse_codeact_result(output, Answer) + + assert parsed.is_final + assert parsed.value == {"value": 7} + assert parsed.serialized == '{"value":7}' + + +def test_parse_codeact_result_returns_validation_feedback(): + output = f"{CODEACT_RESULT_PREFIX}" + '{"value":"not-an-integer"}' + + parsed = parse_codeact_result(output, Answer) + + assert not parsed.is_final + assert "validation failed" in (parsed.validation_error or "") + + +def test_object_store_keeps_large_values_by_reference_and_releases_them(): + store = CodeActObjectStore(inline_threshold=16, preview_chars=32) + + reference = store.wrap(list(range(20))) + + assert isinstance(reference, CodeActObjectRef) + assert len(reference) == 20 + assert reference[-1] == 19 + assert "builtins.list" in reference.describe() + assert len(repr(reference)) < 160 + + store.clear() + with pytest.raises(ReferenceError): + len(reference) + + +def test_codeact_uses_in_process_runtime_by_default(): + assert isinstance(CodeActConfig().runtime, InProcessCodeActRuntime) + + +def test_codeact_tools_require_capable_runtime(): + with pytest.raises(ValueError, match="supports_tools=True"): + LlmAgent( + name="codeact_agent", + model=_unused_model_factory, + code_act=CodeActConfig(runtime=RecordingRuntime()), + tools=[FunctionTool(lambda: "result")], + ) + + +@pytest.mark.asyncio +async def test_codeact_processor_emits_validated_result(): + runtime = RecordingRuntime({"value": 7}) + agent = _make_agent(runtime, output_schema=Answer) + ctx = SimpleNamespace( + invocation_id="inv-1", + agent=agent, + branch=None, + session=SimpleNamespace(state={}), + ) + response = LlmResponse( + content=Content( + role="model", + parts=[Part(text="```python\nreturn_result({'value': 7})\n```")], + )) + + events = [event async for event in CodeActResponseProcessor.run_async(ctx, response)] + + assert [event.object for event in events] == [None, "codeact.result"] + assert events[-1].get_text() == '{"value":7}' + assert runtime.last_cells is not None + assert "def return_result(value):" in runtime.last_cells[0] + assert events[0].content.parts[0].executable_code.code == "return_result({'value': 7})" + + +@pytest.mark.asyncio +async def test_codeact_processor_executes_only_first_cell(): + runtime = RecordingRuntime({"value": 7}) + agent = _make_agent(runtime, output_schema=Answer) + ctx = SimpleNamespace( + invocation_id="inv-single-cell", + agent=agent, + branch=None, + session=SimpleNamespace(state={}), + ) + response = LlmResponse( + content=Content( + role="model", + parts=[ + Part( + text=( + "```python\nfirst = 1\n```\n" + "This text incorrectly predicts the observation.\n" + "```python\nsecond = 2\n```" + )) + ], + )) + + events = [event async for event in CodeActResponseProcessor.run_async(ctx, response)] + + assert [event.object for event in events] == [None, "codeact.result"] + assert runtime.last_cells is not None + assert len(runtime.last_cells) == 1 + assert "first = 1" in runtime.last_cells[0] + assert "second = 2" not in runtime.last_cells[0] + assert response.content is None + + +@pytest.mark.asyncio +async def test_return_result_stops_current_cell_and_remaining_cells(): + runtime = InProcessCodeActRuntime() + ctx = SimpleNamespace( + invocation_id="inv-return-signal", + agent=SimpleNamespace(tools=[]), + ) + + result = await runtime.execute( + ctx, + [ + prepare_codeact_code( + "value = 1\nreturn_result({'value': value})\nvalue = 2" + ), + "value = 3", + ], + ) + observation = await runtime.execute(ctx, ["print(value)"]) + + assert CODEACT_RESULT_PREFIX in result.output + assert "Code execution error" not in result.output + assert observation.output == "Code execution result:\n1\n\n" + await runtime.release(ctx.invocation_id) + + +@pytest.mark.asyncio +async def test_llm_agent_codeact_retries_text_only_and_stops_on_result(): + model = ScriptedModel([ + "I should calculate this first.", + "```python\nreturn_result({'value': 7})\n```", + ]) + agent = LlmAgent( + name="codeact_agent", + model=model, + code_act=CodeActConfig(runtime=RecordingRuntime({"value": 7})), + output_schema=Answer, + ) + session_service = InMemorySessionService() + await session_service.create_session(app_name="test", user_id="u1", session_id="s1") + runner = Runner(app_name="test", agent=agent, session_service=session_service) + + events = [ + event async for event in runner.run_async( + user_id="u1", + session_id="s1", + new_message=Content(role="user", parts=[Part(text="Return seven")]), + ) + ] + + assert model.calls == 2 + assert model.requests[0].config.response_schema is None + assert "CodeAct execution mode" in str(model.requests[0].config.system_instruction) + assert not model.requests[0].config.tools + stored_session = await session_service.get_session(app_name="test", user_id="u1", session_id="s1") + assert any(event.object == "codeact.feedback" for event in stored_session.events) + assert all("I should calculate this first." not in event.get_text() for event in events) + final_event = next(event for event in events if event.object == "codeact.result") + assert final_event.get_text() == '{"value":7}' + + +@pytest.mark.asyncio +async def test_llm_agent_codeact_allows_text_when_result_is_not_required(): + model = ScriptedModel(["A plain text answer."]) + agent = LlmAgent( + name="text_codeact_agent", + model=model, + code_act=CodeActConfig( + runtime=RecordingRuntime(), + require_result=False, + ), + output_key="answer", + ) + session_service = InMemorySessionService() + await session_service.create_session(app_name="test", user_id="u1", session_id="text") + runner = Runner(app_name="test", agent=agent, session_service=session_service) + + events = [ + event async for event in runner.run_async( + user_id="u1", + session_id="text", + new_message=Content(role="user", parts=[Part(text="Answer in text")]), + ) + ] + + assert any(event.get_text() == "A plain text answer." for event in events) + assert not any(event.object == "codeact.feedback" for event in events) + stored_session = await session_service.get_session(app_name="test", user_id="u1", session_id="text") + assert stored_session.state["answer"] == "A plain text answer." + + +@pytest.mark.asyncio +async def test_llm_agent_codeact_preserves_usage_metadata(): + usage = GenerateContentResponseUsageMetadata( + prompt_token_count=10, + candidates_token_count=5, + total_token_count=15, + ) + model = ScriptedModel( + ["```python\nreturn_result({'value': 7})\n```"], + usage_metadata=usage, + ) + agent = LlmAgent( + name="usage_codeact_agent", + model=model, + code_act=CodeActConfig(runtime=RecordingRuntime({"value": 7})), + output_schema=Answer, + ) + session_service = InMemorySessionService() + await session_service.create_session(app_name="test", user_id="u1", session_id="usage") + runner = Runner(app_name="test", agent=agent, session_service=session_service) + + events = [ + event async for event in runner.run_async( + user_id="u1", + session_id="usage", + new_message=Content(role="user", parts=[Part(text="Return seven")]), + ) + ] + + final_event = next(event for event in events if event.object == "codeact.result") + assert final_event.usage_metadata == usage + assert len([event for event in events if event.usage_metadata]) == 1 + + +@pytest.mark.asyncio +async def test_llm_agent_codeact_accumulates_events_with_override_messages(): + model = ScriptedModel([ + "```python\nvalue = 7\nprint(value)\n```", + "```python\nreturn_result({'value': value})\n```", + ]) + agent = LlmAgent( + name="override_codeact_agent", + model=model, + code_act=CodeActConfig(runtime=InProcessCodeActRuntime()), + output_schema=Answer, + ) + session_service = InMemorySessionService() + session = await session_service.create_session( + app_name="test", + user_id="u1", + session_id="override", + ) + runner = Runner(app_name="test", agent=agent, session_service=session_service) + ctx = runner._new_invocation_context( + session, + agent_context=AgentContext(), + ) + ctx.override_messages = [ + Content(role="user", parts=[Part(text="Return seven")]), + ] + + events = [event async for event in agent._run_async_impl(ctx)] + + assert next(event for event in events if event.object == "codeact.result").get_text() == '{"value":7}' + second_request_parts = [ + part + for content in model.requests[1].contents + for part in content.parts + ] + assert any(part.executable_code for part in second_request_parts) + assert any(part.code_execution_result for part in second_request_parts) + + +@pytest.mark.asyncio +async def test_llm_agent_codeact_release_error_does_not_mask_result(): + + class FailingReleaseRuntime(RecordingRuntime): + + _release_calls: int = PrivateAttr(default=0) + + @property + def release_calls(self) -> int: + return self._release_calls + + @override + async def release(self, execution_id: str) -> None: + self._release_calls += 1 + raise RuntimeError(f"failed to release {execution_id}") + + model = ScriptedModel([ + "```python\nreturn_result({'value': 7})\n```", + ]) + runtime = FailingReleaseRuntime({"value": 7}) + agent = LlmAgent( + name="release_codeact_agent", + model=model, + code_act=CodeActConfig(runtime=runtime), + output_schema=Answer, + ) + session_service = InMemorySessionService() + await session_service.create_session(app_name="test", user_id="u1", session_id="release") + runner = Runner(app_name="test", agent=agent, session_service=session_service) + + events = [ + event async for event in runner.run_async( + user_id="u1", + session_id="release", + new_message=Content(role="user", parts=[Part(text="Return seven")]), + ) + ] + + assert next(event for event in events if event.object == "codeact.result").get_text() == '{"value":7}' + assert runtime.release_calls == 1 + + +@pytest.mark.asyncio +async def test_in_process_codeact_persists_object_refs_and_calls_tools(): + def make_values(count: int) -> list[int]: + """Build a list large enough to remain behind an ObjectRef.""" + return list(range(count)) + + model = ScriptedModel([ + ( + "```python\n" + "print(doc(self))\n" + "print(doc(self.make_values))\n" + "values = await self.make_values(count=300)\n" + "print(values)\n" + "print(doc(values))\n" + "```" + ), + "```python\nreturn_result({'value': values[-1]})\n```", + ]) + runtime = InProcessCodeActRuntime(inline_object_threshold=64) + agent = LlmAgent( + name="live_codeact_agent", + model=model, + tools=[FunctionTool(make_values)], + code_act=CodeActConfig(runtime=runtime), + output_schema=Answer, + ) + session_service = InMemorySessionService() + await session_service.create_session(app_name="test", user_id="u1", session_id="live") + runner = Runner(app_name="test", agent=agent, session_service=session_service) + + events = [ + event async for event in runner.run_async( + user_id="u1", + session_id="live", + new_message=Content(role="user", parts=[Part(text="Return the last generated value")]), + ) + ] + + assert model.calls == 2 + assert not model.requests[0].config.tools + assert any( + part.code_execution_result and "ObjectRef" in (part.code_execution_result.output or "") + for event in events + if event.content + for part in event.content.parts + ) + assert any( + part.code_execution_result and "make_values" in (part.code_execution_result.output or "") + for event in events + if event.content + for part in event.content.parts + ) + assert any( + part.code_execution_result and "count" in (part.code_execution_result.output or "") + for event in events + if event.content + for part in event.content.parts + ) + final_event = next(event for event in events if event.object == "codeact.result") + assert final_event.get_text() == '{"value":299}' + assert not runtime.has_execution(final_event.invocation_id) + + +@pytest.mark.asyncio +async def test_codeact_and_code_executor_use_independent_paths(): + model = ScriptedModel([ + "```python\nreturn_result({'value': 7})\n```", + ]) + runtime = RecordingRuntime({"value": 7}) + executor = RecordingExecutor() + agent = LlmAgent( + name="split_runtime_agent", + model=model, + code_act=CodeActConfig(runtime=runtime), + code_executor=executor, + output_schema=Answer, + ) + session_service = InMemorySessionService() + await session_service.create_session(app_name="test", user_id="u1", session_id="split") + runner = Runner(app_name="test", agent=agent, session_service=session_service) + + events = [ + event async for event in runner.run_async( + user_id="u1", + session_id="split", + new_message=Content(role="user", parts=[Part(text="Return seven")]), + ) + ] + + assert runtime.last_cells is not None + assert executor.last_input is None + assert next(event for event in events if event.object == "codeact.result").get_text() == '{"value":7}' + + +@pytest.mark.asyncio +async def test_codeact_tool_can_delegate_user_code_to_code_executor(): + executor = RecordingExecutor() + + async def execute_user_code(code: str, tool_context: InvocationContext) -> int: + """Execute user-provided code through the separately configured executor.""" + await executor.execute_code( + tool_context, + CodeExecutionInput(code_blocks=[CodeBlock(code=code, language="python")]), + ) + return 7 + + model = ScriptedModel([ + ( + "```python\n" + "value = await self.execute_user_code(code='print(7)')\n" + "return_result({'value': value})\n" + "```" + ), + ]) + agent = LlmAgent( + name="delegating_codeact_agent", + model=model, + tools=[FunctionTool(execute_user_code)], + code_act=CodeActConfig(runtime=InProcessCodeActRuntime()), + code_executor=executor, + output_schema=Answer, + ) + session_service = InMemorySessionService() + await session_service.create_session(app_name="test", user_id="u1", session_id="delegate") + runner = Runner(app_name="test", agent=agent, session_service=session_service) + + events = [ + event async for event in runner.run_async( + user_id="u1", + session_id="delegate", + new_message=Content(role="user", parts=[Part(text="Execute user code safely")]), + ) + ] + + assert executor.last_input is not None + assert executor.last_input.code_blocks[0].code == "print(7)" + assert next(event for event in events if event.object == "codeact.result").get_text() == '{"value":7}' diff --git a/tests/tools/test_code_act_implementation.py b/tests/tools/test_code_act_implementation.py new file mode 100644 index 000000000..971df8a6a --- /dev/null +++ b/tests/tools/test_code_act_implementation.py @@ -0,0 +1,158 @@ +# Tencent is pleased to support the open 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 CodeAct implementation persistence backends.""" + +from __future__ import annotations + +from contextlib import AbstractAsyncContextManager +from pathlib import Path +from typing import Any + +import pytest +from sqlalchemy import MetaData + +from trpc_agent_sdk.codeact import ( + CodeActImplementation, + FileCodeActImplementationStore, + RedisCodeActImplementationStore, + SqlCodeActImplementationStore, +) +from trpc_agent_sdk.storage import FileStorage, SqlStorage + + +def _implementation(result: int) -> CodeActImplementation: + return CodeActImplementation.create( + tool_name="add_numbers", + contract_hash="contract", + capability_hash="capabilities", + code_cells=[f"return_result({{'result': {result}}})"], + ) + + +async def _assert_version_lifecycle(store: Any) -> None: + first = _implementation(1) + second = _implementation(2) + assert await store.save_candidate(first) == first + assert await store.save_candidate(second) == second + assert { + implementation.version + for implementation in await store.list_implementations( + first.tool_name, + first.contract_hash, + ) + } == {first.version, second.version} + + await store.approve( + first.tool_name, + first.contract_hash, + second.version, + approved_by="reviewer", + ) + assert ( + await store.get_approved(first.tool_name, first.contract_hash) + == second + ) + + await store.approve( + first.tool_name, + first.contract_hash, + first.version, + approved_by="rollback", + ) + assert ( + await store.get_approved(first.tool_name, first.contract_hash) + == first + ) + + +@pytest.mark.asyncio +async def test_file_store_uses_shared_file_storage(tmp_path): + storage = FileStorage(tmp_path) + store = FileCodeActImplementationStore(storage=storage) + await _assert_version_lifecycle(store) + + reloaded = FileCodeActImplementationStore(storage=storage) + approved = await reloaded.get_approved("add_numbers", "contract") + assert approved is not None + assert approved.version == _implementation(1).version + + +@pytest.mark.asyncio +async def test_file_storage_rejects_path_escape(tmp_path): + storage = FileStorage(tmp_path) + with pytest.raises(ValueError, match="escapes root"): + await storage.write_text(Path("..") / "outside.json", "{}") + + +@pytest.mark.asyncio +async def test_sql_store_uses_sql_storage(tmp_path): + store = SqlCodeActImplementationStore( + db_url=f"sqlite:///{tmp_path / 'codeact.db'}", + ) + try: + await _assert_version_lifecycle(store) + finally: + await store.close() + + +@pytest.mark.asyncio +async def test_sql_store_can_share_existing_storage(tmp_path): + storage = SqlStorage( + is_async=False, + db_url=f"sqlite:///{tmp_path / 'shared.db'}", + metadata=MetaData(), + ) + store = SqlCodeActImplementationStore(storage=storage) + try: + await _assert_version_lifecycle(store) + finally: + await store.close() + await storage.close() + + +class _FakeRedisContext(AbstractAsyncContextManager[object]): + async def __aenter__(self) -> object: + return object() + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + del exc_type, exc_value, traceback + + +class _FakeRedisStorage: + def __init__(self) -> None: + self.values: dict[str, str] = {} + self.sets: dict[str, set[str]] = {} + + def create_db_session(self) -> _FakeRedisContext: + return _FakeRedisContext() + + async def execute_command(self, connection, command): + del connection + method = command.method.lower() + if method == "set": + key, value = command.args + if command.kwargs.get("nx") and key in self.values: + return None + self.values[key] = value + return True + if method == "get": + return self.values.get(command.args[0]) + if method == "sadd": + key, *values = command.args + target = self.sets.setdefault(key, set()) + before = len(target) + target.update(values) + return len(target) - before + if method == "smembers": + return self.sets.get(command.args[0], set()) + raise AssertionError(f"Unexpected Redis command: {method}") + + +@pytest.mark.asyncio +async def test_redis_store_uses_redis_storage(): + storage = _FakeRedisStorage() + store = RedisCodeActImplementationStore(storage=storage) + await _assert_version_lifecycle(store) diff --git a/tests/tools/test_code_act_tool.py b/tests/tools/test_code_act_tool.py new file mode 100644 index 000000000..a603b7f21 --- /dev/null +++ b/tests/tools/test_code_act_tool.py @@ -0,0 +1,329 @@ +# Tencent is pleased to support the open 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 LLM-implemented ellipsis function tools.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.codeact import ( + CodeActImplementation, + CodeActImplementationPolicy, + FileCodeActImplementationStore, + InMemoryCodeActImplementationStore, +) +from trpc_agent_sdk.models import LLMModel, LlmRequest, LlmResponse +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.tools import CodeActTool, is_code_act_function +from trpc_agent_sdk.types import Content, Part + + +def add_numbers(left: int, right: int) -> int: + """Add two integers.""" + ... # noqa: PIE790 # CodeActTool implementation marker + + +def implemented_function() -> int: + """Return a deterministic value.""" + return 1 + + +class ScriptedResponseModel(LLMModel): + """Return pre-built model responses for nested-Agent testing.""" + + def __init__(self, responses: list[LlmResponse]) -> None: + super().__init__(model_name="code_act-function-test-model") + self.responses = responses + self.call_count = 0 + self.requests: list[LlmRequest] = [] + + @classmethod + def supported_models(cls) -> list[str]: + return [r"code_act-function-test-model"] + + async def _generate_async_impl( + self, + request: LlmRequest, + stream: bool = False, + ctx: Any = None, + ): + del stream, ctx + self.requests.append(request) + response = self.responses[self.call_count] + self.call_count += 1 + yield response + + def validate_request(self, request: LlmRequest) -> None: + del request + + +def _text_response(text: str) -> LlmResponse: + return LlmResponse( + content=Content(role="model", parts=[Part.from_text(text=text)]) + ) + + +def test_code_act_function_detection_requires_ellipsis_body(): + assert is_code_act_function(add_numbers) + assert not is_code_act_function(implemented_function) + with pytest.raises(ValueError, match="body is `...`"): + CodeActTool(implemented_function) + + +def test_dynamic_policy_does_not_require_implementation_store(): + tool = CodeActTool(add_numbers) + + assert tool.implementation_policy is CodeActImplementationPolicy.DYNAMIC + assert tool.implementation_store is None + + +@pytest.mark.parametrize( + "policy", + [ + CodeActImplementationPolicy.PREFER_APPROVED, + "approved_only", + ], +) +def test_non_dynamic_policy_defaults_to_in_memory_store(policy): + tool = CodeActTool(add_numbers, implementation_policy=policy) + + assert isinstance( + tool.implementation_store, + InMemoryCodeActImplementationStore, + ) + + +@pytest.mark.asyncio +async def test_code_act_tool_generates_and_executes_code(): + nested_model = ScriptedResponseModel( + [ + _text_response( + "```python\n" + "arguments = await self.get_function_arguments()\n" + "value = arguments['left'] + arguments['right']\n" + "return_result({'result': value})\n" + "```" + ) + ] + ) + tool = CodeActTool(add_numbers, model=nested_model) + + call_part = Part.from_function_call( + name="add_numbers", + args={"left": 3, "right": 4}, + ) + call_part.function_call.id = "code_act-call" + parent_model = ScriptedResponseModel( + [ + LlmResponse( + content=Content(role="model", parts=[call_part]) + ), + _text_response("The generated implementation returned seven."), + ] + ) + agent = LlmAgent( + name="code_act_tool_parent", + model=parent_model, + tools=[tool], + ) + sessions = InMemorySessionService() + await sessions.create_session( + app_name="test", + user_id="u1", + session_id="code_act", + ) + runner = Runner( + app_name="test", + agent=agent, + session_service=sessions, + ) + + events = [ + event + async for event in runner.run_async( + user_id="u1", + session_id="code_act", + new_message=Content( + role="user", + parts=[Part.from_text(text="Add three and four")], + ), + ) + ] + + function_responses = [ + part.function_response.response + for event in events + if event.content + for part in event.content.parts + if part.function_response + ] + assert function_responses == [{"result": 7}] + assert nested_model.call_count == 1 + assert "add_numbers(left: int, right: int) -> int" in str( + nested_model.requests[0].config.system_instruction + ) + assert any( + event.get_text() == "The generated implementation returned seven." + for event in events + ) + await runner.close() + + +@pytest.mark.asyncio +async def test_code_act_tool_saves_approves_and_reuses_implementation( + monkeypatch, +): + nested_model = ScriptedResponseModel( + [ + _text_response( + "```python\n" + "arguments = await self.get_function_arguments()\n" + "print('arguments loaded')\n" + "```" + ), + _text_response( + "```python\n" + "return_result({'result': " + "arguments['left'] + arguments['right']})\n" + "```" + ), + ] + ) + store = InMemoryCodeActImplementationStore() + tool = CodeActTool( + add_numbers, + model=nested_model, + implementation_store=store, + implementation_policy="prefer_approved", + ) + + first_call = Part.from_function_call( + name="add_numbers", + args={"left": 3, "right": 4}, + ) + first_call.function_call.id = "first-code-act-call" + second_call = Part.from_function_call( + name="add_numbers", + args={"left": 10, "right": 5}, + ) + second_call.function_call.id = "second-code-act-call" + parent_model = ScriptedResponseModel( + [ + LlmResponse(content=Content(role="model", parts=[first_call])), + _text_response("first complete"), + LlmResponse(content=Content(role="model", parts=[second_call])), + _text_response("second complete"), + ] + ) + agent = LlmAgent( + name="code_act_tool_parent", + model=parent_model, + tools=[tool], + ) + sessions = InMemorySessionService() + await sessions.create_session( + app_name="test", + user_id="u1", + session_id="solidified", + ) + runner = Runner( + app_name="test", + agent=agent, + session_service=sessions, + ) + + first_events = [ + event + async for event in runner.run_async( + user_id="u1", + session_id="solidified", + new_message=Content( + role="user", + parts=[Part.from_text(text="Add three and four")], + ), + ) + ] + assert any( + part.function_response + and part.function_response.response == {"result": 7} + for event in first_events + if event.content + for part in event.content.parts + ) + candidates = await tool.list_implementations() + assert len(candidates) == 1 + await tool.approve(candidates[0].version, approved_by="reviewer") + + async def reject_nested_agent(**kwargs): + del kwargs + raise AssertionError( + "approved implementation must bypass the nested Agent" + ) + + monkeypatch.setattr(tool, "_run_nested_agent", reject_nested_agent) + + second_events = [ + event + async for event in runner.run_async( + user_id="u1", + session_id="solidified", + new_message=Content( + role="user", + parts=[Part.from_text(text="Add ten and five")], + ), + ) + ] + assert any( + part.function_response + and part.function_response.response == {"result": 15} + for event in second_events + if event.content + for part in event.content.parts + ) + assert nested_model.call_count == 2 + assert len(await tool.list_implementations()) == 1 + await runner.close() + + +@pytest.mark.asyncio +async def test_file_implementation_store_can_promote_and_rollback(tmp_path): + store = FileCodeActImplementationStore(tmp_path) + first = CodeActImplementation.create( + tool_name="add_numbers", + contract_hash="contract", + capability_hash="capabilities", + code_cells=["return_result({'result': 1})"], + ) + second = CodeActImplementation.create( + tool_name="add_numbers", + contract_hash="contract", + capability_hash="capabilities", + code_cells=["return_result({'result': 2})"], + ) + await store.save_candidate(first) + await store.save_candidate(second) + + await store.approve( + "add_numbers", + "contract", + second.version, + approved_by="reviewer", + ) + assert await store.get_approved("add_numbers", "contract") == second + + await store.approve("add_numbers", "contract", first.version) + assert await store.get_approved("add_numbers", "contract") == first + assert { + implementation.version + for implementation in await store.list_implementations( + "add_numbers", + "contract", + ) + } == {first.version, second.version} diff --git a/trpc_agent_sdk/agents/__init__.py b/trpc_agent_sdk/agents/__init__.py index 217bc67ad..b24fb1e32 100644 --- a/trpc_agent_sdk/agents/__init__.py +++ b/trpc_agent_sdk/agents/__init__.py @@ -11,6 +11,7 @@ """ from typing import Any +from trpc_agent_sdk.codeact import CodeActConfig from trpc_agent_sdk.configs import RunConfig from trpc_agent_sdk.context import InvocationContext from trpc_agent_sdk.context import new_invocation_context_id @@ -82,6 +83,7 @@ def __getattr__(name: str) -> Any: "ToolCallback", "ToolCallbackFilter", "ChainAgent", + "CodeActConfig", "CycleAgent", "LangGraphAgent", "LlmAgent", diff --git a/trpc_agent_sdk/agents/_llm_agent.py b/trpc_agent_sdk/agents/_llm_agent.py index b31844e07..f55339b83 100644 --- a/trpc_agent_sdk/agents/_llm_agent.py +++ b/trpc_agent_sdk/agents/_llm_agent.py @@ -26,7 +26,10 @@ from pydantic import BaseModel from pydantic import Field from pydantic import field_validator +from pydantic import model_validator +from trpc_agent_sdk.codeact import CodeActConfig +from trpc_agent_sdk.codeact import CodeActResponseProcessor from trpc_agent_sdk.code_executors import BaseCodeExecutor from trpc_agent_sdk.context import InvocationContext from trpc_agent_sdk.events import Event @@ -45,6 +48,7 @@ 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 ..exceptions import RunCancelledException from ._base_agent import BaseAgent @@ -67,6 +71,9 @@ # Type aliases for tool definitions ToolUnion: TypeAlias = Union[BaseTool, BaseToolSet] +CODEACT_FUNCTION_CALL_RETRY_MESSAGE = ("Traditional function calls are disabled in CodeAct mode. " + "Use a Python cell and call capabilities through await self.(...).") + class LlmAgent(BaseAgent): """LLM-based Agent for TRPC framework. @@ -107,6 +114,14 @@ class LlmAgent(BaseAgent): - BaseToolSet instances (will be expanded to individual tools) """ + code_act: Optional[CodeActConfig] = None + """Optional model-generated Python execution mode. + + When enabled, the model solves the task with Python code blocks and calls + ``return_result(value)`` to submit a validated final result. Its runtime is + configured on this object and remains independent from ``code_executor``. + """ + parallel_tool_calls: bool = False """Parallel tool call""" @@ -465,6 +480,7 @@ def accumulate_content(event: Event) -> None: if local_messages is not None and not event.partial and event.content: local_messages.append(event.content) + codeact_iterations = 0 try: running = agent_context.get_metadata(TRPC_AGENT_RUNNING_KEY, True) # Multi-turn conversation loop - continue until no more tool calls or code execution @@ -473,6 +489,15 @@ def accumulate_content(event: Event) -> None: await ctx.raise_if_cancelled() ctx.raise_if_limit(RunLimitType.MAX_ITERATIONS) + if self.code_act: + codeact_iterations += 1 + if codeact_iterations > self.code_act.max_iterations: + raise RunLimitException( + agent_name=self.name, + limit_type=RunLimitType.MAX_ITERATIONS, + configured_value=self.code_act.max_iterations, + observed_value=codeact_iterations, + ) # Step 1: Build request using the request processor (includes conversation history) request = LlmRequest(model=model_instance.name, ) @@ -488,8 +513,9 @@ def accumulate_content(event: Event) -> None: yield error_event return - # Step 1.5: Process code execution requests if code executor is configured - if self.code_executor: + # CodeAct owns model-generated Python cells. A separately + # configured code executor remains available to Skills/tools. + if self.code_executor and not self.code_act: async for event in CodeExecutionRequestProcessor.run_async(ctx, request): yield event @@ -499,6 +525,8 @@ def accumulate_content(event: Event) -> None: # Step 2: Call LLM and collect all responses collected_tool_calls = [] code_was_executed = False + codeact_text_only = False + codeact_usage_metadata = None ctx.raise_if_limit(RunLimitType.MAX_LLM_CALLS) logger.debug("Starting LLM call for agent: %s", self.name) @@ -511,27 +539,40 @@ def accumulate_content(event: Event) -> None: yield event return elif event.content: + if self.code_act and not event.partial and event.usage_metadata: + codeact_usage_metadata = event.usage_metadata + # 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: + if not event.is_streaming_tool_call(): 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) + if self.code_act and self.code_act.require_result: + codeact_text_only = True + else: + self._save_output_to_state(ctx, event) - # Process code execution responses if code executor is configured. + # Route model-generated cells to exactly one execution + # path. CodeAct and user/Skill code executors are separate. # 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): + codeact_final_event: Optional[Event] = None + code_processor = None + if self.code_act: + code_processor = CodeActResponseProcessor + elif self.code_executor: + code_processor = CodeExecutionResponseProcessor + if code_processor and event.content: + async for code_event in code_processor.run_async(ctx, event): + if code_event.object == "codeact.result": + codeact_final_event = code_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: @@ -539,16 +580,26 @@ def accumulate_content(event: Event) -> None: 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: + # CodeAct model output is control code, not a user-facing + # answer. Its executable-code and observation events are + # emitted below; only the validated codeact.result is final. + allow_codeact_text = bool(self.code_act and not self.code_act.require_result + and event.is_final_response() and not pending_code_events) + if event.content is not None and (not self.code_act or allow_codeact_text): yield event accumulate_content(event) # Then yield code execution events in order. + if pending_code_events and event.usage_metadata: + pending_code_events[-1].usage_metadata = event.usage_metadata + codeact_usage_metadata = None for code_event in pending_code_events: yield code_event + accumulate_content(code_event) + + if codeact_final_event is not None: + self._save_output_to_state(ctx, codeact_final_event) + return else: # Yield other events directly yield event @@ -557,10 +608,27 @@ def accumulate_content(event: Event) -> None: # CHECKPOINT 4: Before tool execution await ctx.raise_if_cancelled() + if self.code_act and collected_tool_calls: + feedback_event = Event( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + content=Content( + role="user", + parts=[Part(text=CODEACT_FUNCTION_CALL_RETRY_MESSAGE)], + ), + usage_metadata=codeact_usage_metadata, + visible=False, + object="codeact.feedback", + ) + yield feedback_event + accumulate_content(feedback_event) + logger.debug("CodeAct rejected %s traditional tool calls", len(collected_tool_calls)) + continue + # 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 @@ -665,13 +733,9 @@ def accumulate_content(event: Event) -> None: logger.debug("Tool execution completed, continuing conversation") continue - except RunCancelledException: + except (RunCancelledException, RunLimitException): # raise to runner to handle raise - - except RunLimitException: - raise - except Exception as ex: # pylint: disable=broad-except logger.error("Error executing tools for agent %s: %s", self.name, ex, exc_info=True) @@ -686,17 +750,33 @@ def accumulate_content(event: Event) -> None: # CHECKPOINT 6: After tool execution, before loop continuation await ctx.raise_if_cancelled() + if self.code_act and self.code_act.require_result and codeact_text_only and not code_was_executed: + feedback_event = Event( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + content=Content( + role="user", + parts=[Part(text=self.code_act.text_only_retry_message)], + ), + usage_metadata=codeact_usage_metadata, + visible=False, + object="codeact.feedback", + ) + yield feedback_event + accumulate_content(feedback_event) + logger.debug("CodeAct received a text-only response; requesting a Python cell") + continue + # 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: + except (RunCancelledException, RunLimitException): # raise to runner to handle raise - except RunLimitException: - raise except Exception as ex: # pylint: disable=broad-except logger.error("Unexpected error in LLM agent %s: %s", self.name, ex, exc_info=True) @@ -706,6 +786,17 @@ def accumulate_content(event: Event) -> None: "agent_execution_failed", f"Agent execution failed: {str(ex)}", ) + finally: + if self.code_act: + try: + await self.code_act.runtime.release(ctx.invocation_id) + except Exception as ex: # pylint: disable=broad-except + logger.error( + "Failed to release CodeAct runtime state for invocation %s: %s", + ctx.invocation_id, + ex, + exc_info=True, + ) def _save_output_to_state(self, ctx: InvocationContext, event: Event) -> None: """Save agent output to session state if output_key is configured. @@ -745,6 +836,15 @@ def __validate_code_executor(cls, code_executor: Optional[BaseCodeExecutor]) -> raise ValueError('Code executor must be an instance of BaseCodeExecutor.') return code_executor + @model_validator(mode="after") + def _validate_code_act_configuration(self) -> "LlmAgent": + """Validate the opt-in CodeAct execution contract.""" + if self.code_act is None: + return self + if self.tools and not self.code_act.runtime.supports_tools: + raise ValueError("code_act tools require a runtime with supports_tools=True") + return self + # Ensure forward references are resolved when this module is imported # This handles cases where LlmAgent is imported directly without going through __init__.py diff --git a/trpc_agent_sdk/agents/core/_request_processor.py b/trpc_agent_sdk/agents/core/_request_processor.py index 757556275..8ddfba099 100644 --- a/trpc_agent_sdk/agents/core/_request_processor.py +++ b/trpc_agent_sdk/agents/core/_request_processor.py @@ -197,7 +197,8 @@ def _set_generate_content_config(self, request: LlmRequest, agent: BaseAgent, 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: + if (agent.output_schema and hasattr(agent, 'tools') and not agent.tools + and not getattr(agent, "code_act", None)): # Set output schema on the llm request's parameter. It needs the model support. request.set_output_schema(agent.output_schema) @@ -249,8 +250,10 @@ async def _add_instructions_to_request(self, agent: BaseAgent, ctx: InvocationCo 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_act = getattr(agent, "code_act", None) + + # Add code executor instruction if code_executor is enabled. + if agent.code_executor and not code_act: code_executor_instruction = ( "# NOTICE\n" "YOU SHOULD NOT GENERATE CODE EXECUTION RESULT WHICH ARE PREFIXED WITH " @@ -262,6 +265,15 @@ async def _add_instructions_to_request(self, agent: BaseAgent, ctx: InvocationCo instructions_parts.append(code_executor_instruction) logger.debug("Added code executor instruction for agent: %s", agent.name) + if code_act: + supports_tools = code_act.runtime.supports_tools + instructions_parts.append( + code_act.build_instruction( + getattr(agent, "output_schema", None), + supports_tools=supports_tools, + )) + logger.debug("Added CodeAct instruction for agent: %s", agent.name) + # Build and set system prompt if we have instructions if instructions_parts: try: @@ -290,6 +302,11 @@ async def _add_tools_to_request(self, agent: BaseAgent, ctx: InvocationContext, Returns: Event: Error event if tool processing fails, None if successful """ + # CodeAct capabilities are discovered and called through the generated + # Python ``self`` proxy. Do not expose duplicate provider tool schemas. + if getattr(agent, "code_act", None): + return None + # Prepare tools list - start with agent's existing tools tools_to_process = agent.tools.copy() if agent.tools else [] @@ -395,6 +412,9 @@ async def _add_agent_transfer_capabilities(self, agent: BaseAgent, ctx: Invocati Returns: Event: Error event if transfer processing fails, None if successful """ + if getattr(agent, "code_act", None): + return None + # Only add transfer capabilities if the agent should support transfers if agent._should_enable_agent_transfer(): try: @@ -869,7 +889,8 @@ async def _add_output_schema_capabilities(self, agent: BaseAgent, ctx: Invocatio 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: + if (hasattr(agent, "output_schema") and agent.output_schema and agent.tools + and not getattr(agent, "code_act", None)): try: from ._output_schema_processor import default_output_schema_processor diff --git a/trpc_agent_sdk/codeact/__init__.py b/trpc_agent_sdk/codeact/__init__.py new file mode 100644 index 000000000..03a688057 --- /dev/null +++ b/trpc_agent_sdk/codeact/__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. +"""CodeAct control runtime, protocol, and live-object public interfaces.""" + +from .code_storage import BaseCodeActImplementationStore +from .code_storage import CodeActImplementation +from .code_storage import CodeActImplementationPolicy +from .code_storage import FileCodeActImplementationStore +from .code_storage import InMemoryCodeActImplementationStore +from .code_storage import RedisCodeActImplementationStore +from .code_storage import SqlCodeActImplementationStore +from ._config import CODEACT_RESULT_PREFIX +from ._config import CodeActConfig +from ._config import ParsedCodeActResult +from ._config import parse_codeact_result +from ._config import prepare_codeact_code +from ._in_process_runtime import InProcessCodeActRuntime +from ._objects import CodeActAgentProxy +from ._objects import CodeActObjectRef +from ._objects import CodeActObjectStore +from ._objects import describe_codeact_object +from ._processor import CodeActResponseProcessor +from ._runtime import BaseCodeActRuntime +from ._runtime import CodeActExecutionResult +from ._runtime import create_codeact_execution_result + +__all__ = [ + "BaseCodeActImplementationStore", + "CodeActImplementation", + "CodeActImplementationPolicy", + "FileCodeActImplementationStore", + "InMemoryCodeActImplementationStore", + "RedisCodeActImplementationStore", + "SqlCodeActImplementationStore", + "CODEACT_RESULT_PREFIX", + "CodeActConfig", + "ParsedCodeActResult", + "parse_codeact_result", + "prepare_codeact_code", + "InProcessCodeActRuntime", + "CodeActAgentProxy", + "CodeActObjectRef", + "CodeActObjectStore", + "describe_codeact_object", + "CodeActResponseProcessor", + "BaseCodeActRuntime", + "CodeActExecutionResult", + "create_codeact_execution_result", +] diff --git a/trpc_agent_sdk/codeact/_config.py b/trpc_agent_sdk/codeact/_config.py new file mode 100644 index 000000000..118f46184 --- /dev/null +++ b/trpc_agent_sdk/codeact/_config.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. +# +# This CodeAct implementation is inspired by and adapted from +# NVIDIA NeMo Labs OO Agents (NOOA): +# https://github.com/NVIDIA-NeMo/labs-OO-Agents +# +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# 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. +"""CodeAct configuration and final-result protocol.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from typing import Any + +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field +from pydantic import ValidationError + +from ._in_process_runtime import InProcessCodeActRuntime +from ._runtime import BaseCodeActRuntime + +CODEACT_RESULT_PREFIX = "__TRPC_AGENT_CODEACT_RESULT__:" + +_CODEACT_PRELUDE = f"""\ +import json as __trpc_codeact_json + +def return_result(value): + \"\"\"Submit the final JSON-serializable result for this CodeAct invocation.\"\"\" + if hasattr(value, "model_dump"): + value = value.model_dump(mode="json") + print({CODEACT_RESULT_PREFIX!r} + __trpc_codeact_json.dumps( + value, ensure_ascii=False, separators=(",", ":") + )) + raise __trpc_codeact_return_signal() + +""" + + +class CodeActConfig(BaseModel): + """Configure the CodeAct protocol and its control-code runtime.""" + + model_config = ConfigDict(extra="forbid") + + runtime: BaseCodeActRuntime = Field(default_factory=InProcessCodeActRuntime) + """Runtime used to execute model-generated CodeAct Python cells.""" + + max_iterations: int = Field(default=10, ge=1) + """Maximum model/code iterations for this CodeAct invocation.""" + + require_result: bool = True + """Retry text-only model responses until ``return_result`` is used.""" + + text_only_retry_message: str = Field( + default=("CodeAct requires executable Python. Reply with a fenced ```python " + "cell and call return_result(value) only when the final answer is ready."), + min_length=1, + ) + """Model-visible feedback used when no executable Python was produced.""" + + def build_instruction( + self, + output_schema: type[BaseModel] | None = None, + *, + supports_tools: bool = False, + ) -> str: + """Build the model instruction for the CodeAct execution protocol.""" + lines = [ + "# CodeAct execution mode", + "Solve the task by writing one executable Python cell in a fenced ```python code block.", + "Emit exactly one code block per response and end the response immediately after it.", + "Do not claim or predict a cell's output before the runtime returns the observation.", + "Each cell runs in the configured CodeAct runtime.", + "A helper named return_result(value) is available in every Python cell.", + "Call return_result exactly once only when the task is complete.", + "The submitted value must be JSON-serializable.", + "Do not print or imitate internal protocol markers.", + "Do not use ordinary function/tool calls; Python code is the action interface.", + f"Complete the task within {self.max_iterations} model/code iterations.", + ] + if supports_tools: + lines.extend([ + "Use print(doc(self)) to discover the available self.(...) capabilities.", + "Use print(doc(self.)) to inspect one capability's detailed declaration.", + ("Tool names mentioned in task instructions refer to capabilities on self; " + "translate tool_name(...) to await self.tool_name(...)."), + "Call self.(...) with keyword arguments and await the result.", + ("Tool results may be ObjectRef values; use doc(value), " + "indexing, iteration, or public methods as needed."), + ]) + if output_schema is not None: + schema_json = json.dumps(output_schema.model_json_schema(), ensure_ascii=False, separators=(",", ":")) + lines.append(f"The return_result value must satisfy this JSON Schema:\n{schema_json}") + return "\n".join(lines) + + +@dataclass(frozen=True) +class ParsedCodeActResult: + """Parsed and validated final value emitted by ``return_result``.""" + + value: Any | None = None + serialized: str | None = None + validation_error: str | None = None + + @property + def is_final(self) -> bool: + """Whether a valid final result was found.""" + return self.serialized is not None + + +def prepare_codeact_code(code: str) -> str: + """Inject the ``return_result`` helper into one generated Python cell.""" + return _CODEACT_PRELUDE + code + + +def parse_codeact_result( + output: str, + output_schema: type[BaseModel] | None = None, +) -> ParsedCodeActResult: + """Parse the last ``return_result`` payload from runtime output.""" + matches = re.findall(re.escape(CODEACT_RESULT_PREFIX) + r"([^\r\n]*)", output or "") + if not matches: + return ParsedCodeActResult() + + payload = matches[-1] + try: + value = json.loads(payload) + except json.JSONDecodeError as exc: + return ParsedCodeActResult(validation_error=f"return_result emitted invalid JSON: {exc}") + + if output_schema is not None: + try: + validated = output_schema.model_validate(value) + except ValidationError as exc: + return ParsedCodeActResult(value=value, validation_error=f"return_result validation failed: {exc}") + value = validated.model_dump(mode="json") + + serialized = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, separators=(",", ":")) + return ParsedCodeActResult(value=value, serialized=serialized) diff --git a/trpc_agent_sdk/codeact/_in_process_runtime.py b/trpc_agent_sdk/codeact/_in_process_runtime.py new file mode 100644 index 000000000..db7b83ff7 --- /dev/null +++ b/trpc_agent_sdk/codeact/_in_process_runtime.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. +# +# This CodeAct implementation is inspired by and adapted from +# NVIDIA NeMo Labs OO Agents (NOOA): +# https://github.com/NVIDIA-NeMo/labs-OO-Agents +# +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# 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. +"""Unsafe persistent in-process runtime for CodeAct development.""" + +from __future__ import annotations + +import ast +import asyncio +import contextlib +import inspect +import io +import traceback +from dataclasses import dataclass +from typing import Any +from typing import ClassVar + +from pydantic import Field +from pydantic import PrivateAttr +from typing_extensions import override + +from trpc_agent_sdk.context import InvocationContext + +from ._objects import CodeActAgentProxy +from ._objects import CodeActObjectStore +from ._objects import describe_codeact_object +from ._runtime import BaseCodeActRuntime +from ._runtime import CodeActExecutionResult +from ._runtime import CodeActReturnSignal +from ._runtime import create_codeact_execution_result + + +@dataclass +class _ExecutionState: + globals: dict[str, Any] + store: CodeActObjectStore + agent_proxy: CodeActAgentProxy + + +class InProcessCodeActRuntime(BaseCodeActRuntime): + """Run persistent CodeAct control cells in the Agent host process. + + Warning: + Generated code has the same filesystem, network, environment, and + process privileges as the Agent host. This runtime is not a sandbox. + """ + + supports_tools: ClassVar[bool] = True + + inline_object_threshold: int = Field(default=256, ge=0) + object_preview_chars: int = Field(default=240, ge=32) + + _states: dict[str, _ExecutionState] = PrivateAttr(default_factory=dict) + _execution_lock: asyncio.Lock = PrivateAttr(default_factory=asyncio.Lock) + + @override + async def execute( + self, + invocation_context: InvocationContext, + cells: list[str], + ) -> CodeActExecutionResult: + """Execute Python cells while retaining globals for the invocation.""" + execution_id = invocation_context.invocation_id + state = self._states.get(execution_id) + if state is None: + state = self._create_state(invocation_context) + self._states[execution_id] = state + await state.agent_proxy.refresh(invocation_context) + + stdout = io.StringIO() + stderr = io.StringIO() + try: + async with self._execution_lock: + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + for cell in cells: + await self._execute_cell(cell, state.globals) + except CodeActReturnSignal: + pass + except Exception: # noqa: BLE001 # pylint: disable=broad-except + stderr.write(traceback.format_exc()) + + return create_codeact_execution_result(stdout=stdout.getvalue(), stderr=stderr.getvalue()) + + @override + async def release(self, execution_id: str) -> None: + """Release globals and live objects retained for one invocation.""" + state = self._states.pop(execution_id, None) + if state is not None: + state.store.clear() + + @override + async def close(self) -> None: + """Release every retained invocation.""" + for execution_id in list(self._states): + await self.release(execution_id) + + def has_execution(self, execution_id: str) -> bool: + """Return whether an invocation currently has persistent REPL state.""" + return execution_id in self._states + + def _create_state(self, context: InvocationContext) -> _ExecutionState: + store = CodeActObjectStore( + inline_threshold=self.inline_object_threshold, + preview_chars=self.object_preview_chars, + ) + proxy = CodeActAgentProxy(context, store) + globals_: dict[str, Any] = { + "__builtins__": __builtins__, + "__name__": "__codeact__", + "self": proxy, + "doc": describe_codeact_object, + "__trpc_codeact_return_signal": CodeActReturnSignal, + } + return _ExecutionState(globals=globals_, store=store, agent_proxy=proxy) + + @staticmethod + async def _execute_cell(code: str, globals_: dict[str, Any]) -> None: + compiled = compile( + code, + "", + "exec", + flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT, + dont_inherit=True, + ) + result = eval(compiled, globals_) # pylint: disable=eval-used + if inspect.isawaitable(result): + await result diff --git a/trpc_agent_sdk/codeact/_objects.py b/trpc_agent_sdk/codeact/_objects.py new file mode 100644 index 000000000..d81fcd6cd --- /dev/null +++ b/trpc_agent_sdk/codeact/_objects.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. +# +# This CodeAct implementation is inspired by and adapted from +# NVIDIA NeMo Labs OO Agents (NOOA): +# https://github.com/NVIDIA-NeMo/labs-OO-Agents +# +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# 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. +"""Live-object and Agent capability proxies for CodeAct runtimes.""" + +from __future__ import annotations + +import inspect +import json +import uuid +from typing import Any +from typing import TYPE_CHECKING + +from pydantic import BaseModel + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.exceptions import RunLimitType + +if TYPE_CHECKING: + from trpc_agent_sdk.tools import BaseTool + +_PRIMITIVE_TYPES = (str, int, float, bool, type(None)) + + +class CodeActObjectStore: + """Invocation-local storage for values kept out of model messages.""" + + def __init__(self, *, inline_threshold: int = 256, preview_chars: int = 240) -> None: + self.inline_threshold = inline_threshold + self.preview_chars = preview_chars + self._objects: dict[str, Any] = {} + + def wrap(self, value: Any) -> Any: + """Return small JSON values inline and retain larger values by reference.""" + if isinstance(value, CodeActObjectRef): + return value + if isinstance(value, _PRIMITIVE_TYPES) and (not isinstance(value, str) or len(value) <= self.inline_threshold): + return value + if self._can_inline(value): + return value + + object_id = uuid.uuid4().hex + self._objects[object_id] = value + return CodeActObjectRef(self, object_id) + + def unwrap(self, value: Any) -> Any: + """Resolve references recursively before invoking Python capabilities.""" + if isinstance(value, CodeActObjectRef): + return value._resolve() # pylint: disable=protected-access + if isinstance(value, list): + return [self.unwrap(item) for item in value] + if isinstance(value, tuple): + return tuple(self.unwrap(item) for item in value) + if isinstance(value, dict): + return {key: self.unwrap(item) for key, item in value.items()} + return value + + def get(self, object_id: str) -> Any: + """Resolve an object ID or raise after the invocation has been released.""" + try: + return self._objects[object_id] + except KeyError as exc: + raise ReferenceError(f"CodeAct object reference {object_id!r} is no longer available") from exc + + def clear(self) -> None: + """Release all invocation-local objects.""" + self._objects.clear() + + def preview(self, value: Any) -> str: + """Build a bounded representation suitable for model-visible output.""" + try: + rendered = repr(value) + except Exception: # noqa: BLE001 # pylint: disable=broad-except + rendered = f"<{type(value).__name__}>" + if len(rendered) > self.preview_chars: + rendered = rendered[:self.preview_chars] + "..." + return rendered + + def describe(self, value: Any) -> str: + """Describe an object without serializing its complete value.""" + details: dict[str, Any] = { + "type": f"{type(value).__module__}.{type(value).__qualname__}", + "preview": self.preview(value), + } + try: + details["length"] = len(value) + except (TypeError, AttributeError): + pass + + if isinstance(value, BaseModel): + details["schema"] = value.__class__.model_json_schema() + else: + public_names = [name for name in dir(value) if not name.startswith("_")] + details["public_members"] = public_names[:40] + if len(public_names) > 40: + details["members_truncated"] = True + return json.dumps(details, ensure_ascii=False, default=str) + + def _can_inline(self, value: Any) -> bool: + try: + rendered = json.dumps(value, ensure_ascii=False, default=self._reject_json_default) + except (TypeError, ValueError): + return False + return len(rendered) <= self.inline_threshold + + @staticmethod + def _reject_json_default(value: Any) -> Any: + raise TypeError(f"{type(value).__name__} is not directly JSON serializable") + + +class CodeActObjectRef: + """A bounded, invocation-local proxy for a live Python object.""" + + __slots__ = ("_object_id", "_store") + + def __init__(self, store: CodeActObjectStore, object_id: str) -> None: + self._store = store + self._object_id = object_id + + @property + def object_id(self) -> str: + """Stable ID within the current invocation.""" + return self._object_id + + def describe(self) -> str: + """Return bounded type, shape, preview, and public-member information.""" + return self._store.describe(self._resolve()) + + def _resolve(self) -> Any: + return self._store.get(self._object_id) + + def __repr__(self) -> str: + value = self._resolve() + return (f"ObjectRef(id={self._object_id!r}, type={type(value).__name__!r}, " + f"preview={self._store.preview(value)!r})") + + def __len__(self) -> int: + return len(self._resolve()) + + def __iter__(self): + for item in self._resolve(): + yield self._store.wrap(item) + + def __getitem__(self, key: Any) -> Any: + return self._store.wrap(self._resolve()[self._store.unwrap(key)]) + + def __getattr__(self, name: str) -> Any: + if name.startswith("_"): + raise AttributeError("private members are not exposed through ObjectRef") + member = getattr(self._resolve(), name) + if not callable(member): + return self._store.wrap(member) + + def call_member(*args: Any, **kwargs: Any) -> Any: + result = member( + *(self._store.unwrap(item) for item in args), + **{ + key: self._store.unwrap(item) + for key, item in kwargs.items() + }, + ) + if inspect.isawaitable(result): + + async def await_and_wrap(): + return self._store.wrap(await result) + + return await_and_wrap() + return self._store.wrap(result) + + return call_member + + +class CodeActAgentProxy: + """Expose only configured Agent tools to generated Python through ``self``.""" + + def __init__(self, context: InvocationContext, store: CodeActObjectStore) -> None: + self._context = context + self._store = store + self._tools: dict[str, "BaseTool"] = {} + + async def refresh(self, context: InvocationContext) -> None: + """Resolve dynamic toolsets for the current invocation.""" + from trpc_agent_sdk.tools import convert_toolunion_to_tool_list + + self._context = context + resolved = await convert_toolunion_to_tool_list(context.agent.tools, context) + self._tools = {tool.name: tool for tool in resolved} + + def describe(self) -> str: + """Return a compact capability catalog; detailed schemas remain on demand.""" + capabilities = [{ + "name": tool.name, + "description": tool.description or "", + } for tool in self._tools.values()] + return json.dumps({"capabilities": capabilities}, ensure_ascii=False) + + def __repr__(self) -> str: + return f"CodeActAgentProxy(tools={sorted(self._tools)!r})" + + def __getattr__(self, name: str) -> Any: + if name.startswith("_") or name not in self._tools: + raise AttributeError(f"CodeAct capability {name!r} is not available") + + tool = self._tools[name] + + async def invoke(*args: Any, **kwargs: Any) -> Any: + if args: + raise TypeError(f"self.{name} accepts keyword arguments only") + if tool.is_progress_streaming: + raise TypeError(f"self.{name} is progress-streaming and is not supported by CodeAct") + self._context.raise_if_limit(RunLimitType.MAX_TOOL_CALLS) + previous_call_id = self._context.function_call_id + self._context.function_call_id = f"codeact-{uuid.uuid4().hex}" + try: + result = await tool.run_async( + tool_context=self._context, + args={ + key: self._store.unwrap(value) + for key, value in kwargs.items() + }, + ) + finally: + self._context.function_call_id = previous_call_id + return self._store.wrap(result) + + invoke.__codeact_tool__ = tool # type: ignore[attr-defined] + return invoke + + +def describe_codeact_object(value: Any) -> str: + """Model-callable ``doc`` helper for proxies, references, and Python values.""" + if isinstance(value, (CodeActAgentProxy, CodeActObjectRef)): + return value.describe() + + tool = getattr(value, "__codeact_tool__", None) + if tool is not None: + details: dict[str, Any] = { + "name": tool.name, + "description": tool.description or "", + } + declaration = tool._get_declaration() # pylint: disable=protected-access + if declaration is not None: + details["declaration"] = (declaration.model_dump(mode="json", exclude_none=True) if isinstance( + declaration, BaseModel) else declaration) + return json.dumps(details, ensure_ascii=False, default=str) + + details: dict[str, Any] = { + "type": f"{type(value).__module__}.{type(value).__qualname__}", + "doc": inspect.getdoc(value) or "", + } + if callable(value): + try: + details["signature"] = str(inspect.signature(value)) + except (TypeError, ValueError): + pass + return json.dumps(details, ensure_ascii=False, default=str) diff --git a/trpc_agent_sdk/codeact/_processor.py b/trpc_agent_sdk/codeact/_processor.py new file mode 100644 index 000000000..6c9058729 --- /dev/null +++ b/trpc_agent_sdk/codeact/_processor.py @@ -0,0 +1,170 @@ +# Tencent is pleased to support the open 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 CodeAct implementation is inspired by and adapted from +# NVIDIA NeMo Labs OO Agents (NOOA): +# https://github.com/NVIDIA-NeMo/labs-OO-Agents +# +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# 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 processing for the CodeAct control runtime.""" + +from __future__ import annotations + +import copy +import re +from collections.abc import AsyncGenerator + +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 LlmResponse +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Outcome +from trpc_agent_sdk.types import Part + +from ._config import parse_codeact_result +from ._config import prepare_codeact_code +from ._config import CodeActConfig + +_PYTHON_FENCE_PATTERN = re.compile( + r"(?P`{3,}|~{3,})[ \t]*(?Ppython|py|python3)[ \t]*\n" + r"(?P.*?)(?:\n)?(?P=fence)", + re.IGNORECASE | re.DOTALL, +) + + +class CodeActResponseProcessor: + """Extract and execute model-generated CodeAct Python cells.""" + + @staticmethod + async def run_async( + invocation_context: InvocationContext, + llm_response: LlmResponse, + ) -> AsyncGenerator[Event, None]: + """Process one complete model response.""" + if llm_response.partial or not llm_response.content: + return + + agent = invocation_context.agent + code_act = getattr(agent, "code_act", None) + if code_act is None or not isinstance(code_act, CodeActConfig): + return + + runtime = code_act.runtime + response_content = copy.deepcopy(llm_response.content) + cells = _extract_first_cell_and_truncate_content(response_content) + if not cells: + return + + result = await runtime.execute( + invocation_context, + [prepare_codeact_code(cell) for cell in cells], + ) + + yield Event( + invocation_id=invocation_context.invocation_id, + author=agent.name, + branch=invocation_context.branch, + content=Content( + role="model", + parts=[Part.from_executable_code(code=cell, language="PYTHON") for cell in cells], + ), + actions=EventActions(), + ) + + if result.outcome == Outcome.OUTCOME_OK: + parsed_result = parse_codeact_result(result.output, getattr(agent, "output_schema", None)) + if parsed_result.is_final: + yield Event( + invocation_id=invocation_context.invocation_id, + author=agent.name, + branch=invocation_context.branch, + content=Content(role="model", parts=[Part(text=parsed_result.serialized)]), + object="codeact.result", + ) + _retain_non_code_parts(llm_response, response_content) + return + if parsed_result.validation_error: + yield Event( + invocation_id=invocation_context.invocation_id, + author=agent.name, + branch=invocation_context.branch, + content=Content( + role="user", + parts=[ + Part(text=(f"{parsed_result.validation_error}\n" + "Fix the value and call return_result again in a new Python cell.")) + ], + ), + visible=False, + object="codeact.validation_error", + ) + _retain_non_code_parts(llm_response, response_content) + return + + yield Event( + invocation_id=invocation_context.invocation_id, + author=agent.name, + branch=invocation_context.branch, + content=Content( + role="user", + parts=[Part.from_code_execution_result(outcome=result.outcome, output=result.output)], + ), + actions=EventActions(), + ) + _retain_non_code_parts(llm_response, response_content) + + +def _extract_first_cell_and_truncate_content(content: Content) -> list[str]: + """Extract one Python cell and discard speculative trailing content.""" + for index, part in enumerate(content.parts or []): + if part.executable_code and not getattr(part, "thought", False): + language = (part.executable_code.language or "python").lower() + if language in ("python", "py", "python3"): + content.parts = [ + preceding for preceding in content.parts[:index] + if preceding.text and not getattr(preceding, "thought", False) + ] + return [part.executable_code.code or ""] + + text_parts = [part for part in content.parts or [] if part.text and not getattr(part, "thought", False)] + if not text_parts: + return [] + + response_text = "\n".join(part.text for part in text_parts) + match = _PYTHON_FENCE_PATTERN.search(response_text) + if not match: + return [] + + text_before = response_text[:match.start()].strip() + content.parts = [Part(text=text_before)] if text_before else [] + return [match.group("code").strip()] + + +def _retain_non_code_parts(llm_response: LlmResponse, response_content: Content) -> None: + """Keep model text and provider function calls after CodeAct extraction.""" + retained_parts = [copy.deepcopy(part) for part in response_content.parts or [] if part.text] + 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 diff --git a/trpc_agent_sdk/codeact/_runtime.py b/trpc_agent_sdk/codeact/_runtime.py new file mode 100644 index 000000000..fe9fb4e74 --- /dev/null +++ b/trpc_agent_sdk/codeact/_runtime.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. +# +# This CodeAct implementation is inspired by and adapted from +# NVIDIA NeMo Labs OO Agents (NOOA): +# https://github.com/NVIDIA-NeMo/labs-OO-Agents +# +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# 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. +"""Runtime contracts for model-generated CodeAct Python cells.""" + +from __future__ import annotations + +import abc +from typing import ClassVar + +from pydantic import BaseModel + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.types import Outcome + + +class CodeActReturnSignal(BaseException): + """Internal control-flow signal raised after ``return_result``.""" + + +class CodeActExecutionResult(BaseModel): + """Result of executing one batch of CodeAct Python cells.""" + + outcome: Outcome = Outcome.OUTCOME_UNSPECIFIED + output: str = "" + + +def create_codeact_execution_result( + *, + stdout: str = "", + stderr: str = "", +) -> CodeActExecutionResult: + """Build a model-visible CodeAct execution result.""" + output = "" + outcome = Outcome.OUTCOME_OK + if stderr: + output = f"Code execution error:\n{stderr}\n" + outcome = Outcome.OUTCOME_FAILED + if stdout: + output += f"Code execution result:\n{stdout}\n" + return CodeActExecutionResult(outcome=outcome, output=output) + + +class BaseCodeActRuntime(BaseModel, abc.ABC): + """Execute CodeAct control cells independently from user code executors.""" + + model_config = {"arbitrary_types_allowed": True} + + supports_tools: ClassVar[bool] = False + """Whether cells can call configured Agent tools through ``self``.""" + + @abc.abstractmethod + async def execute( + self, + invocation_context: InvocationContext, + cells: list[str], + ) -> CodeActExecutionResult: + """Execute Python cells for one invocation.""" + + async def release(self, execution_id: str) -> None: + """Release state retained for one invocation.""" + + async def close(self) -> None: + """Release all resources retained by this runtime.""" diff --git a/trpc_agent_sdk/codeact/code_storage/__init__.py b/trpc_agent_sdk/codeact/code_storage/__init__.py new file mode 100644 index 000000000..e065c8722 --- /dev/null +++ b/trpc_agent_sdk/codeact/code_storage/__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. +"""CodeAct implementation storage.""" + +from ._base import BaseCodeActImplementationStore +from ._base import CodeActImplementation +from ._base import CodeActImplementationPolicy +from ._file import FileCodeActImplementationStore +from ._in_memory import InMemoryCodeActImplementationStore +from ._redis import RedisCodeActImplementationStore +from ._sql import SqlCodeActImplementationStore + +__all__ = [ + "BaseCodeActImplementationStore", + "CodeActImplementation", + "CodeActImplementationPolicy", + "FileCodeActImplementationStore", + "InMemoryCodeActImplementationStore", + "RedisCodeActImplementationStore", + "SqlCodeActImplementationStore", +] diff --git a/trpc_agent_sdk/codeact/code_storage/_base.py b/trpc_agent_sdk/codeact/code_storage/_base.py new file mode 100644 index 000000000..824b27f61 --- /dev/null +++ b/trpc_agent_sdk/codeact/code_storage/_base.py @@ -0,0 +1,115 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Versioned persistence for generated CodeAct function implementations.""" + +from __future__ import annotations + +import abc +import ast +import hashlib +import json +from datetime import datetime +from datetime import timezone +from enum import Enum +from typing import Any + +from pydantic import Field +from pydantic import BaseModel +from pydantic import ConfigDict + + +class CodeActImplementationPolicy(str, Enum): + """Select how a CodeActTool obtains its implementation.""" + + DYNAMIC = "dynamic" + PREFER_APPROVED = "prefer_approved" + APPROVED_ONLY = "approved_only" + + +class CodeActImplementation(BaseModel): + """One immutable generated implementation candidate.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + tool_name: str + contract_hash: str + capability_hash: str + version: str + code_hash: str + code_cells: tuple[str, ...] + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + metadata: dict[str, Any] = Field(default_factory=dict) + + @classmethod + def create( + cls, + *, + tool_name: str, + contract_hash: str, + capability_hash: str, + code_cells: list[str], + metadata: dict[str, Any] | None = None, + ) -> CodeActImplementation: + """Create a content-addressed candidate.""" + if not code_cells: + raise ValueError("A CodeAct implementation requires at least one cell") + for cell in code_cells: + ast.parse(cell, mode="exec") + code_hash = hashlib.sha256(json.dumps( + code_cells, + ensure_ascii=False, + separators=(",", ":"), + ).encode()).hexdigest() + return cls( + tool_name=tool_name, + contract_hash=contract_hash, + capability_hash=capability_hash, + version=code_hash[:16], + code_hash=code_hash, + code_cells=tuple(code_cells), + metadata=metadata or {}, + ) + + +class BaseCodeActImplementationStore(abc.ABC): + """Store generated candidates and one approved version per contract.""" + + @abc.abstractmethod + async def save_candidate( + self, + implementation: CodeActImplementation, + ) -> CodeActImplementation: + """Persist an immutable candidate and return the stored value.""" + + @abc.abstractmethod + async def list_implementations( + self, + tool_name: str, + contract_hash: str, + ) -> list[CodeActImplementation]: + """List candidates for a function contract.""" + + @abc.abstractmethod + async def get_approved( + self, + tool_name: str, + contract_hash: str, + ) -> CodeActImplementation | None: + """Return the approved implementation, if any.""" + + @abc.abstractmethod + async def approve( + self, + tool_name: str, + contract_hash: str, + version: str, + *, + approved_by: str | None = None, + ) -> CodeActImplementation: + """Promote a candidate. Re-approving an older version rolls back.""" + + async def close(self) -> None: + """Release resources owned by this store.""" diff --git a/trpc_agent_sdk/codeact/code_storage/_file.py b/trpc_agent_sdk/codeact/code_storage/_file.py new file mode 100644 index 000000000..252708946 --- /dev/null +++ b/trpc_agent_sdk/codeact/code_storage/_file.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. +"""File implementation store.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import re +from datetime import datetime +from datetime import timezone +from pathlib import Path +from typing_extensions import override + +from trpc_agent_sdk.storage import FileStorage + +from ._base import BaseCodeActImplementationStore +from ._base import CodeActImplementation + + +class FileCodeActImplementationStore(BaseCodeActImplementationStore): + """JSON implementation store built on :class:`FileStorage`.""" + + def __init__( + self, + root: str | Path | None = None, + *, + storage: FileStorage | None = None, + ) -> None: + if storage is None and root is None: + raise ValueError("File CodeAct storage requires root or storage") + self._storage = storage or FileStorage(root or "") + self._owns_storage = storage is None + self._lock = asyncio.Lock() + + @override + async def save_candidate( + self, + implementation: CodeActImplementation, + ) -> CodeActImplementation: + async with self._lock: + key = self._version_key( + implementation.tool_name, + implementation.contract_hash, + implementation.version, + ) + stored = await self._read_implementation(key) + if stored is not None: + if stored.code_hash != implementation.code_hash: + raise ValueError(f"CodeAct version collision for {implementation.version!r}") + return stored + await self._storage.write_text( + key, + implementation.model_dump_json(indent=2), + overwrite=False, + ) + stored = await self._read_implementation(key) + if stored is None: + raise RuntimeError("CodeAct candidate write did not persist") + return stored + + @override + async def list_implementations( + self, + tool_name: str, + contract_hash: str, + ) -> list[CodeActImplementation]: + directory = self._contract_key(tool_name, contract_hash) / "versions" + paths = await self._storage.list_files(directory, "*.json") + values = [ + implementation for path in paths if (implementation := await self._read_implementation(path)) is not None + ] + return sorted(values, key=lambda value: value.created_at) + + @override + async def get_approved( + self, + tool_name: str, + contract_hash: str, + ) -> CodeActImplementation | None: + content = await self._storage.read_text(self._approved_key(tool_name, contract_hash)) + if content is None: + return None + data = json.loads(content) + version = data.get("version") + if not isinstance(version, str): + raise TypeError("Invalid CodeAct approved pointer") + implementation = await self._read_implementation(self._version_key(tool_name, contract_hash, version)) + if implementation is None: + raise ValueError(f"Approved CodeAct implementation {version!r} is missing") + return implementation + + @override + async def approve( + self, + tool_name: str, + contract_hash: str, + version: str, + *, + approved_by: str | None = None, + ) -> CodeActImplementation: + async with self._lock: + implementation = await self._read_implementation(self._version_key(tool_name, contract_hash, version)) + if implementation is None: + raise KeyError(f"Unknown CodeAct implementation version {version!r}") + pointer = { + "version": version, + "approved_at": datetime.now(timezone.utc).isoformat(), + "approved_by": approved_by, + } + await self._storage.write_text( + self._approved_key(tool_name, contract_hash), + json.dumps(pointer, ensure_ascii=False, indent=2), + ) + return implementation + + @override + async def close(self) -> None: + if self._owns_storage: + await self._storage.close() + + @staticmethod + def _contract_key(tool_name: str, contract_hash: str) -> Path: + safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", tool_name).strip("._") + if not safe_name: + safe_name = "code_act_tool" + name_hash = hashlib.sha256(tool_name.encode()).hexdigest()[:8] + contract_dir = hashlib.sha256(contract_hash.encode()).hexdigest() + return Path(f"{safe_name}-{name_hash}") / contract_dir + + def _version_key( + self, + tool_name: str, + contract_hash: str, + version: str, + ) -> Path: + if re.fullmatch(r"[0-9a-f]{16}", version) is None: + raise ValueError(f"Invalid CodeAct implementation version {version!r}") + return (self._contract_key(tool_name, contract_hash) / "versions" / f"{version}.json") + + def _approved_key(self, tool_name: str, contract_hash: str) -> Path: + return self._contract_key(tool_name, contract_hash) / "approved.json" + + async def _read_implementation( + self, + key: str | Path, + ) -> CodeActImplementation | None: + content = await self._storage.read_text(key) + if content is None: + return None + return CodeActImplementation.model_validate_json(content) diff --git a/trpc_agent_sdk/codeact/code_storage/_in_memory.py b/trpc_agent_sdk/codeact/code_storage/_in_memory.py new file mode 100644 index 000000000..4f1b02efd --- /dev/null +++ b/trpc_agent_sdk/codeact/code_storage/_in_memory.py @@ -0,0 +1,82 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Process-local implementation store for tests and development.""" + +import asyncio +from typing_extensions import override + +from ._base import BaseCodeActImplementationStore +from ._base import CodeActImplementation + + +class InMemoryCodeActImplementationStore(BaseCodeActImplementationStore): + """Process-local implementation store for tests and development.""" + + def __init__(self) -> None: + self._implementations: dict[tuple[str, str, str], CodeActImplementation] = {} + self._approved: dict[tuple[str, str], str] = {} + self._lock = asyncio.Lock() + + @override + async def save_candidate( + self, + implementation: CodeActImplementation, + ) -> CodeActImplementation: + async with self._lock: + key = ( + implementation.tool_name, + implementation.contract_hash, + implementation.version, + ) + self._implementations.setdefault(key, implementation) + return self._implementations[key] + + @override + async def list_implementations( + self, + tool_name: str, + contract_hash: str, + ) -> list[CodeActImplementation]: + async with self._lock: + values = [ + value for (stored_tool, stored_contract, _), value in self._implementations.items() + if stored_tool == tool_name and stored_contract == contract_hash + ] + return sorted(values, key=lambda value: value.created_at) + + @override + async def get_approved( + self, + tool_name: str, + contract_hash: str, + ) -> CodeActImplementation | None: + async with self._lock: + version = self._approved.get((tool_name, contract_hash)) + if version is None: + return None + return self._implementations.get((tool_name, contract_hash, version)) + + @override + async def approve( + self, + tool_name: str, + contract_hash: str, + version: str, + *, + approved_by: str | None = None, + ) -> CodeActImplementation: + del approved_by + async with self._lock: + key = (tool_name, contract_hash, version) + implementation = self._implementations.get(key) + if implementation is None: + raise KeyError(f"Unknown CodeAct implementation version {version!r}") + self._approved[(tool_name, contract_hash)] = version + return implementation + + @override + async def close(self) -> None: + pass diff --git a/trpc_agent_sdk/codeact/code_storage/_redis.py b/trpc_agent_sdk/codeact/code_storage/_redis.py new file mode 100644 index 000000000..f6c72bd3f --- /dev/null +++ b/trpc_agent_sdk/codeact/code_storage/_redis.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. +"""Redis persistence for generated CodeAct implementations.""" + +from __future__ import annotations + +import hashlib +import json +import re +from datetime import datetime +from datetime import timezone +from typing import Any +from typing_extensions import override + +from trpc_agent_sdk.storage import RedisCommand +from trpc_agent_sdk.storage import RedisExpire +from trpc_agent_sdk.storage import RedisStorage + +from ._base import BaseCodeActImplementationStore +from ._base import CodeActImplementation + + +class RedisCodeActImplementationStore(BaseCodeActImplementationStore): + """Persist CodeAct versions through the shared :class:`RedisStorage`.""" + + def __init__( + self, + redis_url: str | None = None, + *, + storage: RedisStorage | None = None, + is_async: bool = True, + key_prefix: str = "trpc_agent:codeact", + **storage_kwargs: Any, + ) -> None: + if storage is None and redis_url is None: + raise ValueError("Redis CodeAct storage requires redis_url or storage") + self._storage = storage or RedisStorage( + redis_url=redis_url or "", + is_async=is_async, + **storage_kwargs, + ) + self._owns_storage = storage is None + self._key_prefix = key_prefix.rstrip(":") + + @override + async def save_candidate( + self, + implementation: CodeActImplementation, + ) -> CodeActImplementation: + key = self._version_key( + implementation.tool_name, + implementation.contract_hash, + implementation.version, + ) + created = await self._command( + "set", + key, + implementation.model_dump_json(), + nx=True, + ) + if created not in (True, b"OK", "OK"): + stored = await self._read_implementation(key) + if stored is None: + raise RuntimeError("Redis CodeAct candidate write did not persist") + if stored.code_hash != implementation.code_hash: + raise ValueError(f"CodeAct version collision for {implementation.version!r}") + implementation = stored + await self._command( + "sadd", + self._versions_key( + implementation.tool_name, + implementation.contract_hash, + ), + implementation.version, + ) + return implementation + + @override + async def list_implementations( + self, + tool_name: str, + contract_hash: str, + ) -> list[CodeActImplementation]: + versions = await self._command( + "smembers", + self._versions_key(tool_name, contract_hash), + ) or [] + values: list[CodeActImplementation] = [] + for value in versions: + version = self._text(value) + implementation = await self._read_implementation(self._version_key(tool_name, contract_hash, version)) + if implementation is not None: + values.append(implementation) + return sorted(values, key=lambda value: value.created_at) + + @override + async def get_approved( + self, + tool_name: str, + contract_hash: str, + ) -> CodeActImplementation | None: + pointer_value = await self._command( + "get", + self._approved_key(tool_name, contract_hash), + ) + if pointer_value is None: + return None + pointer = json.loads(self._text(pointer_value)) + version = pointer.get("version") + if not isinstance(version, str): + raise TypeError("Invalid Redis CodeAct approved pointer") + implementation = await self._read_implementation(self._version_key(tool_name, contract_hash, version)) + if implementation is None: + raise ValueError(f"Approved CodeAct implementation {version!r} is missing") + return implementation + + @override + async def approve( + self, + tool_name: str, + contract_hash: str, + version: str, + *, + approved_by: str | None = None, + ) -> CodeActImplementation: + implementation = await self._read_implementation(self._version_key(tool_name, contract_hash, version)) + if implementation is None: + raise KeyError(f"Unknown CodeAct implementation version {version!r}") + pointer = { + "version": version, + "approved_at": datetime.now(timezone.utc).isoformat(), + "approved_by": approved_by, + } + await self._command( + "set", + self._approved_key(tool_name, contract_hash), + json.dumps(pointer, ensure_ascii=False, separators=(",", ":")), + ) + return implementation + + @override + async def close(self) -> None: + if self._owns_storage: + await self._storage.close() + + async def _read_implementation( + self, + key: str, + ) -> CodeActImplementation | None: + value = await self._command("get", key) + if value is None: + return None + return CodeActImplementation.model_validate_json(self._text(value)) + + async def _command(self, method: str, *args: Any, **kwargs: Any) -> Any: + async with self._storage.create_db_session() as connection: + return await self._storage.execute_command( + connection, + RedisCommand( + method=method, + args=args, + kwargs=kwargs, + expire=RedisExpire(), + ), + ) + + def _scope_key(self, tool_name: str, contract_hash: str) -> str: + tool_hash = hashlib.sha256(tool_name.encode()).hexdigest()[:16] + contract_key = hashlib.sha256(contract_hash.encode()).hexdigest() + scope = f"{tool_hash}:{contract_key}" + return f"{self._key_prefix}:{{{scope}}}" + + def _versions_key(self, tool_name: str, contract_hash: str) -> str: + return f"{self._scope_key(tool_name, contract_hash)}:versions" + + def _version_key( + self, + tool_name: str, + contract_hash: str, + version: str, + ) -> str: + if re.fullmatch(r"[0-9a-f]{16}", version) is None: + raise ValueError(f"Invalid CodeAct implementation version {version!r}") + return f"{self._scope_key(tool_name, contract_hash)}:version:{version}" + + def _approved_key(self, tool_name: str, contract_hash: str) -> str: + return f"{self._scope_key(tool_name, contract_hash)}:approved" + + @staticmethod + def _text(value: Any) -> str: + return value.decode("utf-8") if isinstance(value, bytes) else str(value) diff --git a/trpc_agent_sdk/codeact/code_storage/_sql.py b/trpc_agent_sdk/codeact/code_storage/_sql.py new file mode 100644 index 000000000..8f44b012c --- /dev/null +++ b/trpc_agent_sdk/codeact/code_storage/_sql.py @@ -0,0 +1,301 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""SQL persistence for generated CodeAct implementations.""" + +from __future__ import annotations + +import asyncio +import hashlib +from datetime import datetime +from datetime import timezone +from typing import Any +from typing_extensions import override + +from sqlalchemy import String, Text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import mapped_column + +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 ._base import BaseCodeActImplementationStore +from ._base import CodeActImplementation + + +class CodeActImplementationSqlBase(DeclarativeBase): + """Metadata owned exclusively by CodeAct implementation stores.""" + + +class _CodeActImplementationRow(CodeActImplementationSqlBase): + __tablename__ = "trpc_agent_codeact_impl" + + tool_hash: Mapped[str] = mapped_column(String(64), primary_key=True) + contract_hash: Mapped[str] = mapped_column(String(64), primary_key=True) + version: Mapped[str] = mapped_column(String(16), primary_key=True) + payload: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(PreciseTimestamp) + + +class _CodeActApprovalRow(CodeActImplementationSqlBase): + __tablename__ = "trpc_agent_codeact_impl_approvals" + + tool_hash: Mapped[str] = mapped_column(String(64), primary_key=True) + contract_hash: Mapped[str] = mapped_column(String(64), primary_key=True) + version: Mapped[str] = mapped_column(String(16)) + approved_at: Mapped[datetime] = mapped_column(PreciseTimestamp) + approved_by: Mapped[str | None] = mapped_column(Text, nullable=True) + + +class SqlCodeActImplementationStore(BaseCodeActImplementationStore): + """Persist CodeAct versions through the shared :class:`SqlStorage`.""" + + def __init__( + self, + db_url: str | None = None, + *, + storage: SqlStorage | None = None, + is_async: bool = False, + **storage_kwargs: Any, + ) -> None: + if storage is None and db_url is None: + raise ValueError("SQL CodeAct storage requires db_url or storage") + self._storage = storage or SqlStorage( + is_async=is_async, + db_url=db_url or "", + metadata=CodeActImplementationSqlBase.metadata, + expire_on_commit=False, + **storage_kwargs, + ) + self._owns_storage = storage is None + self._initialized = False + self._initialization_lock = asyncio.Lock() + + @override + async def save_candidate( + self, + implementation: CodeActImplementation, + ) -> CodeActImplementation: + await self._ensure_initialized() + key = self._implementation_key( + implementation.tool_name, + implementation.contract_hash, + implementation.version, + ) + async with self._storage.create_db_session() as db: + row = await self._storage.get(db, key) + if row is not None: + stored = CodeActImplementation.model_validate_json(row.payload) + if stored.code_hash != implementation.code_hash: + raise ValueError(f"CodeAct version collision for {implementation.version!r}") + return stored + await self._storage.add( + db, + _CodeActImplementationRow( + tool_hash=key.key[0], + contract_hash=implementation.contract_hash, + version=implementation.version, + payload=implementation.model_dump_json(), + created_at=self._naive_utc(implementation.created_at), + ), + ) + try: + await self._storage.commit(db) + except IntegrityError: + # Another worker may have inserted the same content-addressed + # version concurrently. Verify that winner before returning. + pass + else: + return implementation + async with self._storage.create_db_session() as db: + row = await self._storage.get(db, key) + if row is None: + raise RuntimeError("SQL CodeAct candidate write did not persist") + stored = CodeActImplementation.model_validate_json(row.payload) + if stored.code_hash != implementation.code_hash: + raise ValueError(f"CodeAct version collision for {implementation.version!r}") + return stored + + @override + async def list_implementations( + self, + tool_name: str, + contract_hash: str, + ) -> list[CodeActImplementation]: + await self._ensure_initialized() + tool_hash = self._tool_hash(tool_name) + async with self._storage.create_db_session() as db: + rows = await self._storage.query( + db, + SqlKey( + key=(tool_hash, contract_hash), + storage_cls=_CodeActImplementationRow, + ), + SqlCondition( + filters=[ + _CodeActImplementationRow.tool_hash == tool_hash, + _CodeActImplementationRow.contract_hash == contract_hash, + ], + order_func=lambda: _CodeActImplementationRow.created_at.asc(), + ), + ) + return [CodeActImplementation.model_validate_json(row.payload) for row in rows] + + @override + async def get_approved( + self, + tool_name: str, + contract_hash: str, + ) -> CodeActImplementation | None: + await self._ensure_initialized() + tool_hash = self._tool_hash(tool_name) + async with self._storage.create_db_session() as db: + approval = await self._storage.get( + db, + SqlKey( + key=(tool_hash, contract_hash), + storage_cls=_CodeActApprovalRow, + ), + ) + if approval is None: + return None + implementation = await self._storage.get( + db, + SqlKey( + key=(tool_hash, contract_hash, approval.version), + storage_cls=_CodeActImplementationRow, + ), + ) + if implementation is None: + raise ValueError("Approved SQL CodeAct implementation " + f"{approval.version!r} is missing") + return CodeActImplementation.model_validate_json(implementation.payload) + + @override + async def approve( + self, + tool_name: str, + contract_hash: str, + version: str, + *, + approved_by: str | None = None, + ) -> CodeActImplementation: + await self._ensure_initialized() + tool_hash = self._tool_hash(tool_name) + async with self._storage.create_db_session() as db: + implementation = await self._storage.get( + db, + SqlKey( + key=(tool_hash, contract_hash, version), + storage_cls=_CodeActImplementationRow, + ), + ) + if implementation is None: + raise KeyError(f"Unknown CodeAct implementation version {version!r}") + approval_key = SqlKey( + key=(tool_hash, contract_hash), + storage_cls=_CodeActApprovalRow, + ) + approval = await self._storage.get_for_update(db, approval_key) + now = datetime.now(timezone.utc).replace(tzinfo=None) + if approval is None: + approval = _CodeActApprovalRow( + tool_hash=tool_hash, + contract_hash=contract_hash, + version=version, + approved_at=now, + approved_by=approved_by, + ) + await self._storage.add(db, approval) + else: + approval.version = version + approval.approved_at = now + approval.approved_by = approved_by + try: + await self._storage.commit(db) + except IntegrityError: + # Two workers can create the first approval row concurrently. + # Retry as an update after the winning transaction commits. + pass + else: + return CodeActImplementation.model_validate_json(implementation.payload) + return await self._retry_approval_update( + tool_hash=tool_hash, + contract_hash=contract_hash, + version=version, + approved_by=approved_by, + ) + + @override + async def close(self) -> None: + if self._owns_storage: + await self._storage.close() + + async def _ensure_initialized(self) -> None: + if self._initialized: + return + async with self._initialization_lock: + if self._initialized: + return + await self._storage.ensure_metadata(CodeActImplementationSqlBase.metadata) + self._initialized = True + + async def _retry_approval_update( + self, + *, + tool_hash: str, + contract_hash: str, + version: str, + approved_by: str | None, + ) -> CodeActImplementation: + async with self._storage.create_db_session() as db: + approval = await self._storage.get_for_update( + db, + SqlKey( + key=(tool_hash, contract_hash), + storage_cls=_CodeActApprovalRow, + ), + ) + if approval is None: + raise RuntimeError("SQL CodeAct approval write did not persist") + approval.version = version + approval.approved_at = datetime.now(timezone.utc).replace(tzinfo=None) + approval.approved_by = approved_by + implementation = await self._storage.get( + db, + SqlKey( + key=(tool_hash, contract_hash, version), + storage_cls=_CodeActImplementationRow, + ), + ) + if implementation is None: + raise KeyError(f"Unknown CodeAct implementation version {version!r}") + await self._storage.commit(db) + return CodeActImplementation.model_validate_json(implementation.payload) + + @staticmethod + def _tool_hash(tool_name: str) -> str: + return hashlib.sha256(tool_name.encode()).hexdigest() + + def _implementation_key( + self, + tool_name: str, + contract_hash: str, + version: str, + ) -> SqlKey: + return SqlKey( + key=(self._tool_hash(tool_name), contract_hash, version), + storage_cls=_CodeActImplementationRow, + ) + + @staticmethod + def _naive_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value + return value.astimezone(timezone.utc).replace(tzinfo=None) diff --git a/trpc_agent_sdk/filter/_filter_runner.py b/trpc_agent_sdk/filter/_filter_runner.py index aa4bac543..b48e6de2c 100644 --- a/trpc_agent_sdk/filter/_filter_runner.py +++ b/trpc_agent_sdk/filter/_filter_runner.py @@ -38,6 +38,16 @@ def __init__(self, filters_name: Optional[list[str]] = None, filters: Optional[l self._name = self.__class__.__name__ self._type = FilterType.UNSUPPORTED + def _convert_filters(self) -> list[BaseFilter]: + """Resolve registered filter names while preserving filter instances.""" + filter_instances = list(self._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}") + filter_instances.append(filter_instance) + return filter_instances + @property def filters_name(self) -> list[str]: """Get filter name.""" @@ -53,11 +63,8 @@ def name(self, value: str): 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) + self._filters = self._convert_filters() + self._filters_name = [filter.name for filter in self._filters] @property def filters(self) -> list[BaseFilter]: diff --git a/trpc_agent_sdk/storage/__init__.py b/trpc_agent_sdk/storage/__init__.py index c4b0b930d..73f5c453c 100644 --- a/trpc_agent_sdk/storage/__init__.py +++ b/trpc_agent_sdk/storage/__init__.py @@ -8,6 +8,7 @@ from ._constants import DEFAULT_MAX_KEY_LENGTH from ._constants import DEFAULT_MAX_VARCHAR_LENGTH from ._db import BaseStorage +from ._file import FileStorage from ._redis import EXPIRE_METHOD from ._redis import RedisAsyncContextManager from ._redis import RedisCommand @@ -43,6 +44,7 @@ "DEFAULT_MAX_KEY_LENGTH", "DEFAULT_MAX_VARCHAR_LENGTH", "BaseStorage", + "FileStorage", "RedisAsyncContextManager", "RedisCommand", "RedisCondition", diff --git a/trpc_agent_sdk/storage/_file.py b/trpc_agent_sdk/storage/_file.py new file mode 100644 index 000000000..ff4459740 --- /dev/null +++ b/trpc_agent_sdk/storage/_file.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. +"""Small asynchronous file storage primitives.""" + +from __future__ import annotations + +import asyncio +import os +import tempfile +from pathlib import Path + + +class FileStorage: + """Read and atomically write files below one configured root directory.""" + + def __init__(self, root: str | Path, *, encoding: str = "utf-8") -> None: + self.root = Path(root) + self.encoding = encoding + + async def read_text(self, key: str | Path) -> str | None: + """Read one relative file, returning ``None`` when it does not exist.""" + path = self._resolve(key) + + def _read() -> str | None: + if not path.exists(): + return None + return path.read_text(encoding=self.encoding) + + return await asyncio.to_thread(_read) + + async def write_text( + self, + key: str | Path, + content: str, + *, + overwrite: bool = True, + ) -> bool: + """Atomically write one relative file. + + Returns ``False`` without changing the file when ``overwrite`` is + disabled and the target already exists. + """ + path = self._resolve(key) + return await asyncio.to_thread( + self._write_text_sync, + path, + content, + overwrite, + ) + + async def list_files(self, key: str | Path, pattern: str) -> list[Path]: + """List relative files matching a glob below one relative directory.""" + directory = self._resolve(key) + + def _list() -> list[Path]: + if not directory.exists(): + return [] + return sorted(path.relative_to(self.root.resolve()) for path in directory.glob(pattern) if path.is_file()) + + return await asyncio.to_thread(_list) + + async def close(self) -> None: + """Release resources. File storage does not retain open handles.""" + + def _resolve(self, key: str | Path) -> Path: + root = self.root.resolve() + path = (root / key).resolve() + if path != root and root not in path.parents: + raise ValueError(f"File storage key escapes root: {key!s}") + return path + + def _write_text_sync( + self, + path: Path, + content: str, + overwrite: bool, + ) -> bool: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", + dir=path.parent, + ) + try: + with os.fdopen(descriptor, "w", encoding=self.encoding) as file: + file.write(content) + file.flush() + os.fsync(file.fileno()) + if overwrite: + os.replace(temporary_name, path) + else: + try: + os.link(temporary_name, path) + except FileExistsError: + return False + return True + finally: + if os.path.exists(temporary_name): + os.unlink(temporary_name) diff --git a/trpc_agent_sdk/storage/_sql.py b/trpc_agent_sdk/storage/_sql.py index 0d250490a..6b9deea43 100644 --- a/trpc_agent_sdk/storage/_sql.py +++ b/trpc_agent_sdk/storage/_sql.py @@ -130,7 +130,11 @@ def __init__(self, is_async: bool, db_url: str, metadata: Optional[MetaData] = N self.__sessionmaker_kwargs.setdefault("expire_on_commit", expire_on_commit) self.__kwargs = kwargs - def _migrate_missing_columns(self, connection: Connection) -> None: + def _migrate_missing_columns( + self, + connection: Connection, + metadata: Optional[MetaData] = None, + ) -> None: """Add columns that exist in the ORM model but are missing from the database, for forward compatibility across version changes. @@ -152,7 +156,8 @@ def _migrate_missing_columns(self, connection: Connection) -> None: ddl_compiler = dialect.ddl_compiler(dialect, None) pending_add_columns: list[tuple[str, str, str]] = [] - for table_name, table in self.__metadata.tables.items(): + target_metadata = metadata or self.__metadata + for table_name, table in target_metadata.tables.items(): if not insp.has_table(table_name): continue existing: set[str] = {col["name"] for col in insp.get_columns(table_name)} @@ -250,6 +255,27 @@ async def _async_inspect(): raise ValueError(f"Failed to create database engine for URL '{self.__db_url}'") from ex self._db_engine = db_engine + async def ensure_metadata(self, metadata: MetaData) -> None: + """Create and forward-migrate another service's tables. + + This allows multiple framework services to share one ``SqlStorage`` + engine and connection pool while retaining separate SQLAlchemy metadata. + """ + await self.create_sql_engine() + if self._db_engine is None: + raise RuntimeError("SQL storage engine is not initialized") + if isinstance(self._db_engine, AsyncEngine): + async with self._db_engine.begin() as connection: + await connection.run_sync(metadata.create_all) + await connection.run_sync(lambda sync_connection: self._migrate_missing_columns( + sync_connection, + metadata, + )) + return + metadata.create_all(self._db_engine) + with self._db_engine.begin() as connection: + self._migrate_missing_columns(connection, metadata) + @override async def close(self): """Close the database engine.""" diff --git a/trpc_agent_sdk/tools/__init__.py b/trpc_agent_sdk/tools/__init__.py index 11aff777a..8443b0c2a 100644 --- a/trpc_agent_sdk/tools/__init__.py +++ b/trpc_agent_sdk/tools/__init__.py @@ -9,6 +9,13 @@ from trpc_agent_sdk.abc import ToolPredicate from trpc_agent_sdk.abc import ToolSetABC as BaseToolSet +from trpc_agent_sdk.codeact import BaseCodeActImplementationStore +from trpc_agent_sdk.codeact import CodeActImplementation +from trpc_agent_sdk.codeact import CodeActImplementationPolicy +from trpc_agent_sdk.codeact import FileCodeActImplementationStore +from trpc_agent_sdk.codeact import InMemoryCodeActImplementationStore +from trpc_agent_sdk.codeact import RedisCodeActImplementationStore +from trpc_agent_sdk.codeact import SqlCodeActImplementationStore if TYPE_CHECKING: # Lazy re-export — see ``_LAZY_REEXPORTS`` below. @@ -17,6 +24,8 @@ from ._agent_tool import AGENT_TOOL_APP_NAME_SUFFIX from ._agent_tool import AgentTool +from ._code_act_tool import CodeActTool +from ._code_act_tool import is_code_act_function from ._base_tool import BaseTool from ._constants import TOOL_NAME from ._context_var import get_tool_var @@ -114,6 +123,15 @@ "BaseToolSet", "AGENT_TOOL_APP_NAME_SUFFIX", "AgentTool", + "BaseCodeActImplementationStore", + "CodeActImplementation", + "CodeActImplementationPolicy", + "FileCodeActImplementationStore", + "InMemoryCodeActImplementationStore", + "RedisCodeActImplementationStore", + "SqlCodeActImplementationStore", + "CodeActTool", + "is_code_act_function", "BaseTool", "get_tool_var", "reset_tool_var", diff --git a/trpc_agent_sdk/tools/_code_act_tool.py b/trpc_agent_sdk/tools/_code_act_tool.py new file mode 100644 index 000000000..2ddf34032 --- /dev/null +++ b/trpc_agent_sdk/tools/_code_act_tool.py @@ -0,0 +1,483 @@ +# Tencent is pleased to support the open 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 CodeAct implementation is inspired by and adapted from +# NVIDIA NeMo Labs OO Agents (NOOA): +# https://github.com/NVIDIA-NeMo/labs-OO-Agents +# +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# 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-implemented function tools backed by the CodeAct runtime.""" + +from __future__ import annotations + +import ast +import hashlib +import inspect +import json +import textwrap +from collections.abc import AsyncGenerator +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from pydantic import BaseModel +from pydantic import TypeAdapter +from pydantic import create_model +from typing_extensions import override + +from trpc_agent_sdk.abc import AgentABC +from trpc_agent_sdk.codeact import BaseCodeActImplementationStore +from trpc_agent_sdk.codeact import CodeActConfig +from trpc_agent_sdk.codeact import CodeActImplementation +from trpc_agent_sdk.codeact import CodeActImplementationPolicy +from trpc_agent_sdk.codeact import InMemoryCodeActImplementationStore +from trpc_agent_sdk.codeact import parse_codeact_result +from trpc_agent_sdk.codeact import prepare_codeact_code +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 trpc_agent_sdk.filter import BaseFilter +from trpc_agent_sdk.memory import InMemoryMemoryService +from trpc_agent_sdk.models import LLMModel +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Outcome +from trpc_agent_sdk.types import Part + +from ._base_tool import BaseTool +from ._constants import INPUT_STREAM +from ._constants import TOOL_CONTEXT +from ._function_tool import FunctionTool + + +@dataclass(frozen=True) +class _CodeActRun: + result: dict[str, Any] + code_cells: list[str] + + +class _ApprovedExecutionAgent(AgentABC): + """Expose approved-code capabilities without configuring an LLM.""" + + tools: list[BaseTool] + + @override + def get_subagents(self) -> list[AgentABC]: + return [] + + @override + async def run_async( + self, + parent_context: InvocationContext, + ) -> AsyncGenerator[Any, None]: + del parent_context + for event in (): + yield event + + +def is_code_act_function(func: Callable[..., Any]) -> bool: + """Return whether a function body consists of an optional docstring and ``...``.""" + if not (inspect.isfunction(func) or inspect.ismethod(func)): + return False + try: + module = ast.parse(textwrap.dedent(inspect.getsource(func))) + except (OSError, TypeError, IndentationError, SyntaxError): + return False + + definition = next( + (node for node in ast.walk(module) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == func.__name__), + None, + ) + if definition is None: + return False + + body = definition.body + if (body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) + and isinstance(body[0].value.value, str)): + body = body[1:] + return (len(body) == 1 and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) + and body[0].value.value is Ellipsis) + + +class CodeActTool(FunctionTool): + """Implement an ellipsis-bodied function through a nested CodeAct Agent. + + This class is deliberately opt-in. ``FunctionTool`` remains deterministic; + use ``CodeActTool`` only when executing model-generated Python is + intended and the configured CodeAct runtime has an appropriate isolation + boundary. + """ + + def __init__( + self, + func: Callable[..., Any], + *, + model: LLMModel | None = None, + tools: list[BaseTool] | None = None, + code_act: CodeActConfig | None = None, + filters: list[BaseFilter | str] | None = None, + implementation_store: BaseCodeActImplementationStore | None = None, + implementation_policy: CodeActImplementationPolicy | str = (CodeActImplementationPolicy.DYNAMIC), + ) -> None: + if not is_code_act_function(func): + raise ValueError("CodeActTool requires a function whose body is `...`") + policy = CodeActImplementationPolicy(implementation_policy) + if (policy is not CodeActImplementationPolicy.DYNAMIC and implementation_store is None): + implementation_store = InMemoryCodeActImplementationStore() + super().__init__( + func=func, + filters=filters, + ) + self.model = model + self.capability_tools = tools + self.code_act = code_act + self.implementation_store = implementation_store + self.implementation_policy = policy + self._input_model = self._build_input_model(func) + self._output_model, self._return_adapter = self._build_output_model(func) + self._contract_hash = self._build_contract_hash() + + @property + def contract_hash(self) -> str: + """Return the hash that invalidates implementations on contract changes.""" + return self._contract_hash + + async def list_implementations(self) -> list[CodeActImplementation]: + """List generated candidates for the current function contract.""" + if self.implementation_store is None: + return [] + return await self.implementation_store.list_implementations( + self.name, + self.contract_hash, + ) + + async def approve( + self, + version: str, + *, + approved_by: str | None = None, + ) -> CodeActImplementation: + """Promote a candidate. Approving an older version performs rollback.""" + if self.implementation_store is None: + raise ValueError("CodeActTool has no implementation_store") + return await self.implementation_store.approve( + self.name, + self.contract_hash, + version, + approved_by=approved_by, + ) + + @override + async def _run_async_impl( + self, + *, + tool_context: InvocationContext, + args: dict[str, Any], + ) -> Any: + from trpc_agent_sdk.agents import LlmAgent + + validated_input = self._input_model.model_validate(args) + arguments = validated_input.model_dump(mode="python") + + def get_function_arguments() -> dict[str, Any]: + """Return the validated arguments for the function being implemented.""" + return arguments + + capabilities = self._resolve_capabilities(tool_context) + capability_hash = self._build_capability_hash(capabilities) + approved = await self._get_compatible_approved(capability_hash) + generated = approved is None + if (approved is None and self.implementation_policy is CodeActImplementationPolicy.APPROVED_ONLY): + raise ValueError(f"CodeActTool {self.name!r} has no compatible approved " + f"implementation for contract {self.contract_hash}") + + capabilities.insert(0, FunctionTool(get_function_arguments)) + code_act = self.code_act or CodeActConfig() + if approved is not None: + result = await self._run_approved_implementation( + approved=approved, + arguments=arguments, + capabilities=capabilities, + code_act=code_act, + parent_context=tool_context, + ) + else: + model = self.model or getattr(tool_context.agent, "model", None) + if model is None: + raise ValueError(f"CodeActTool {self.name!r} requires a model or " + "a model configured on the calling LlmAgent") + nested_agent = LlmAgent( + name=f"code_act_{self.name}", + description=self.description, + model=model, + instruction=self._build_instruction(), + tools=capabilities, + code_act=code_act, + input_schema=self._input_model, + output_schema=self._output_model, + include_previous_history=False, + ) + result = await self._run_nested_agent( + agent=nested_agent, + arguments=arguments, + parent_context=tool_context, + ) + if generated and self.implementation_store is not None: + implementation = CodeActImplementation.create( + tool_name=self.name, + contract_hash=self.contract_hash, + capability_hash=capability_hash, + code_cells=result.code_cells, + metadata={ + "model": getattr(model, "_model_name", + type(model).__name__), + }, + ) + await self.implementation_store.save_candidate(implementation) + + value = result.result["result"] + if self._return_adapter is None: + return value + validated = self._return_adapter.validate_python(value) + if isinstance(validated, BaseModel): + return validated.model_dump(mode="json") + return validated + + async def _run_approved_implementation( + self, + *, + approved: CodeActImplementation, + arguments: dict[str, Any], + capabilities: list[BaseTool], + code_act: CodeActConfig, + parent_context: InvocationContext, + ) -> _CodeActRun: + """Execute approved cells directly without an LLM or Runner.""" + execution_agent = _ApprovedExecutionAgent( + name=f"approved_code_act_{self.name}", + description=self.description, + tools=capabilities, + ) + execution_context = parent_context.model_copy( + update={ + "invocation_id": + new_invocation_context_id(), + "agent": + execution_agent, + "branch": + (f"{parent_context.branch}.{execution_agent.name}" if parent_context.branch else execution_agent.name), + "user_content": + Content( + role="user", + parts=[ + Part.from_text(text=self._input_model.model_validate(arguments).model_dump_json( + exclude_none=True)) + ], + ), + }) + runtime = code_act.runtime + try: + execution = await runtime.execute( + execution_context, + [prepare_codeact_code(cell) for cell in approved.code_cells], + ) + finally: + await runtime.release(execution_context.invocation_id) + + if execution.outcome != Outcome.OUTCOME_OK: + raise ValueError(f"Approved CodeAct implementation {approved.version!r} " + f"failed:\n{execution.output}") + parsed = parse_codeact_result( + execution.output, + self._output_model, + ) + if not parsed.is_final: + detail = parsed.validation_error or ("approved implementation did not call return_result") + raise ValueError(f"Approved CodeAct implementation {approved.version!r} " + f"failed: {detail}") + return _CodeActRun( + result=self._output_model.model_validate(parsed.value).model_dump(mode="python"), + code_cells=list(approved.code_cells), + ) + + async def _run_nested_agent( + self, + *, + agent: Any, + arguments: dict[str, Any], + parent_context: InvocationContext, + ) -> _CodeActRun: + from trpc_agent_sdk.runners import Runner + + app_name = f"{agent.name}_code_act_function" + sessions = InMemorySessionService() + runner = Runner( + app_name=app_name, + agent=agent, + artifact_service=parent_context.artifact_service, + session_service=sessions, + memory_service=InMemoryMemoryService(), + ) + session = await sessions.create_session( + app_name=app_name, + user_id=parent_context.user_id, + state=parent_context.state.to_dict(), + ) + result_text = "" + code_cells: list[str] = [] + try: + async for event in runner.run_async( + user_id=session.user_id, + session_id=session.id, + new_message=Content( + role="user", + parts=[ + Part.from_text(text=self._input_model.model_validate(arguments).model_dump_json( + exclude_none=True)) + ], + ), + run_config=parent_context.run_config or RunConfig(), + ): + if event.actions.state_delta: + parent_context.state.update(event.actions.state_delta) + if event.content: + code_cells.extend(part.executable_code.code or "" for part in event.content.parts + if part.executable_code) + if event.object == "codeact.result": + result_text = event.get_text() + finally: + await runner.close() + + if not result_text: + raise ValueError(f"CodeActTool {self.name!r} produced no CodeAct result") + return _CodeActRun( + result=self._output_model.model_validate_json(result_text).model_dump(mode="python"), + code_cells=code_cells, + ) + + async def _get_compatible_approved( + self, + capability_hash: str, + ) -> CodeActImplementation | None: + if (self.implementation_store is None or self.implementation_policy is CodeActImplementationPolicy.DYNAMIC): + return None + approved = await self.implementation_store.get_approved( + self.name, + self.contract_hash, + ) + if approved is None or approved.capability_hash != capability_hash: + return None + return approved + + def _resolve_capabilities( + self, + context: InvocationContext, + ) -> list[BaseTool]: + if self.capability_tools is not None: + return list(self.capability_tools) + parent_tools = getattr(context.agent, "tools", None) or [] + return [tool for tool in parent_tools if tool is not self and getattr(tool, "name", None) != self.name] + + def _build_instruction(self) -> str: + try: + signature = inspect.signature(self.func, eval_str=True) + except (NameError, TypeError, ValueError): + signature = inspect.signature(self.func) + docstring = inspect.getdoc(self.func) or "Implement the requested function." + return "\n".join([ + "Implement the following incomplete Python function for this call.", + f"Function: {self.name}{signature}", + f"Contract: {docstring}", + ("Start by calling " + "`arguments = await self.get_function_arguments()`."), + ("Use the argument values and available self capabilities to " + "compute the result. Do not redefine or invoke the incomplete " + "function itself."), + ("Finish with `return_result({'result': value})`, where value " + "satisfies the annotated return type."), + ]) + + def _build_contract_hash(self) -> str: + try: + signature = str(inspect.signature(self.func, eval_str=True)) + except (NameError, TypeError, ValueError): + signature = str(inspect.signature(self.func)) + payload = { + "module": getattr(self.func, "__module__", ""), + "qualname": getattr(self.func, "__qualname__", self.name), + "signature": signature, + "docstring": inspect.getdoc(self.func) or "", + "input_schema": self._input_model.model_json_schema(), + "output_schema": self._output_model.model_json_schema(), + } + return hashlib.sha256( + json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + default=str, + ).encode()).hexdigest() + + @staticmethod + def _build_capability_hash(capabilities: list[BaseTool]) -> str: + declarations: list[dict[str, Any]] = [] + for tool in capabilities: + declaration = tool._get_declaration() # pylint: disable=protected-access + declarations.append({ + "name": + tool.name, + "description": + tool.description, + "declaration": (declaration.model_dump(mode="json") if declaration is not None else None), + }) + declarations.sort(key=lambda value: value["name"]) + return hashlib.sha256( + json.dumps( + declarations, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + default=str, + ).encode()).hexdigest() + + @staticmethod + def _build_input_model(func: Callable[..., Any], ) -> type[BaseModel]: + fields: dict[str, tuple[Any, Any]] = {} + for name, parameter in inspect.signature(func).parameters.items(): + if name in {TOOL_CONTEXT, INPUT_STREAM}: + continue + annotation = (Any if parameter.annotation is inspect.Signature.empty else parameter.annotation) + default = (... if parameter.default is inspect.Signature.empty else parameter.default) + fields[name] = (annotation, default) + return create_model(f"{func.__name__.title()}CodeActInput", **fields) + + @staticmethod + def _build_output_model(func: Callable[..., Any], ) -> tuple[type[BaseModel], TypeAdapter[Any] | None]: + annotation = inspect.signature(func).return_annotation + if annotation is inspect.Signature.empty: + annotation = Any + adapter = None + else: + adapter = TypeAdapter(annotation) + model = create_model( + f"{func.__name__.title()}CodeActOutput", + result=(annotation, ...), + ) + return model, adapter