ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
@@ -0,0 +1,284 @@
# coding: utf-8
# Copyright (c) 2025 inclusionAI.
import abc
import asyncio
import json
import re
from typing import List, Optional, Dict, Any, Union
from aworld.agents.llm_agent import Agent
from aworld.core.agent.swarm import Swarm
from aworld.core.task import TaskResponse
from aworld.runner import Runners
from aworld.utils.common import sync_exec
from swift.llm import RequestConfig
from swift.llm.infer.protocol import ChatCompletionResponse
from swift.trainers.rlhf_trainer.grpo_trainer import InputsType, GRPOTrainer, logger
from transformers import AutoTokenizer
from trl.extras.profiling import profiling_context
class AworldTrainer(GRPOTrainer):
def _engine_infer(
self,
infer_requests: InputsType,
request_config: Optional[RequestConfig] = None,
*,
use_tqdm: Optional[bool] = False,
) -> List[ChatCompletionResponse]:
with profiling_context(self, 'generate'):
if self.vllm_mode != 'server':
return self.engine.infer(infer_requests, request_config, use_tqdm=use_tqdm)
request_keys = ['messages', 'images', 'audios', 'videos', 'tools', 'objects']
infer_requests = [{
**{k: request[k]
for k in request_keys if k in request},
**({
'data_dict': {k: request[k]
for k in request if k not in request_keys}
} if self.multi_turn_scheduler and self.vllm_use_async_engine else {})
} for request in infer_requests]
self._process_infer_requests_images(infer_requests)
return self.run_infer(infer_requests)
def run_infer(self, infer_requests: List[Dict[str, Any]]) -> List[ChatCompletionResponse]:
workers = [asyncio.create_task(self._rollout(req)) for req in infer_requests]
results = sync_exec(asyncio.gather, *workers)
return self.convert_agent_output(results, infer_requests)
async def _rollout(self, req: Dict[str, Any]):
agent = self.build_agents()
result = await self.run_agents(req, agent)
return result
@abc.abstractmethod
def build_agents(self) -> Union[Agent, Swarm]:
"""Build single- or multi-agent"""
async def run_agents(self, input, agent):
# collect trajectory
if isinstance(agent, Swarm):
result = Runners.sync_run(input=input, swarm=agent)
else:
result = Runners.sync_run(input=input, agent=agent)
return result
def convert_agent_output(self,
results: List[TaskResponse],
infer_requests: List[Dict[str, Any]]) -> List[ChatCompletionResponse]:
message_final_merge = []
for result in results:
trajectory = result.trajectory
last_exp_data = trajectory[-1]['exp_data']
task_id = trajectory[0]['exp_meta']['task_id'].split('_')[1]
message_final = []
message = last_exp_data["messages"]
answer_flag = 0
for i in range(len(message)):
actions = last_exp_data.get('actions', [])
if actions:
actions_str = json.dumps(actions)
if '<answer>' in actions_str and '</answer>' in actions_str:
match = re.search(r'<answer>(.*?)</answer>', actions_str, re.DOTALL)
if match:
answer_flag = 1
logger.info(f"{task_id} answer content: {match.group(1)}")
else:
logger.warning(f"{task_id} no answer content found.")
if message[i]["role"] in ["system", "user"]:
message_final.append(
{
"role": message[i]["role"],
"content": message[i]["content"],
}
)
elif message[i]["role"] == "assistant" and "tool_calls" in message[i].keys():
if message[i]["tool_calls"][0]["function"]["arguments"]:
arguments = json.loads(message[i]["tool_calls"][0]["function"]["arguments"])
else:
arguments = ""
function_call = {
"name": message[i]["tool_calls"][0]["function"]["name"],
"arguments": arguments
}
if message[i]["content"] != "" and message[i]["content"] is not None:
message_final.append(
{
"role": "assistant",
"content": message[i]["content"],
}
)
message_final.append(
{
"role": "tool_call",
"content": json.dumps(function_call, ensure_ascii=False),
}
)
elif message[i]["role"] == "tool":
last_content = message[i - 1]["content"]
if last_content is None:
last_content = ""
message_final.append(
{
"role": "tool",
"content": message[i]["content"].replace(last_content, ""),
}
)
else:
logger.warning(f"Unknown message role: {message[i]['role']}")
tokenizer = AutoTokenizer.from_pretrained(self.args.model_init_kwargs)
try:
response = last_exp_data["actions"][0]["policy_info"]
if response:
message_final.append(
{
"role": "assistant",
"content": response
}
)
else:
message_final.append(
{
"role": "assistant",
"content": "No response was received. Please try again later."
}
)
message_final = truncate_messages_fast(message_final, tokenizer)
status = "success" if answer_flag == 1 else "length"
message_final_merge.append((message_final, status, task_id))
except:
message_final.append({
"role": "assistant",
"content": "No response was received. Please try again later."
})
message_final = truncate_messages_fast(message_final, tokenizer)
message_final_merge.append((message_final, "length", task_id))
return self.pad_list_to_length(message_final_merge, infer_requests)
def pad_list_to_length(self, message_final_merge, infer_requests) -> List[ChatCompletionResponse]:
unique_task_ids = []
for msg in message_final_merge:
task_id = msg[2]
if task_id not in unique_task_ids:
unique_task_ids.append(task_id)
# Group by task_id
task_groups = {task_id: [] for task_id in unique_task_ids}
for item in message_final_merge:
messages, status, task_id = item
if task_id in task_groups:
task_groups[task_id].append(item)
# Ensure each group has exactly num_generations samples
for task_id in unique_task_ids:
# If this task_id has no samples, construct fallback data
if len(task_groups[task_id]) == 0:
for _infer_request in infer_requests:
# Check if the first message content matches the task_id
if task_id == _infer_request["messages"][0]["content"]:
fallback_completion = {
"role": "assistant",
"content": "No response was received. Please try again later."
}
new_messages = _infer_request["messages"].copy()[1:]
new_messages.append(fallback_completion)
task_groups[task_id].append((new_messages, "length", task_id))
break
# # Ensure we have exactly num_generations samples
# while len(task_groups[task_id]) < num_generations/len(unique_task_ids):
# success_samples = [item for item in task_groups[task_id] if item[1] == "success"]
# if success_samples:
# task_groups[task_id].append(random.choice(success_samples))
# else:
# task_groups[task_id].append(random.choice(task_groups[task_id]))
num_generations = len(infer_requests)
current_count = len(task_groups[task_id])
if current_count >= num_generations / len(unique_task_ids):
continue
# Get success samples if available, otherwise use all samples
success_samples = [item for item in task_groups[task_id] if item[1] == "success"]
samples_to_cycle = success_samples if success_samples else task_groups[task_id]
# Calculate how many more we need
needed = int(num_generations / len(unique_task_ids)) - current_count
# Add samples in a cycling manner
for i in range(int(needed)):
task_groups[task_id].append(samples_to_cycle[i % len(samples_to_cycle)])
# Combine all groups and convert back to 2-tuples for final output
final_result = []
for task_id in unique_task_ids:
for item in task_groups[task_id]:
messages, status, _ = item
final_result.append((messages, status))
return final_result
def truncate_messages_fast(
messages: List[Dict[str, Any]],
tokenizer: Any,
max_length: int = 131072,
tools: Optional[List] = None
) -> List[Dict[str, Any]]:
"""Simplifies message list truncation by removing entire messages from the end
to fit within max_length, with a final role check.
Core Logic:
1. First, removes messages from the end of the list one by one until the
total token count is within `max_length`.
2. After ensuring the length is acceptable, it performs a final check on the
last remaining message.
3. If the last message's role is not 'assistant' or 'tool_call', it is
also removed. This check is repeated until the last message has a valid
role or the list becomes empty.
4. This function does not partially truncate message content.
Args:
messages (List[Dict[str, Any]]): A list of message dictionaries.
tokenizer: The tokenizer instance to calculate token count.
max_length (int, optional): The target maximum number of tokens. Defaults to 131072.
tools (Optional[List], optional): A list of tools that might be needed when applying
the chat template. Defaults to None.
Returns:
List[Dict[str, Any]]: The truncated list of messages.
"""
truncated_messages = list(messages)
def get_current_tokens(msgs: List[Dict[str, Any]]) -> int:
if not msgs:
return 0
# The return value of apply_chat_template can be a list of token IDs or a string
# We use len() to get the count, which works for both cases.
return len(tokenizer.apply_chat_template(msgs, tools=tools, add_generation_prompt=False))
# 1. Truncate from the end based on length
# The `and truncated_messages` ensures we don't pop from an empty list
while get_current_tokens(truncated_messages) > max_length and truncated_messages:
truncated_messages.pop() # pop() removes the last item
# 2. Ensure the last remaining message has a valid role ('assistant' or 'tool_call')
# This loop handles cases where multiple invalid messages are at the end (e.g., ..., tool, user)
while truncated_messages:
last_message_role = truncated_messages[-1].get("role")
if last_message_role in ('assistant', 'tool_call'):
# The last message is valid, so we are done.
break
else:
# The last message is not of the required role, remove it and check again.
truncated_messages.pop()
return truncated_messages
@@ -0,0 +1,27 @@
# VERL Adapter (AWorld Train)
This module hosts the VERL integration for AWorld training workflows.
- aworld_agent_loop.py: Base class bridging VERL AgentLoop with AWorld agents.
- common.py:
- Utilities for converting trajectories/messages to VERL AgentLoopOutput.
- Utilities for getting MCP server configuration.
## Usage
Import adapter entrypoints from your example code:
```python
from train.adapter.verl.aworld_agent_loop import AworldAgentLoop
```
Then implement your example-specific loop:
```python
class MyLoop(AworldAgentLoop):
def build_agents(self):
...
```
## Adding New Features
- Avoid putting example-specific code here; that belongs in train/examples/.
## Notes
- Prefer small, composable utilities and explicit public APIs.
@@ -0,0 +1,179 @@
# coding: utf-8
# Copyright (c) 2025 inclusionAI.
import abc
import json
import logging
import os
import uuid
from typing import Any, List, Dict, Union
from aworld.agents.llm_agent import Agent
from aworld.config.agent_loader import _load_yaml
from aworld.core.agent.swarm import Swarm
from aworld.runner import Runners
from aworld.logs.util import logger
from verl.experimental.agent_loop.agent_loop import AgentLoopBase, AgentLoopOutput
from train.adapter.verl.common import to_agent_loop_output
logger.setLevel(logging.INFO)
logger.propagate = False
if not logger.handlers:
handler = logging.StreamHandler()
handler.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)
class AworldAgentLoop(AgentLoopBase):
__metaclass__ = abc.ABCMeta
@abc.abstractmethod
async def build_agents(self) -> Union[Agent, Swarm]:
"""Build single- or multi-agent"""
async def get_llm_server_address(self, server_name: str = None) -> str:
server = self.server_manager._choose_server(server_name or uuid.uuid4().hex)
base_url = await server.get_server_address.remote()
base_url = f"http://{base_url}/v1"
logger.info(f"get_server_address#base_url: {base_url}")
return base_url
async def get_llm_server_model_name(self):
model_name = "/".join(self.config.actor_rollout_ref.model.path.split("/")[-2:])
logger.info(f"get_server_model_name#model_name: {model_name}")
return model_name
# main branch
# async def run(self, sampling_params: dict[str, Any], **kwargs) -> AgentLoopOutput:
# messages = list(kwargs["raw_prompt"])
# release 0.5.0
async def run(self, messages: list, sampling_params: dict[str, Any], **kwargs) -> AgentLoopOutput:
agent = await self.build_agents()
self.agent = agent
result = await self.run_agents(messages[0], agent)
res = result.trajectory
# build agent loop output
output = await self.convert_agent_output(trajectory=res,
response_length=self.config.actor_rollout_ref.rollout.response_length)
return output
async def run_agents(self, input, agent):
if isinstance(input, dict):
input = input.get("content", "")
# collect trajectory
if isinstance(agent, Swarm):
result = Runners.sync_run(input=input, swarm=agent)
else:
result = Runners.sync_run(input=input, agent=agent)
return result
async def get_agent_tool_config(self, config_path: str) -> Dict[str, Any]:
"""Load tool configuration, preferring YAML with simple fields.
Priority:
1) agent_tools.yaml (simple user config with url, Authorization, MCP_SERVERS)
2) mcp.json (legacy full config)
"""
# 1) Try YAML (simple schema)
try:
import yaml # Local import to avoid hard dependency at import time
if os.path.exists(config_path):
src = _load_yaml(config_path)
url = src.get('url', '')
authorization = src.get('Authorization', '')
mcp_servers_value = src.get('MCP_SERVERS', '')
# Normalize servers to comma-separated string for header and list for internal
if isinstance(mcp_servers_value, list):
mcp_servers_str = ','.join([str(s).strip() for s in mcp_servers_value if str(s).strip()])
else:
mcp_servers_str = str(mcp_servers_value or '').strip()
# Build internal full mcp_config
server_name = src.get('server_name', 'aworld-mcp')
server_type = src.get('type', 'streamable-http')
timeout = src.get('timeout', 600)
sse_read_timeout = src.get('sse_read_timeout', 600)
client_session_timeout_seconds = src.get('client_session_timeout_seconds', 600)
if url:
mcp_config = {
"mcpServers": {
server_name: {
"type": server_type,
"url": url,
"headers": {
"Authorization": authorization,
"MCP_SERVERS": mcp_servers_str,
},
"timeout": timeout,
"sse_read_timeout": sse_read_timeout,
"client_session_timeout_seconds": client_session_timeout_seconds,
}
}
}
return mcp_config
except Exception as err:
print(f"Error loading YAML tool config err: {err}")
# 2) Fallback to legacy JSON
try:
if os.path.exists(config_path):
with open(config_path, "r") as f:
return json.load(f)
except Exception as err:
print(f"Error loading tool config[{config_path}] err is : {err}")
def get_num_turns(self, trajectory: List[Dict[str, Any]]):
return len(trajectory)
async def convert_agent_output(self, trajectory: List[Dict[str, Any]], response_length: int) -> AgentLoopOutput:
"""Convert trajectory to AgentLoopOutput.
Args:
trajectory (List[Dict[str, Any]]): List of agent execution trajectory.
response_length (int): Max length of response.
Returns:
AgentLoopOutput: agent loop output trajectory used for training.
"""
if not trajectory:
raise Exception("Trajectory is empty")
num_turns = self.get_num_turns(trajectory)
messages = trajectory[-1].get("exp_data", {}).get("messages", [])
if not messages:
return AgentLoopOutput(
prompt_ids=[],
response_ids=[],
response_mask=[],
num_turns=num_turns,
metrics={},
)
if messages[-1].get("role") != "assistant":
logger.warning(f"Found last message with role '{messages[-1].get('role')}', but expected 'assistant'. Truncating trailing 'tool' messages.")
last_non_tool_index = -1
for i in range(len(messages) - 1, -1, -1):
if messages[i].get("role") != "tool":
last_non_tool_index = i
break
if last_non_tool_index != -1:
messages = messages[:last_non_tool_index + 1]
else:
messages = []
output = await to_agent_loop_output(tokenizer=self.tokenizer,
messages=messages,
response_length=response_length,
tools=self.agent.tools)
return output
@@ -0,0 +1,202 @@
# coding: utf-8
# Copyright (c) 2025 inclusionAI.
import asyncio
import json
import os
from typing import List, Dict, Any
from transformers import AutoTokenizer
from verl.experimental.agent_loop.agent_loop import AgentLoopBase, AgentLoopOutput, AgentLoopMetrics
async def to_agent_loop_output(tokenizer: AutoTokenizer,
messages: List[Dict[str, Any]],
response_length: int,
tools: Dict[str, Any] = None) -> AgentLoopOutput:
"""Convert messages to AgentLoopOutput.
Args:
tokenizer (AutoTokenizer): Tokenizer for tokenize messages.
messages (List[Dict[str, Any]]): List of messages in OpenAI request format.
response_length (int): Max length of response.
tools: Tool list used by the agent.
Returns:
AgentLoopOutput: agent loop output trajectory used for training.
"""
# Ensure tools is iterable for chat templates that iterate over tools
if tools is None:
tools = []
# Normalize messages to satisfy chat templates expectations
def _normalize_message(msg: Dict[str, Any]) -> Dict[str, Any]:
normalized = dict(msg)
# content may be None when assistant only returns tool_calls; make it empty string
if normalized.get("content") is None:
normalized["content"] = ""
# Ensure tool_calls.function.arguments is a string (many templates expect str)
if isinstance(normalized.get("tool_calls"), list):
fixed_calls = []
for call in normalized["tool_calls"]:
call_copy = dict(call)
func = call_copy.get("function")
if isinstance(func, dict):
func_copy = dict(func)
args_val = func_copy.get("arguments")
if not isinstance(args_val, (str, bytes)):
try:
func_copy["arguments"] = json.dumps(args_val, ensure_ascii=False)
except Exception:
func_copy["arguments"] = str(args_val)
call_copy["function"] = func_copy
fixed_calls.append(call_copy)
normalized["tool_calls"] = fixed_calls
return normalized
if not messages:
return AgentLoopOutput(
prompt_ids=[],
response_ids=[],
response_mask=[],
num_turns=0,
metrics={},
)
messages = [_normalize_message(m) for m in messages]
num_turns = 0
for i in range(len(messages)):
if messages[i].get("role") == "system":
continue
# parallel tool calls are in single turn
if i == 0 or messages[i].get("role") != messages[i - 1].get("role"):
num_turns += 1
prompt_ids = []
response_ids = []
response_mask = []
chat_list = []
loop = asyncio.get_running_loop()
# system_prompt_prefix_ids = self.tokenizer.apply_chat_template([{}], add_generation_prompt=False, tokenize=True)
i = 0
try:
while i < len(messages):
if messages[i].get("role") == "system":
chat_list.append(messages[i])
i += 1
continue
# initial chat completion
if messages[i].get("role") == "user":
if i == 0 or messages[i - 1].get("role") == "system":
chat_list.append(messages[i])
prompt_ids = await loop.run_in_executor(
None,
lambda: tokenizer.apply_chat_template(
chat_list,
tools=tools,
add_generation_prompt=True,
tokenize=True,
),
)
else:
chat_list.append(messages[i])
cur_response_ids = await loop.run_in_executor(
None,
lambda: tokenizer.apply_chat_template(
chat_list,
add_generation_prompt=False,
tokenize=True,
),
)
response_ids += cur_response_ids
response_mask += [0] * len(cur_response_ids)
chat_list = []
i += 1
continue
# assistant message
if messages[i].get("role") == "assistant":
chat_list.append(messages[i])
cur_response_ids = await loop.run_in_executor(
None,
lambda: tokenizer.apply_chat_template(
chat_list,
add_generation_prompt=False,
tokenize=True,
),
)
chat_list = []
response_ids += cur_response_ids
response_mask += [1] * len(cur_response_ids)
i += 1
continue
# follow up chat completion with tool response:
if messages[i].get("role") == "tool":
last_assistant_message = messages[i - 1]
chat_list.append(last_assistant_message)
token_assistant = await loop.run_in_executor(
None,
lambda: tokenizer.apply_chat_template(
chat_list,
add_generation_prompt=False,
tokenize=True,
),
)
while i < len(messages) and messages[i].get("role") == "tool":
chat_list.append(messages[i])
i += 1
token_assistant_tool = await loop.run_in_executor(
None,
lambda: tokenizer.apply_chat_template(
chat_list,
add_generation_prompt=False,
tokenize=True,
),
)
tool_response_ids = token_assistant_tool[len(token_assistant):]
chat_list = []
response_ids += tool_response_ids
response_mask += [0] * len(tool_response_ids)
except Exception as e:
raise Exception(f"Failed to convert messages to agentloop_output: {messages}.Exception is: {e}")
max_response_length = min(response_length, len(response_ids))
output = AgentLoopOutput(
prompt_ids=prompt_ids,
response_ids=response_ids[:max_response_length],
response_mask=response_mask[:max_response_length],
num_turns=num_turns,
metrics={},
)
return output
def get_agent_tool_env_and_servers(tool_config: Dict[str, Any] = None) -> tuple[Dict[str, Any], List[str]]:
if not tool_config or not tool_config.get("url") or not tool_config.get("authorization"):
tool_config["url"] = os.getenv("MCP_SERVER_URL")
tool_config["authorization"] = f"Bearer {os.getenv('MCP_SERVER_TOKEN')}"
url = tool_config.get("url")
authorization = tool_config.get("authorization")
mcp_servers_str = tool_config.get("mcp_servers", "")
if not url or not authorization:
raise ValueError("url, Authorization are required. Please set MCP_SERVER_URL and MCP_SERVER_TOKEN environment variable \
or provide them in tool_config parameter.")
server_name = tool_config.get('server_name', 'aworld-mcp')
server_type = tool_config.get('type', 'streamable-http')
timeout = tool_config.get('timeout', 600)
sse_read_timeout = tool_config.get('sse_read_timeout', 600)
client_session_timeout_seconds = tool_config.get('client_session_timeout_seconds', 600)
mcp_config = {
"mcpServers": {
server_name: {
"type": server_type,
"url": url,
"headers": {
"Authorization": authorization,
"MCP_SERVERS": mcp_servers_str,
},
"timeout": timeout,
"sse_read_timeout": sse_read_timeout,
"client_session_timeout_seconds": client_session_timeout_seconds,
}
}
}
servers = list(server_name for server_name in mcp_config.get("mcpServers", {}).keys())
return mcp_config, servers