Skip to content

[BREAKING][core] refactor: unify agent runtime and training interfaces#83

Merged
wuxibin89 merged 108 commits into
mainfrom
refactor
Jul 20, 2026
Merged

[BREAKING][core] refactor: unify agent runtime and training interfaces#83
wuxibin89 merged 108 commits into
mainfrom
refactor

Conversation

@yyDing1

@yyDing1 yyDing1 commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

This PR refactors Uni-Agent around a unified set of composable runtime abstractions for agent inference, data synthesis, evaluation, and reinforcement learning.

New docs: https://uni-agent.readthedocs.io/en/latest/

Key changes:

  • Introduce unified Task, Agent, Sandbox, Tool, and Toolbox interfaces.
  • Support both white-box agents such as ReAct and black-box harnesses such as Claude Code.
  • Connect tasks to the verl rollout stack through Gateway sessions, Agent Framework, and TransferQueue.
  • Move reward and verification logic into Task implementations.
  • Add External API and verl-managed inference workflows.
  • Replace the legacy documentation stack with MkDocs Material and add new Quickstart, Concepts, and Benchmark sections.

Related:

Checklist Before Starting

  • Reviewed the Uni-Agent roadmap and related verl Gateway RFC.
  • PR title follows the required format and is marked as breaking.

Test

The following targeted checks were run:

# Task Config merge precedence
PYTHONPATH=verl python -m pytest \
  tests/uni_agent/framework/test_task_config_merge.py -q

# Claude Code integration
python -m pytest \
  tests/uni_agent/agents/test_claude_code_agent.py -q

# Sandbox lifecycle, error policy, and file operations
python -m pytest \
  tests/uni_agent/sandbox/test_exec_error_policy.py -q

# Documentation
python -m mkdocs build --strict

# Example syntax
python -m py_compile \
  examples/inference/parallel_infer_api.py \
  examples/inference/parallel_infer_verl.py \
  examples/quickstart/sandbox/demo.py

bash -n \
  examples/inference/run_infer.sh \
  examples/quickstart/inference/run_infer.sh

API and Usage Example

A workload is now defined through one Task Config:

- name: swe_bench
  sandbox:
    provider: modal
  agent:
    name: react
    max_steps: 200
    tools:
      - name: stateful_shell
      - name: str_replace_editor
      - name: submit
    model:
      temperature: 0.8
      top_p: 0.9
      max_total_tokens: 65536

Tasks can also be instantiated directly:

from uni_agent.tasks import get_task

task = get_task(task_config)
result = await task.run()

Task configuration is resolved as:

Task Config defaults
    ← Sample Config overrides
    ← Runtime Model Endpoint injection

This allows sample-wise customization while keeping model endpoints and credentials bound to the live runtime.

Design & Code Changes

Core abstractions

  • Task owns the episode lifecycle, prompt, metadata, Sandbox, Agent, and reward computation.
  • Agent defines the solving strategy and supports white-box loops or black-box harnesses.
  • Sandbox provides a shared lifecycle and execution/data-plane interface across Local, Modal, veFaaS, and Seed backends.
  • Tool defines one model-visible action; Toolbox binds stateful Tool instances to one Sandbox.

All major abstractions use typed Pydantic configurations, registries, and lazy module loading.

Gateway and verl integration

The verl-managed rollout path is:

verl LLMServerManager
    → AgentFrameworkRolloutAdapter
    → Uni-Agent Gateway
    → Task Runner
    → Task / Agent / Sandbox
    → TransferQueue

The Gateway supports OpenAI and Anthropic-compatible sessions and captures token-level trajectories for the training pipeline.

Inference workflows

Two inference modes are provided:

  • External API mode for hosted APIs or self-managed inference engines.
  • verl-managed rollout mode for training-path parity and TransferQueue trajectory collection.

Examples live under:

examples/inference/
examples/quickstart/inference/
examples/quickstart/sandbox/

Reliability and extensibility

  • Add shared Sandbox timeout and error semantics.
  • Add native Local filesystem operations, including macOS compatibility.
  • Add ToolResult status and Toolbox error-handling contracts.
  • Add automatic Claude Code installation inside the Sandbox.
  • Add sample-wise Task Config overrides with runtime endpoint protection.
  • Add targeted tests for the new contracts.

Documentation

  • Replace Sphinx/RTD Theme with MkDocs Material.
  • Add new Quickstart guides for installation, Sandbox, inference, and RL.
  • Add Concepts documentation covering interfaces and customization rules.
  • Add Benchmark pages for inference and RL reference results.
  • Add reproducible examples and sanitized Runtime Env configurations.

Breaking Changes

This PR intentionally replaces several legacy interfaces:

  • Removes the legacy AgentEnv, interaction, deployment, and global reward-registry architecture.
  • Moves reward and verification logic into Task implementations.
  • Reorganizes Agent, Sandbox, Tool, Task, and logging modules.
  • Changes Task and Agent configuration schemas.
  • Renames and reorganizes several example directories and entry points.
  • Changes Task Config precedence so Sample Config overrides run-level defaults.
  • Migrates documentation from Sphinx to MkDocs.
  • Updates the pinned verl revision.

Existing integrations will need to migrate to the new registries and configuration format.

Checklist Before Submitting

  • Run pre-commit run --all-files --show-diff-on-failure --color=always
  • Add or update docs/examples for user-facing changes
  • Add targeted tests for new interfaces and behavior
  • Confirm the PR title marks the API and configuration changes as breaking
  • Confirm full CI passes

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request migrates the project's documentation from Sphinx to MkDocs Material, introducing comprehensive quickstart guides, core concepts, and benchmark results. It also adds standalone inference examples, a sandbox demo, and updates training recipes to utilize verl's V1 unified trainer in colocate_async mode. The review feedback highlights a critical security issue with hardcoded Modal API credentials in the inference runtime environment. Additionally, several technical improvements are requested, such as lazily initializing asyncio.Semaphore in Ray actors to prevent runtime errors, implementing the stubbed run method in MiniSweAgentAgent to fix failing tests, using with blocks for safe file operations in task_runner.py, utilizing async with for OpenAICompatibleChatModel in the ReAct agent, and avoiding hardcoded paths in training shell scripts.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +18 to +19
MODAL_TOKEN_ID: "ak-MWuXc0JB73Ll3y75lnTl1K"
MODAL_TOKEN_SECRET: "as-b3iq9Xf3igKAb3DfPQVNwG"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

Hardcoded Modal API credentials (MODAL_TOKEN_ID and MODAL_TOKEN_SECRET) have been committed to the repository. This poses a significant security risk.

Please replace these credentials with placeholders (similar to examples/quickstart/inference/runtime_env.yaml) and load them via environment variables or a local configuration file.

  MODAL_TOKEN_ID: "<modal-token-id>"
  MODAL_TOKEN_SECRET: "<modal-token-secret>"

Comment on lines +48 to +52
class InferenceActor:
_semaphore = asyncio.Semaphore(max(1, GLOBAL_CONCURRENCY // NUM_WORKERS))

async def run_single(self, sample: dict, task_defaults: dict) -> dict:
async with self._semaphore:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Creating an asyncio.Semaphore at class definition time (outside of any running event loop) is problematic. When this class is imported or instantiated on Ray workers, it can lead to RuntimeError: Task got Future attached to a different loop because the semaphore binds to the loop active during class loading (or no loop at all).

Instead, initialize the semaphore lazily inside the async method or within __init__ once the event loop is running.

class InferenceActor:
    def __init__(self) -> None:
        self._semaphore = None

    async def run_single(self, sample: dict, task_defaults: dict) -> dict:
        if self._semaphore is None:
            self._semaphore = asyncio.Semaphore(max(1, GLOBAL_CONCURRENCY // NUM_WORKERS))
        async with self._semaphore:

Comment on lines +33 to +38
class TestEvalActor:
_semaphore = asyncio.Semaphore(max(1, GLOBAL_CONCURRENCY // NUM_WORKERS))

async def run_single(self, sample: dict) -> dict:
async with self._semaphore:
task_config = sample["extra_info"]["tools_kwargs"]["task"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Creating an asyncio.Semaphore at class definition time (outside of any running event loop) is problematic. When this class is imported or instantiated on Ray workers, it can lead to RuntimeError: Task got Future attached to a different loop because the semaphore binds to the loop active during class loading (or no loop at all).

Instead, initialize the semaphore lazily inside the async method or within __init__ once the event loop is running.

@ray.remote
class TestEvalActor:
    def __init__(self) -> None:
        self._semaphore = None

    async def run_single(self, sample: dict) -> dict:
        if self._semaphore is None:
            self._semaphore = asyncio.Semaphore(max(1, GLOBAL_CONCURRENCY // NUM_WORKERS))
        async with self._semaphore:

Comment on lines +36 to +42
async def run(
self,
*,
sandbox: Sandbox,
messages: list[dict[str, Any]],
) -> AgentResult:
pass

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The run method for MiniSweAgentAgent is currently implemented as a stub (pass). However, a comprehensive test suite tests/uni_agent/agents/test_mini_swe_agent_agent.py has been added in this PR that asserts a full implementation of run returning an AgentResult.

Running the test suite will currently fail with an AssertionError because run returns None. Please implement the run method or mark the tests as skipped/expected to fail until the agent is fully implemented.

Comment thread uni_agent/framework/task_runner.py Outdated
Comment on lines +68 to +70
entries = raw if isinstance(raw, list) else [raw]
index: dict[str, dict[str, Any]] = {}
for entry in entries:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The file is opened using open(path) without explicitly closing it or using a with statement. This can lead to unclosed file descriptor leaks.

Additionally, it is highly recommended to specify encoding="utf-8" when opening text files to ensure platform-independent behavior.

    import yaml

    with open(path, "r", encoding="utf-8") as f:
        raw = yaml.safe_load(f)

Comment on lines +66 to +106
toolbox = Toolbox.from_specs(cfg.tools, sandbox=sandbox)
model = OpenAICompatibleChatModel(
base_url=cfg.model.base_url,
api_key=cfg.model.api_key,
model_name=cfg.model.model_name,
sampling_params={
"temperature": cfg.model.temperature,
"top_p": cfg.model.top_p,
"top_k": cfg.model.top_k,
},
tools_schemas=toolbox.schemas(),
)

transcript: list[dict[str, Any]] = list(messages)
for message in messages:
logger.info(f"{str(message.get('role', '')).upper()} PROMPT:\n{message.get('content', '')}")
trajectory_info: dict[str, Any] = {
"steps": 0,
"num_tool_calls": 0,
"timeouts": 0,
"errors": 0,
"total_tokens": 0,
"exit_reason": "unknown",
}
try:
async with toolbox.entered(retry=3, timeout=60):
for step_idx in range(1, cfg.max_steps + 1):
trajectory_info["steps"] = step_idx
stop_reason = await self.step(cfg, model, toolbox, transcript, trajectory_info)
if stop_reason != "completed":
trajectory_info["exit_reason"] = stop_reason
break
else: # loop ran the full step budget without an early stop
trajectory_info["exit_reason"] = "max_steps"
logger.warning(f"Reached max steps ({cfg.max_steps}) without finishing.")
except Exception as exc: # keep the partial transcript; the task buckets the failure
logger.exception("react loop failed at step %s", trajectory_info["steps"])
trajectory_info["exit_reason"] = "unknown_error"
trajectory_info["error"] = f"{type(exc).__name__}: {exc}"
finally:
await model.aclose() # release the reused HTTP session

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since OpenAICompatibleChatModel implements __aenter__ and __aexit__ to manage its underlying HTTP session lifecycle, it is much more idiomatic and safer to use an async with block. This ensures that the model session is always closed cleanly, even if an exception is raised during initialization or before entering the main try block, preventing potential resource leaks.

        toolbox = Toolbox.from_specs(cfg.tools, sandbox=sandbox)
        async with OpenAICompatibleChatModel(
            base_url=cfg.model.base_url,
            api_key=cfg.model.api_key,
            model_name=cfg.model.model_name,
            sampling_params={
                "temperature": cfg.model.temperature,
                "top_p": cfg.model.top_p,
                "top_k": cfg.model.top_k,
            },
            tools_schemas=toolbox.schemas(),
        ) as model:
            transcript: list[dict[str, Any]] = list(messages)
            for message in messages:
                logger.info(f"{str(message.get('role', '')).upper()} PROMPT:\n{message.get('content', '')}")
            trajectory_info: dict[str, Any] = {
                "steps": 0,
                "num_tool_calls": 0,
                "timeouts": 0,
                "errors": 0,
                "total_tokens": 0,
                "exit_reason": "unknown",
            }
            try:
                async with toolbox.entered(retry=3, timeout=60):
                    for step_idx in range(1, cfg.max_steps + 1):
                        trajectory_info["steps"] = step_idx
                        stop_reason = await self.step(cfg, model, toolbox, transcript, trajectory_info)
                        if stop_reason != "completed":
                            trajectory_info["exit_reason"] = stop_reason
                            break
                    else:  # loop ran the full step budget without an early stop
                        trajectory_info["exit_reason"] = "max_steps"
                        logger.warning(f"Reached max steps ({cfg.max_steps}) without finishing.")
            except Exception as exc:  # keep the partial transcript; the task buckets the failure
                logger.exception("react loop failed at step %s", trajectory_info["steps"])
                trajectory_info["exit_reason"] = "unknown_error"
                trajectory_info["error"] = f"{type(exc).__name__}: {exc}"


project_name='Uni-Agent-SWE-Agent'
exp_name='GRPO-Qwen3-30B-R2E-Fully-Async'
RAY_DATA_HOME=/mnt/hdfs/yyding

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Hardcoding RAY_DATA_HOME=/mnt/hdfs/yyding at the top of the script prevents users from overriding this path via environment variables, as the subsequent check RAY_DATA_HOME=${RAY_DATA_HOME:-...} will always see the hardcoded value.

Consider using the standard shell parameter expansion to allow environment overrides.

Suggested change
RAY_DATA_HOME=/mnt/hdfs/yyding
RAY_DATA_HOME=${RAY_DATA_HOME:-/mnt/hdfs/yyding}

#
# Launch from the repo root so Ray packages both `verl/` and `uni_agent/`.

RAY_DATA_HOME=/mnt/hdfs/yyding

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Hardcoding RAY_DATA_HOME=/mnt/hdfs/yyding at the top of the script prevents users from overriding this path via environment variables, as the subsequent check RAY_DATA_HOME=${RAY_DATA_HOME:-...} will always see the hardcoded value.

Consider using the standard shell parameter expansion to allow environment overrides.

Suggested change
RAY_DATA_HOME=/mnt/hdfs/yyding
RAY_DATA_HOME=${RAY_DATA_HOME:-/mnt/hdfs/yyding}

@wuxibin89
wuxibin89 merged commit 55ff16b into main Jul 20, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants