Source code for agentsociety2.society.society

"""仿真社会编排模块(record-based,无 agent 对象常驻)。

:class:`AgentSociety` 不再持有 agent 对象——agent 是磁盘上的记录,每 tick 通过
Ray Task(:mod:`agentsociety2.agent.runner`)流式处理。这使得内存占用与总 agent 数 N
解耦:driver 只持有 specs(元数据)+ ids + 句柄(env / service_proxy),并行度来自
"大量 Ray Task"而非"主进程内的 N 个对象"。

主要功能:

- **仿真初始化**: 初始化 Ray / LLM dispatcher、service_proxy(已注入则复用)、批式创建 agent
  workspaces(不在主进程实例化 agent)、初始化环境路由器、可选回放写入 agent profile。
- **时间推进**: 通过 ``step()``/``run()`` 每 tick 切批提交 ``step_agent_batch`` Ray Task,
  ``await`` 全部后再推进 env、前进时钟。
- **交互接口**: ``ask()``/``intervene()``/``run_questionnaire()`` 为低频外部查询,在主进程
  内按需 ``from_workspace`` 重建目标 agent(workspace 在本地磁盘,无需 Ray Task)。
- **状态持久化**: ``to_workspace()`` 每 step 写 ``run_dir/SOCIETY.json``(society
  时间 / 步数 / env 模块配置)并让 env router 落盘各模块动态状态;``from_workspace()``
  从 ``run_dir`` 重建 society(agent 状态已落盘,env 由 actor 在 ``init`` 中恢复),
  支持中断后 CLI ``--resume`` 续跑。
"""

from __future__ import annotations

import asyncio
import json
from datetime import datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional

from agentsociety2.storage.workspace_state import atomic_write_text

from agentsociety2.agent.runner import (
    create_agents_batch,
    questionnaire_agent_batch,
    step_agent_batch,
)
from agentsociety2.env import RouterBase
from agentsociety2.society.helper import AgentSocietyHelper
from agentsociety2.society.questionnaire import (
    AgentQuestionnaireResult,
    Questionnaire,
    QuestionnaireResponse,
)
from agentsociety2.storage import (
    ColumnDef,
    ReplayDatasetSpec,
    TableSchema,
)
from agentsociety2.storage.replay_metadata import (
    AGENT_PROFILE_DATASET_CAPABILITY,
    AGENT_PROFILE_DATASET_ID,
    AGENT_PROFILE_TABLE_NAME,
)
from agentsociety2.logger import get_logger

if TYPE_CHECKING:
    from agentsociety2.agent.base import AgentBase
    from agentsociety2.agent.service_proxy import ServiceProxy

__all__ = ["AgentSociety"]

logger = get_logger()

# checkpoint 布局:SOCIETY.json 只在 init 写一次(含不可变的 agent_specs 等,
# 避免每步重序列化大列表);SOCIETY_STEP.json 每步写少量标量。两文件均原子写入。
SOCIETY_JSON = "SOCIETY.json"
SOCIETY_STEP_JSON = "SOCIETY_STEP.json"
SOCIETY_SCHEMA_VERSION = 1


def _json_safe_profile(profile: Any) -> dict[str, Any]:
    """Convert an arbitrary profile payload into a JSON-safe dict."""
    if not isinstance(profile, dict):
        profile = {"raw": str(profile)}
    try:
        return json.loads(json.dumps(profile, ensure_ascii=False, default=str))
    except Exception:
        return {"raw": str(profile)}


def _chunked(lst: list, n: int):
    """Yield successive chunks of size ``n`` from ``lst``."""
    if n <= 0:
        n = 1
    for i in range(0, len(lst), n):
        yield lst[i : i + n]


def _agent_name_from_spec(spec: dict) -> str:
    """Derive a display name from an agent spec (no reconstruction needed)."""
    profile = spec.get("profile") or {}
    if isinstance(profile, dict):
        name = profile.get("name")
        if isinstance(name, str) and name:
            return name
    return f"Agent_{spec.get('id')}"


[docs] class AgentSociety: """Record-based 仿真编排器(不持有 agent 对象)。 AgentSociety 是框架的核心类,负责管理仿真生命周期: - 持有 **agent_specs**(元数据:``{"id","profile","config"}``)与 **agent_ids**, 绝不在主进程常驻 agent 对象。 - ``step`` 每 tick 切批提交 ``step_agent_batch`` Ray Task,跨 worker 并行;批内顺序执行 (每个 agent 是 LLM-bound,顺序无妨)。 - ``ask``/``intervene``/``run_questionnaire``/``dump`` 为低频外部查询,按需在主进程 ``from_workspace`` 重建目标 agent(workspace 在本地磁盘)。 Example:: from datetime import datetime from pathlib import Path from agentsociety2.society import AgentSociety society = AgentSociety( agent_specs=[{"id":1,"profile":{...},"config":{...}}, ...], agent_class_name="PersonAgent", env_router=env_proxy, service_proxy=proxy, start_t=datetime.now(), run_dir=Path("./run"), ) async with society: await society.run(num_steps=100, tick=3600) """
[docs] def __init__( self, agent_specs: list[dict], agent_class_name: str, env_router: RouterBase, start_t: datetime, run_dir: Optional[Path] = None, *, service_proxy: Optional["ServiceProxy"] = None, batch_size: Optional[int] = None, enable_replay: bool = True, env_module_types: Optional[list[str]] = None, env_kwargs: Optional[dict[str, dict]] = None, ): """创建 record-based 仿真编排器。 :param agent_specs: agent 元数据列表,每个形如 ``{"id": int, "profile": dict, "config": dict}``。仅元数据,不在构造时实例化。 :param agent_class_name: agent 类名(经 registry 解析,如 ``"PersonAgent"``)。 :param env_router: 环境路由器(in-process 或 ``EnvRouterProxy``)。 :param start_t: 仿真开始时间。 :param run_dir: 运行目录(``run_dir/agents`` 为 agent workspaces 根)。 :param service_proxy: 共享服务句柄(env / llm / trace / replay)。流式任务 处理必需——runner 任务通过它访问 LLM clients / env actor。提供时 :meth:`init` 直接复用;``close`` 关闭其中 trace/replay actor。 :param batch_size: 每 ``step_agent_batch`` Ray Task 处理的 agent 数; ``None`` 时回落到 ``Config.BATCH_SIZE``(环境变量 ``AGENTSOCIETY_BATCH_SIZE``,缺省 256)。 :param enable_replay: 是否启用回放记录。 """ self._agent_specs: list[dict] = list(agent_specs) self._agent_class_name: str = agent_class_name self._agent_ids: list[int] = [int(s["id"]) for s in self._agent_specs] self._env_router = env_router self._t = start_t self._should_terminate: bool = False self._step_count: int = 0 # Resolve to ABSOLUTE paths. Ray worker processes may run with a cwd # inside an uploaded runtime-env package (/tmp/ray/.../_ray_pkg_.../), # not the driver's cwd — so a relative run_dir would resolve to a # phantom path inside the package and agent workspaces would never land # where the driver (and replay/trace) expect them. self._run_dir = Path(run_dir).resolve() if run_dir is not None else None self._workspace_root: Path = ( (self._run_dir / "agents").resolve() if self._run_dir is not None else Path("./agents").resolve() ) from agentsociety2.config import Config resolved_batch_size = ( batch_size if batch_size is not None else Config.BATCH_SIZE ) self._batch_size = max(1, int(resolved_batch_size)) self._enable_replay = enable_replay # env 模块类型 + init kwargs:写入 SOCIETY.json,resume 时据此重建 env 模块。 self._env_module_types: list[str] = list(env_module_types or []) self._env_kwargs: dict[str, dict] = { k: dict(v) for k, v in (env_kwargs or {}).items() } # resume 游标:已完成的前置 step 数(含 Ask/Intervene/Questionnaire), # 由 CLI 在每个顶层 step 开始前推进;resume 时据此跳过已执行的前置步。 self._completed_step_count: int = 0 # steps.yaml 的 hash(CLI 在 init 前设置),用于 resume 时检测 steps 漂移。 self._steps_hash: Optional[str] = None self._service_proxy: Optional["ServiceProxy"] = service_proxy self._owns_service_proxy = service_proxy is not None self._replay_writer: Any = None self._agent_profiles_persisted = False self._agents_created = False # SOCIETY.json(不可变全量)只在 init 写一次;resume 时由 from_workspace # 置 True 跳过,避免覆盖原 checkpoint。 self._society_json_written = False # Aggregate LLM token usage across all Ray Task batches (instance-level, # not module-global). Each batch returns a per-task delta. self._token_stats: dict[str, dict[str, int]] = {} # Helper is built lazily on first ask/intervene (it reconstructs agents on demand). self._helper: Optional[AgentSocietyHelper] = None
# ------------------------------------------------------------------ # Properties # ------------------------------------------------------------------ @property def current_time(self) -> datetime: """返回当前仿真时间。""" return self._t @property def step_count(self) -> int: """返回已执行的仿真步数。""" return self._step_count @property def agent_ids(self) -> list[int]: """返回所有 agent id(不暴露 agent 对象)。""" return list(self._agent_ids) @property def agent_specs(self) -> list[dict]: """返回 agent specs(元数据快照)。""" return list(self._agent_specs) # ------------------------------------------------------------------ # Replay: agent profile persistence (record-based) # ------------------------------------------------------------------ async def _persist_agent_profiles_once(self) -> None: if self._replay_writer is None or self._agent_profiles_persisted: return columns = [ ColumnDef( "id", "INTEGER", nullable=False, logical_type="entity_id", description="Unique agent identifier.", ), ColumnDef( "name", "TEXT", nullable=False, logical_type="label", description="Agent display name.", ), ColumnDef( "profile", "JSON", nullable=False, logical_type="json", description="Static agent profile payload captured at simulation init.", ), ColumnDef( "created_at", "TIMESTAMP", nullable=False, logical_type="timestamp", description="When the agent profile snapshot was persisted.", ), ] await self._replay_writer.register_table( TableSchema( name=AGENT_PROFILE_TABLE_NAME, columns=columns, primary_key=["id"], indexes=[["name"]], ) ) await self._replay_writer.register_dataset( ReplayDatasetSpec( dataset_id=AGENT_PROFILE_DATASET_ID, table_name=AGENT_PROFILE_TABLE_NAME, module_name="AgentSociety", kind="entity_static", title="Agent Profiles", description="Static agent profiles persisted once when the simulation initializes.", entity_key="id", default_order=["id"], capabilities=[AGENT_PROFILE_DATASET_CAPABILITY, "entity_static"], ), columns, ) rows = [ { "id": int(spec["id"]), "name": _agent_name_from_spec(spec), "profile": _json_safe_profile(spec.get("profile", {})), "created_at": self._t, } for spec in self._agent_specs ] if rows: await self._replay_writer.write_batch(AGENT_PROFILE_TABLE_NAME, rows) self._agent_profiles_persisted = True # ------------------------------------------------------------------ # Workspace helpers # ------------------------------------------------------------------ def _workspace_for(self, agent_id: int) -> Path: """Return ``<workspace_root>/agent_<id:04d>``.""" return self._workspace_root / f"agent_{int(agent_id):04d}" async def _create_agent_workspaces(self) -> None: """Batch-create agent workspaces via ``create_agents_batch`` Ray Tasks. Agents are NOT instantiated in the society process — each task writes ``config.json`` + initial ``AGENT.json`` for its chunk of specs. """ if self._agents_created or not self._agent_specs: self._agents_created = True return refs = [] for chunk in _chunked(self._agent_specs, self._batch_size): refs.append( create_agents_batch.remote( chunk, str(self._workspace_root), self._agent_class_name, ) ) if refs: await asyncio.gather(*[self._await_ref(r) for r in refs]) self._agents_created = True async def _resolve_service_proxy(self) -> "ServiceProxy": """Return the bound service proxy, building one if none injected.""" if self._service_proxy is not None: return self._service_proxy from agentsociety2.agent.service_proxy import build_service_proxy proxy = build_service_proxy( self._env_router, run_dir=self._run_dir, trace=False, replay=bool(self._enable_replay and self._run_dir is not None), ) self._service_proxy = proxy self._owns_service_proxy = True return proxy # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------
[docs] async def init(self): """初始化:Ray / LLM dispatcher → service_proxy → replay → 批式创建 workspaces → env init。""" # Initializes Ray and per-process LLM dispatch support (idempotent). from agentsociety2.config.llm_dispatcher import init_dispatchers await init_dispatchers() proxy = await self._resolve_service_proxy() # Replay: the single proxy.replay handle (a the distributed ReplayProxy proxy). self._replay_writer = getattr(proxy, "replay", None) if self._replay_writer is not None: await self._persist_agent_profiles_once() try: self._env_router.set_replay_writer(self._replay_writer) except Exception: logger.debug("Failed to set replay writer on env router", exc_info=True) # Ensure workspace_root exists before tasks write into it. self._workspace_root.mkdir(parents=True, exist_ok=True) # Batch-create agent workspaces (no agent objects in this process). await self._create_agent_workspaces() await self._env_router.init(self._t) # 写一次不可变全量 checkpoint(含 agent_specs / env 配置 / steps_hash)。 # resume 时(from_workspace 已置 _society_json_written=True)跳过,避免覆盖。 if self._run_dir is not None and not self._society_json_written: self._write_society_json() self._society_json_written = True
[docs] async def close(self): """关闭:env router + service_proxy 的 trace/replay 句柄 + LLM dispatcher。""" try: await self._env_router.close() except Exception: logger.debug("Error closing env router", exc_info=True) # Close the proxy's replay actor (if we own the proxy). Trace needs no # central close: it is distributed — each writer process appends # directly and its fds are reclaimed on process exit. if self._owns_service_proxy and self._service_proxy is not None: replay = getattr(self._service_proxy, "replay", None) if replay is not None and hasattr(replay, "close"): try: await replay.close() except Exception: logger.debug("Error closing replay writer", exc_info=True) # LLM shutdown is currently a no-op; local Routers die with the process. from agentsociety2.config.llm_dispatcher import shutdown_dispatchers await shutdown_dispatchers()
# ------------------------------------------------------------------ # 状态持久化 / resume(无状态 checkpoint) # ------------------------------------------------------------------
[docs] async def to_workspace(self, *, tick: Optional[int] = None) -> None: """持久化 society + env 状态(每步调用,无状态 resume)。 让 env router 落盘各模块状态,并写 ``run_dir/SOCIETY_STEP.json``(仅少量 标量:当前时间 / 步数 / 已完成前置步数 / terminated)。不可变的大字段 (agent_specs 等)只在 :meth:`init` 写一次到 ``SOCIETY.json``,避免每步 重序列化。``run_dir`` 为空时 no-op;env 落盘失败只告警,不中断 step。 :param tick: 保留以兼容旧调用签名(不再单独持久化)。 """ if self._run_dir is None: return try: await self._env_router.to_workspaces() except Exception as exc: # noqa: BLE001 - 落盘失败不中断 step logger.warning("env to_workspaces failed: %s", exc) self._write_society_step_json()
def _write_society_json(self) -> None: """写不可变全量 checkpoint 到 ``run_dir/SOCIETY.json``(init 时写一次)。 含 schema_version、agent_specs、env 模块类型+kwargs、batch_size、steps_hash。 原子写入。 """ assert self._run_dir is not None payload = { "schema_version": SOCIETY_SCHEMA_VERSION, "agent_class_name": self._agent_class_name, "agent_specs": list(self._agent_specs), "env_module_types": list(self._env_module_types), "env_kwargs": dict(self._env_kwargs), "batch_size": self._batch_size, "steps_hash": self._steps_hash, } atomic_write_text( self._run_dir / SOCIETY_JSON, json.dumps(payload, ensure_ascii=False, indent=2, default=str), ) def _write_society_step_json(self) -> None: """写每步标量 checkpoint 到 ``run_dir/SOCIETY_STEP.json``(原子写入)。""" assert self._run_dir is not None payload = { "current_time": self._t.isoformat(), "step_count": self._step_count, "completed_step_count": self._completed_step_count, "terminated": self._should_terminate, } atomic_write_text( self._run_dir / SOCIETY_STEP_JSON, json.dumps(payload, ensure_ascii=False, indent=2, default=str), )
[docs] def mark_step_completed(self, step_idx: int) -> None: """标记第 ``step_idx`` 个顶层 step 已完成并立即持久化进度。 由 CLI 在每个顶层 step(含 Ask/Intervene/Questionnaire)成功后调用—— 否则连续的非 RunStep 完成后若崩溃,resume 会把它们当作未执行而重跑。 """ if self._run_dir is None: return self._completed_step_count = step_idx + 1 self._write_society_step_json()
@staticmethod def _read_checkpoint(run_dir: Path) -> tuple[dict, dict]: """读取并校验 society checkpoint,返回 (society_meta, step_meta)。 :raises FileNotFoundError: SOCIETY.json 不存在。 :raises ValueError: schema_version 缺失/不支持,或字段残缺。 """ run_dir = Path(run_dir) meta_path = run_dir / SOCIETY_JSON if not meta_path.is_file(): raise FileNotFoundError( f"Cannot resume: {meta_path} not found (not a checkpoint run_dir)" ) try: meta = json.loads(meta_path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: raise ValueError( f"Cannot resume: {meta_path} is corrupt or was truncated " f"(non-atomic write of a prior crash?): {exc}" ) from exc schema_version = meta.get("schema_version") if schema_version != SOCIETY_SCHEMA_VERSION: raise ValueError( f"Cannot resume: {meta_path} schema_version={schema_version!r}, " f"expected {SOCIETY_SCHEMA_VERSION}. The checkpoint was written by " "an incompatible build." ) if "agent_class_name" not in meta: raise ValueError(f"Cannot resume: {meta_path} missing agent_class_name") step_path = run_dir / SOCIETY_STEP_JSON step_meta: dict = {} if step_path.is_file(): try: step_meta = json.loads(step_path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: raise ValueError( f"Cannot resume: {step_path} is corrupt or was truncated: {exc}" ) from exc return meta, step_meta
[docs] @classmethod async def from_workspace( cls, run_dir: Path, *, env_router: RouterBase, service_proxy: Optional["ServiceProxy"] = None, ) -> "AgentSociety": """从 ``run_dir`` 重建 society(resume)。 读 ``SOCIETY.json``(不可变:agent specs、env 模块类型+kwargs)+ ``SOCIETY_STEP.json``(每步标量:当前时间、步数、已完成前置步数)。 ``env_router`` 必须由调用方(CLI)预先构造(actor 不可在此重建); env 模块状态由 actor 在自身 ``init()`` 中恢复。 返回的 society 的 :meth:`init` 会跳过 agent workspace 创建与 profile 重写 (原 run 已存在),也不会覆盖已有的 ``SOCIETY.json``。 :raises FileNotFoundError: ``run_dir/SOCIETY.json`` 不存在。 :raises ValueError: schema 不兼容或 checkpoint 损坏。 """ run_dir = Path(run_dir).resolve() meta, step_meta = cls._read_checkpoint(run_dir) if step_meta.get("current_time"): current_time = datetime.fromisoformat(str(step_meta["current_time"])) else: # 旧布局(仅有 SOCIETY.json,无 SOCIETY_STEP.json)——回退到 SOCIETY.json。 current_time = datetime.fromisoformat(str(meta["current_time"])) society = cls( agent_specs=list(meta.get("agent_specs", [])), agent_class_name=str(meta["agent_class_name"]), env_router=env_router, start_t=current_time, run_dir=run_dir, service_proxy=service_proxy, batch_size=meta.get("batch_size"), enable_replay=True, env_module_types=list(meta.get("env_module_types", [])), env_kwargs=dict(meta.get("env_kwargs", {})), ) society._steps_hash = meta.get("steps_hash") society._step_count = int(step_meta.get("step_count", meta.get("step_count", 0))) society._completed_step_count = int(step_meta.get("completed_step_count", 0)) # init() 时跳过 agent workspace 创建、profile 重写,以及 SOCIETY.json 覆盖。 society._agents_created = True society._agent_profiles_persisted = True society._society_json_written = True return society
async def __aenter__(self): await self.init() return self async def __aexit__(self, exc_type, exc_value, traceback): await self.close() # ------------------------------------------------------------------ # Step / Run — the hot path (no agent objects held) # ------------------------------------------------------------------
[docs] async def step(self, tick: int): """推进一次仿真步:sync clock → 切批 step_agent_batch → env.step → 前进时钟。 :param tick: 本步时间跨度(秒)。 """ if not self._agent_ids: # 仍推进 env + 时钟。 self._env_router.set_current_time(self._t) await self._env_router.step(tick, self._t) self._t += timedelta(seconds=tick) self._step_count += 1 # 自增后再落盘,checkpoint 反映已完成步数。 await self.to_workspace(tick=tick) return proxy = await self._resolve_service_proxy() # Push the clock to env BEFORE fan-out so all batches share the same # time context (env modules read it during agent tool calls). self._env_router.set_current_time(self._t) # Chunk ids and submit one Ray Task per chunk. refs = [] for ids in _chunked(self._agent_ids, self._batch_size): refs.append( step_agent_batch.remote( ids, str(self._workspace_root), self._agent_class_name, tick, self._t, proxy, ) ) # Await all batches. Each batch returns {"results", "token_stats"}; # fold the per-task token-usage deltas into this society's instance # aggregate (no module-global state). if refs: from agentsociety2.config.llm_dispatcher import merge_token_stats batch_returns = await asyncio.gather( *[self._await_ref(r) for r in refs] ) for br in batch_returns: if isinstance(br, dict) and br.get("token_stats"): self._token_stats = merge_token_stats( self._token_stats, br["token_stats"] ) # Env advances AFTER agents. await self._env_router.step(tick, self._t) self._t += timedelta(seconds=tick) self._step_count += 1 # 自增后再落盘,checkpoint 反映已完成步数。 await self.to_workspace(tick=tick)
[docs] async def run(self, num_steps: int, tick: int): """运行多步仿真。 :param num_steps: 运行步数上限。 :param tick: 每步时间跨度(秒)。 """ for _ in range(num_steps): if self._should_terminate: break await self.step(tick)
@staticmethod async def _await_ref(ref: Any) -> Any: """Await a Ray ObjectRef (Ray >=2 supports ``await ref`` in an asyncio loop).""" return await ref # ------------------------------------------------------------------ # Queries — low-volume, reconstruct target agents locally # ------------------------------------------------------------------ async def _reconstruct_agent(self, agent_id: int) -> "AgentBase": """Reconstruct a single agent in the society process via from_workspace. Used by ask/intervene/questionnaire (low-volume external queries). The workspace lives on local disk, so no Ray Task is needed. """ from agentsociety2.registry import get_agent_module_class cls = get_agent_module_class(self._agent_class_name) if cls is None: try: from agentsociety2 import agent as _agent_mod cls = getattr(_agent_mod, self._agent_class_name, None) except Exception: cls = None if cls is None: raise ValueError( f"Agent class '{self._agent_class_name}' not found in registry." ) proxy = await self._resolve_service_proxy() ws = self._workspace_for(int(agent_id)) return await cls.from_workspace(ws, proxy) async def _reconstruct_agents(self, agent_ids: list[int]) -> list["AgentBase"]: """Reconstruct a subset of agents (parallel from_workspace calls).""" coros = [self._reconstruct_agent(int(aid)) for aid in agent_ids] return list(await asyncio.gather(*coros)) def _get_helper(self) -> AgentSocietyHelper: """Lazily build the plan-and-execute helper bound to this society. The helper receives the society handle (specs/ids + reconstruction callbacks) instead of a pre-built agent list, so it can reconstruct only the agents a specific plan needs. """ if self._helper is None: self._helper = AgentSocietyHelper( env_router=self._env_router, society=self, ) return self._helper
[docs] async def ask(self, question: str) -> str: """向仿真系统提问(由 helper 协调 agents/env 作答)。 :param question: 问题文本。 :returns: 答案文本。 """ return await self._get_helper().ask(question)
[docs] async def intervene(self, instruction: str) -> str: """对仿真进行干预(由 helper 协调执行)。 :param instruction: 干预指令文本。 :returns: 执行结果/反馈文本。 """ return await self._get_helper().intervene(instruction)
[docs] async def run_questionnaire( self, questionnaire: Questionnaire, target_agent_ids: list[int] | None = None, ) -> QuestionnaireResponse: """向目标 agents 发放问卷并返回结构化结果。 目标 agent 按 id 切批,每批提交一个 ``questionnaire_agent_batch`` Ray Task (与 ``step`` 同款分布式批处理):worker 进程内 ``from_workspace`` 重建本批 agents、并发跑完整个问卷、``to_workspace`` 落盘,再返回结构化结果。失败的单个 agent 被隔离(不中断整批),与 ``step_agent_batch`` 的容错语义一致。 :param questionnaire: 问卷定义。 :param target_agent_ids: 目标 agent id(默认全部)。 :returns: 汇总后的问卷响应。 """ ids = ( list(target_agent_ids) if target_agent_ids is not None else list(self._agent_ids) ) # Validate ids against known specs. known = set(self._agent_ids) missing = [i for i in ids if int(i) not in known] if missing: raise ValueError(f"Unknown target_agent_ids: {missing}") ordered_ids = [int(i) for i in ids] if not ordered_ids: return QuestionnaireResponse( questionnaire_id=questionnaire.questionnaire_id, title=questionnaire.title, description=questionnaire.description, simulation_time=self._t.isoformat(), step_count=self._step_count, target_agent_ids=ordered_ids, questions=questionnaire.questions, responses=[], ) proxy = await self._resolve_service_proxy() # Chunk ids and submit one Ray Task per chunk — same fan-out as step(). refs = [] for chunk in _chunked(ordered_ids, self._batch_size): refs.append( questionnaire_agent_batch.remote( chunk, str(self._workspace_root), self._agent_class_name, questionnaire, self._t, self._step_count, proxy, ) ) from agentsociety2.config.llm_dispatcher import merge_token_stats batch_returns = await asyncio.gather( *[self._await_ref(r) for r in refs] ) # Merge per-agent results across batches; aggregate token deltas. results_by_id: dict[int, dict] = {} failed: list[tuple[int, str]] = [] for br in batch_returns: if isinstance(br, dict) and br.get("token_stats"): self._token_stats = merge_token_stats( self._token_stats, br["token_stats"] ) for r in br.get("results", []): results_by_id[int(r["id"])] = r if not r.get("ok"): failed.append((int(r["id"]), str(r.get("error")))) responses: list[AgentQuestionnaireResult] = [] for aid in ordered_ids: r = results_by_id.get(aid) if r is not None and r.get("ok") and r.get("result") is not None: responses.append( AgentQuestionnaireResult.model_validate(r["result"]) ) if failed: logger.warning( "Questionnaire %s: %d/%d agents failed (skipped): %s", questionnaire.questionnaire_id, len(failed), len(ordered_ids), failed[:5], ) return QuestionnaireResponse( questionnaire_id=questionnaire.questionnaire_id, title=questionnaire.title, description=questionnaire.description, simulation_time=self._t.isoformat(), step_count=self._step_count, target_agent_ids=ordered_ids, questions=questionnaire.questions, responses=responses, )