Files
xiaoluolyg--god/agentsociety/custom/agents/jiuwenclaw_agent.py
T
wehub-resource-sync aa1a5d5763
CI / Release hygiene (push) Waiting to run
CI / Python targeted tests (push) Waiting to run
CI / Frontend build (push) Waiting to run
Pages / Build and deploy public pages (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:18:17 +08:00

1886 lines
77 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""AgentSociety2 custom agent backed by a JiuwenClaw AgentServer.
JiuwenClaw runs as a separate process. This adapter only speaks the
AgentServer WebSocket protocol shape that JiuwenClaw already accepts, so the
AgentSociety runtime does not need to import JiuwenClaw or openjiuwen.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, ClassVar
from urllib.parse import urlsplit
from agentsociety2.agent.base import AgentBase
from agentsociety2.agent.skills import SkillRegistry
from agentsociety2.agent.skills.runtime import AgentSkillRuntime
DEFAULT_JIUWENCLAW_WS_URL = "ws://127.0.0.1:18092"
DEFAULT_CHANNEL_ID = "agentsociety"
DEFAULT_MODE = "agent.plan"
DEFAULT_JIUWENCLAW_REQUEST_CONCURRENCY = 24
JIUWENCLAW_REQUEST_CONCURRENCY_ENV = "AGENTSOCIETY_JIUWENCLAW_REQUEST_CONCURRENCY"
logger = logging.getLogger(__name__)
DEFAULT_COMMON_SKILLS = [
"routine.daily",
"social.reply",
"memory.record",
"map.navigate",
"safety.respond",
]
SKILL_CHINESE_LABELS = {
"care.basic": "基础关怀",
"chronic.followup": "健康随访",
"class.learn": "课堂学习",
"class.organize": "课堂组织",
"community.coordinate": "社区协调",
"community.observe": "社区观察",
"computer.basic": "基础电脑处理",
"computer.repair": "电脑维修",
"conflict.mediate": "矛盾调解",
"cooking.lightmeal": "简餐准备",
"crowd.guide": "人群引导",
"emotion.calm": "情绪安抚",
"first_aid.basic": "基础急救",
"garden.basic": "庭院照料",
"gossip.filter": "消息甄别",
"health.educate": "健康说明",
"history.localtelling": "本地故事讲述",
"info.research": "信息查证",
"ingredient.advise": "食材建议",
"inventory.count": "库存清点",
"ledger.basic": "账目记录",
"library.curate": "图书整理",
"listen.relay": "倾听转达",
"map.navigate": "地图导航",
"memory.record": "记忆记录",
"messaging.group": "群组通知",
"neighbor.greet": "邻里问候",
"neighbor.support": "邻里支持",
"notice.write": "公告撰写",
"patrol.plan": "巡查规划",
"peer.communicate": "同伴沟通",
"phone.photolog": "手机记录",
"price.negotiate": "价格协商",
"privacy.protect": "隐私保护",
"radio.comms": "无线电沟通",
"record.shortnote": "短笔记记录",
"remote.communicate": "远程沟通",
"repair.basic": "基础维修",
"roster.verify": "名单核对",
"route.localmap": "本地路线判断",
"route.recall": "路线回忆",
"routine.daily": "日常安排",
"safety.respond": "安全响应",
"script.automate": "脚本自动化",
"shop.run": "店铺经营",
"sketch.draw": "速写记录",
"social.matchmake": "牵线介绍",
"social.reply": "社交回复",
"stall.run": "摊位经营",
"story.localpast": "本地旧事讲述",
"tools.repair": "工具维修",
"vegetable.source": "蔬菜采购",
"writing.feedback": "写作反馈",
"writing.hand": "手写记录",
"youth.communicate": "青少年沟通",
}
STATUS_LABELS = {
"active": "活跃",
"available": "可交流",
"calm": "平静",
"caring": "照护中",
"content": "满足",
"coordinating": "协调中",
"eating": "用餐中",
"focused": "专注",
"moving": "移动中",
"peaceful": "安稳",
"ready": "就绪",
"resting": "休息中",
"socializing": "社交中",
"starting_day": "开始一天",
"studying": "学习中",
"teaching": "授课中",
"tired": "疲惫",
"warm": "温和",
"winding_down": "放松中",
"working": "工作中",
}
CHINESE_OUTPUT_POLICY = (
"语言硬性规则:除 JSON 键名、action_type 枚举、location_id、interaction_id、"
"skill_id、session_id、URL 等机器标识符外,所有会被人看到的自然语言都必须使用"
"简体中文。public_summary、environment_instruction、action_proposal.content、"
"action、status、emotion、reason、事件、通知、记忆内容和对话内容都不能出现英文句子或英文词组;"
"智能体姓名可以保留英文。"
)
SKILL_RESULT_SCHEMA_VERSION = "agent_skill_result.v1"
URGENT_INTERVENTION_KEYWORDS = (
"火山",
"爆发",
"地震",
"火灾",
"洪水",
"海啸",
"爆炸",
"撤离",
"疏散",
"紧急",
"危险",
"灾",
"evacuate",
"emergency",
"volcano",
"earthquake",
"fire",
"flood",
)
PUBLIC_GROUP_SKILL_IDS = {
"safety.respond",
"notice.write",
"radio.comms",
"messaging.group",
}
def _workspace_root_from_file() -> str:
"""Infer the AgentSociety workspace root from custom/agents/this_file.py."""
try:
return str(Path(__file__).resolve().parents[2])
except IndexError:
return str(Path.cwd())
def _json_safe(value: Any) -> Any:
"""Return a JSON-serializable representation for profile/dump fields."""
try:
json.dumps(value, ensure_ascii=False)
return value
except TypeError:
if hasattr(value, "model_dump"):
return value.model_dump()
return str(value)
def _contains_latin_text(value: Any) -> bool:
return any("a" <= ch.lower() <= "z" for ch in str(value or ""))
def _contains_latin_text_outside_terms(value: Any, allowed_terms: list[str]) -> bool:
text = str(value or "")
terms: set[str] = set()
for item in allowed_terms:
term = str(item).strip()
if not term:
continue
terms.add(term)
terms.update(
piece
for piece in term.replace("-", " ").replace("_", " ").split()
if _contains_latin_text(piece)
)
for term in sorted(terms, key=len, reverse=True):
text = text.replace(term, "")
return _contains_latin_text(text)
class JiuwenClawAgent(AgentBase):
"""AgentSociety2 AgentBase adapter for a running JiuwenClaw AgentServer."""
_request_semaphore: ClassVar[asyncio.Semaphore | None] = None
_request_semaphore_limit: ClassVar[int | None] = None
_request_semaphore_loop: ClassVar[asyncio.AbstractEventLoop | None] = None
@classmethod
def _request_concurrency_limit(cls) -> int:
raw_value = os.getenv(JIUWENCLAW_REQUEST_CONCURRENCY_ENV, "").strip()
if not raw_value:
return DEFAULT_JIUWENCLAW_REQUEST_CONCURRENCY
try:
value = int(raw_value)
except ValueError:
return DEFAULT_JIUWENCLAW_REQUEST_CONCURRENCY
return max(1, value)
@classmethod
def _request_semaphore_for_current_loop(cls) -> tuple[asyncio.Semaphore, int]:
limit = cls._request_concurrency_limit()
loop = asyncio.get_running_loop()
if (
cls._request_semaphore is None
or cls._request_semaphore_limit != limit
or cls._request_semaphore_loop is not loop
):
cls._request_semaphore = asyncio.Semaphore(limit)
cls._request_semaphore_limit = limit
cls._request_semaphore_loop = loop
return cls._request_semaphore, limit
def __init__(
self,
id: int,
profile: Any,
name: str | None = None,
jiuwenclaw_ws_url: str = DEFAULT_JIUWENCLAW_WS_URL,
session_id: str | None = None,
mode: str = DEFAULT_MODE,
trusted_dirs: list[str] | None = None,
request_timeout: float = 600.0,
enable_memory: bool = True,
channel_id: str = DEFAULT_CHANNEL_ID,
enable_daily_life: bool = True,
daily_life_skill_path: str | None = None,
enable_skill_runtime: bool = True,
common_skill_ids: list[str] | None = None,
skill_ids: list[str] | None = None,
mounted_skill_ids: list[str] | None = None,
skill_runtime_skill_names: list[str] | None = None,
experiment_context: Any | None = None,
) -> None:
super().__init__(id=id, profile=profile, name=name)
self._jiuwenclaw_ws_url = jiuwenclaw_ws_url
self._session_id = session_id or f"agentsociety_agent_{id}"
self._mode = mode
self._trusted_dirs = trusted_dirs or [_workspace_root_from_file()]
self._request_timeout = float(request_timeout)
self._enable_memory = bool(enable_memory)
self._channel_id = channel_id or DEFAULT_CHANNEL_ID
# Legacy constructor fields are accepted for old configs. Runtime
# behavior is always driven by executable AgentSociety skills.
_ = (
enable_daily_life,
daily_life_skill_path,
enable_skill_runtime,
skill_runtime_skill_names,
)
self._enable_skill_runtime = True
self._experiment_context = experiment_context
self._common_skill_ids = self._normalize_skill_ids(
common_skill_ids or DEFAULT_COMMON_SKILLS
)
personal_skill_ids = self._normalize_skill_ids(skill_ids or [])
if not personal_skill_ids and mounted_skill_ids:
common_set = set(self._common_skill_ids)
personal_skill_ids = [
item
for item in self._normalize_skill_ids(mounted_skill_ids)
if item not in common_set
]
self._skill_ids = personal_skill_ids
self._skill_registry = SkillRegistry()
self._skill_registry.scan_custom(_workspace_root_from_file())
self._skill_runtime = AgentSkillRuntime(
agent_id=id,
registry=self._skill_registry,
)
self._ws: Any = None
self._ws_lock = asyncio.Lock()
self._last_response: str = ""
self._last_environment_result: str = ""
self._agent_work_dir: Path | None = None
self._pending_interventions: list[str] = []
self._recent_live_questions: list[dict[str, str]] = []
self._last_selected_skills: set[str] = set()
self._last_activated_skills: set[str] = set()
self._last_skill_results: list[dict[str, Any]] = []
self._last_action_proposal: dict[str, Any] = {}
self._last_social_action_proposal: dict[str, Any] = {}
self._last_skill_decision: dict[str, Any] = {}
self._last_skill_result: dict[str, Any] = {}
self._last_environment_effects: list[dict[str, Any]] = []
@classmethod
def mcp_description(cls) -> str:
return """JiuwenClawAgent: AgentSociety2 custom agent backed by JiuwenClaw
Runs JiuwenClaw as an external AgentServer and communicates over WebSocket.
Profile fields are free-form. Useful fields:
- name: display name
- role: simulation role
- persona: behavior/personality notes
Initialization example:
```json
{
"id": 0,
"profile": {
"name": "Jiuwen assistant",
"role": "JiuwenClaw-driven simulation agent"
},
"jiuwenclaw_ws_url": "ws://127.0.0.1:18092",
"session_id": "agentsociety_agent_0",
"mode": "agent.plan",
"trusted_dirs": ["<path-to-GOD>/agentsociety"],
"enable_memory": true,
"enable_skill_runtime": true,
"common_skill_ids": ["routine.daily", "social.reply", "memory.record", "map.navigate", "safety.respond"],
"skill_ids": ["community.coordinate", "tools.repair"]
}
```
"""
@staticmethod
def _normalize_skill_ids(values: list[str] | None) -> list[str]:
result: list[str] = []
seen: set[str] = set()
for value in values or []:
skill_id = str(value or "").strip()
if skill_id and skill_id not in seen:
seen.add(skill_id)
result.append(skill_id)
return result
def _mounted_skill_ids(self) -> list[str]:
mounted = self._normalize_skill_ids(self._common_skill_ids + self._skill_ids)
return mounted or list(DEFAULT_COMMON_SKILLS)
async def init(self, env: Any) -> None:
await super().init(env)
if getattr(env, "run_dir", None) is None:
self._agent_work_dir = None
return
self._skill_registry.scan_custom(_workspace_root_from_file())
self._agent_work_dir = self._skill_runtime.ensure_agent_work_dir(env)
self._skill_runtime.ensure_standard_workspace_dirs()
self._write_json(
"agent_config.json",
{
"agent_type": self.__class__.__name__,
"id": self.id,
"name": self.name,
"profile": _json_safe(self.get_profile()),
"jiuwenclaw_ws_url": self._jiuwenclaw_ws_url,
"session_id": self._session_id,
"mode": self._mode,
"trusted_dirs": list(self._trusted_dirs),
"request_timeout": self._request_timeout,
"enable_memory": self._enable_memory,
"enable_skill_runtime": self._enable_skill_runtime,
"common_skill_ids": list(self._common_skill_ids),
"skill_ids": list(self._skill_ids),
"mounted_skill_ids": self._mounted_skill_ids(),
"experiment_context": _json_safe(self._experiment_context),
},
)
def _experiment_context_text(self) -> str:
if not self._experiment_context:
return ""
return (
"\n实验上下文:\n"
f"{json.dumps(_json_safe(self._experiment_context), ensure_ascii=False)}\n"
f"{CHINESE_OUTPUT_POLICY}\n"
)
async def ask(self, message: str, readonly: bool = True) -> str:
prompt = self._build_ask_prompt(message=message, readonly=readonly)
answer = await self._send_jiuwenclaw_request(prompt)
self._last_response = answer
self._recent_live_questions.append(
{
"question": message,
"answer": answer,
"readonly": str(readonly),
}
)
self._recent_live_questions = self._recent_live_questions[-10:]
return answer
async def answer_external_question(
self,
prompt: str,
*,
t: datetime,
response_type: str = "text",
choices: list[str] | None = None,
) -> str:
"""Answer live targeted Ask through the JiuwenClaw agent session."""
requirement = self._external_question_output_requirement(
response_type,
choices,
)
message = (
"这是给 AgentSociety 模拟智能体的外部提问。\n"
f"当前模拟时间:{t.isoformat()}\n"
f"输出要求:{requirement}\n"
f"{CHINESE_OUTPUT_POLICY}\n"
"请以该智能体第一人称回答。以当前模拟状态、近期实时提问、角色档案和九问会话上下文为准。"
"只读提问不得改变 AgentSociety 环境。\n\n"
f"问题:\n{prompt}"
)
return await self.ask(message, readonly=True)
async def step(self, tick: int, t: datetime) -> str:
observation = await self._observe_environment()
pending_interventions = list(self._pending_interventions)
self._pending_interventions = []
broadcast_result = await self._broadcast_urgent_interventions(
pending_interventions
)
result = await self._run_skill_runtime(
tick=tick,
t=t,
observation=observation,
pending_interventions=pending_interventions,
broadcast_result=broadcast_result,
)
status = "completed" if result.get("ok", True) else "error"
self._last_environment_result = json.dumps(
result.get("environment_effects") or [],
ensure_ascii=False,
)
self._persist_runtime_state(tick=tick, t=t, status=status)
public_summary = str(result.get("public_summary") or "技能步骤已完成。")
environment_summary = result.get("environment_effects") or []
if environment_summary:
return (
f"{public_summary}\n\n"
f"环境结果:{json.dumps(environment_summary, ensure_ascii=False)}"
)
return public_summary
def queue_intervention(self, instruction: str) -> str:
self._pending_interventions.append(str(instruction))
return (
"已实时投递到该 agent 的 pending interventions"
"下一次 step prompt 会包含这条干预并要求优先执行。"
"若识别为紧急公共事件,会先自动广播到小镇群组。"
)
async def _observe_environment(self) -> Any:
social_env = self._find_social_environment()
if social_env is not None and callable(getattr(social_env, "observe_agent", None)):
try:
return await social_env.observe_agent(self.id)
except Exception:
pass
try:
_, observation = await self.ask_env(
{"variables": {}},
"请观察该智能体当前的环境状态。",
readonly=True,
)
return observation
except Exception as exc:
return f"无法观察环境:{exc}"
async def _run_skill_runtime(
self,
*,
tick: int,
t: datetime,
observation: Any,
pending_interventions: list[str] | None = None,
broadcast_result: str = "",
) -> dict[str, Any]:
if self._agent_work_dir is None:
self._last_selected_skills = set()
self._last_activated_skills = set()
self._last_skill_results = []
self._last_action_proposal = {}
self._last_social_action_proposal = {}
self._last_skill_decision = {}
self._last_skill_result = {}
self._last_environment_effects = []
return {}
pending_interventions = pending_interventions or []
runtime_args = {
"agent_id": self.id,
"agent_name": self.name,
"profile": _json_safe(self.get_profile()),
"tick": tick,
"time": t.isoformat(),
"observation": _json_safe(observation),
"agent_work_dir": str(self._agent_work_dir),
"pending_interventions": list(pending_interventions),
"broadcast_result": broadcast_result,
}
self._skill_runtime.workspace_write(
"state/observation.json",
json.dumps(_json_safe(observation), ensure_ascii=False, indent=2),
)
self._skill_runtime.workspace_write(
"state/profile.json",
json.dumps(_json_safe(self.get_profile()), ensure_ascii=False, indent=2),
)
self._skill_runtime.workspace_write(
"state/current_time.json",
json.dumps(
{"tick": tick, "time": t.isoformat()},
ensure_ascii=False,
indent=2,
),
)
self._skill_runtime.workspace_write(
"state/mounted_skills.json",
json.dumps(self._mounted_skill_ids(), ensure_ascii=False, indent=2),
)
requested_skill_ids = self._mounted_skill_ids()
metadata = self._skill_runtime.skill_list(requested_skill_ids)
discovered = {str(item["name"]) for item in metadata if item.get("name")}
mounted = [skill_id for skill_id in requested_skill_ids if skill_id in discovered]
self._last_selected_skills = set(mounted)
self._last_activated_skills = set()
self._last_skill_results = []
self._last_action_proposal = {}
self._last_social_action_proposal = {}
self._last_skill_decision = {}
self._last_skill_result = {}
self._last_environment_effects = []
decision = await self._select_next_skill(
tick=tick,
t=t,
observation=observation,
catalog=[item for item in metadata if str(item.get("name") or "") in mounted],
mounted_skill_ids=mounted,
pending_interventions=pending_interventions,
broadcast_result=broadcast_result,
)
selected_skill_id = str(decision.get("selected_skill_id") or "").strip()
if selected_skill_id not in mounted:
decision = self._fallback_skill_decision(
mounted_skill_ids=mounted,
observation=observation,
pending_interventions=pending_interventions,
reason=f"选择的技能无效或不可用:{selected_skill_id}",
)
selected_skill_id = str(decision.get("selected_skill_id") or "").strip()
self._last_skill_decision = dict(decision)
if not selected_skill_id:
return {
"ok": False,
"public_summary": "该智能体没有挂载可执行技能。",
"mounted_skill_ids": mounted,
"last_skill_decision": dict(decision),
}
self._last_activated_skills = {selected_skill_id}
self._skill_runtime.skill_activate(selected_skill_id)
runtime_args["selected_skill_id"] = selected_skill_id
runtime_args["skill_args"] = (
decision.get("args") if isinstance(decision.get("args"), dict) else {}
)
runtime_args["skill_decision"] = dict(decision)
try:
raw_result = await self._skill_runtime.execute(
selected_skill_id,
runtime_args,
)
except Exception as exc:
raw_result = {
"ok": False,
"exit_code": -1,
"stdout": "",
"stderr": str(exc),
"error_type": type(exc).__name__,
"artifacts": [],
}
skill_result = self._parse_skill_result(raw_result, selected_skill_id)
skill_result = self._localize_skill_result(skill_result, selected_skill_id)
validation = self._validate_skill_result(
selected_skill_id=selected_skill_id,
skill_result=skill_result,
observation=observation,
)
skill_result["validation"] = validation
environment_effects = await self._apply_skill_result(skill_result, validation)
self._last_skill_result = dict(skill_result)
self._last_environment_effects = list(environment_effects)
self._last_action_proposal = self._legacy_action_from_skill_result(skill_result)
entry = {
"tick": tick,
"time": t.isoformat(),
"tool": "execute_skill",
"skill_name": selected_skill_id,
"decision": dict(decision),
"result": raw_result,
"skill_result": skill_result,
"environment_effects": environment_effects,
}
self._last_skill_results = [entry]
self._skill_runtime.append_tool_log(entry)
public_summary = str(
decision.get("public_summary")
or skill_result.get("summary")
or f"{self.name} 执行了{self._skill_label(selected_skill_id)}。"
)
return {
"ok": bool(raw_result.get("ok")) and not validation.get("errors"),
"public_summary": public_summary,
"mounted_skill_ids": mounted,
"selected_skills": mounted,
"activated_skills": sorted(self._last_activated_skills),
"last_skill_decision": dict(decision),
"last_skill_result": skill_result,
"environment_effects": environment_effects,
"skill_results": [entry],
}
async def _select_next_skill(
self,
*,
tick: int,
t: datetime,
observation: Any,
catalog: list[dict[str, Any]],
mounted_skill_ids: list[str],
pending_interventions: list[str],
broadcast_result: str,
) -> dict[str, Any]:
if not mounted_skill_ids:
return self._fallback_skill_decision(
mounted_skill_ids=[],
observation=observation,
pending_interventions=pending_interventions,
reason="没有可用的已挂载技能。",
)
prompt = self._build_skill_selection_prompt(
tick=tick,
t=t,
observation=observation,
catalog=catalog,
pending_interventions=pending_interventions,
broadcast_result=broadcast_result,
)
try:
raw = await self._send_jiuwenclaw_request(prompt)
self._last_response = raw
self._append_thread_message("user", prompt, tick=tick, t=t)
self._append_thread_message("assistant", raw, tick=tick, t=t)
decision = self._parse_step_decision(raw)
if isinstance(decision, dict) and decision.get("_parsed"):
decision.pop("_parsed", None)
return decision
return self._fallback_skill_decision(
mounted_skill_ids=mounted_skill_ids,
observation=observation,
pending_interventions=pending_interventions,
reason="九问返回的技能决策不是 JSON。",
)
except Exception as exc:
self._last_response = f"九问技能选择失败:{exc}"
return self._fallback_skill_decision(
mounted_skill_ids=mounted_skill_ids,
observation=observation,
pending_interventions=pending_interventions,
reason=str(exc),
)
def _build_skill_selection_prompt(
self,
*,
tick: int,
t: datetime,
observation: Any,
catalog: list[dict[str, Any]],
pending_interventions: list[str],
broadcast_result: str,
) -> str:
compact_catalog = [
{
"name": item.get("name"),
"description": item.get("description"),
"effects": item.get("effects", []),
"args_schema": item.get("args_schema", {}),
"trigger_examples": item.get("trigger_examples", []),
"shared": item.get("shared", False),
}
for item in catalog
]
return (
"你正在为一个 AgentSociety 模拟智能体选择本步骤唯一要执行的技能。\n"
f"智能体编号:{self.id}\n"
f"智能体姓名:{self.name}\n"
f"角色档案:{json.dumps(_json_safe(self.get_profile()), ensure_ascii=False)}\n"
f"{self._experiment_context_text()}"
f"模拟时间:{t.isoformat()}\n"
f"单步秒数:{tick}\n"
f"环境观察:\n{json.dumps(_json_safe(observation), ensure_ascii=False, indent=2)}\n"
f"待处理实时干预:{json.dumps(pending_interventions, ensure_ascii=False)}\n"
f"自动紧急处理结果:{broadcast_result}\n"
f"已挂载可执行技能目录:\n{json.dumps(compact_catalog, ensure_ascii=False, indent=2)}\n\n"
f"{CHINESE_OUTPUT_POLICY}\n"
"请从已挂载目录中选择一个技能。只返回一个 JSON 对象:\n"
"{\n"
' "selected_skill_id": "一个已挂载技能编号",\n'
' "args": {"optional": "符合 args_schema 的技能参数"},\n'
' "reason": "为什么这个技能适合当前状态",\n'
' "public_summary": "用中文简短描述智能体想做什么"\n'
"}\n"
"不要返回 environment_instruction 或 action_proposal,选中的技能脚本会产生效果。"
)
def _fallback_skill_decision(
self,
*,
mounted_skill_ids: list[str],
observation: Any,
pending_interventions: list[str],
reason: str,
) -> dict[str, Any]:
observation_dict = observation if isinstance(observation, dict) else {}
text = " ".join(
[
str(observation_dict.get("latest_event") or ""),
str(observation_dict.get("last_message") or ""),
" ".join(pending_interventions),
json.dumps(observation_dict.get("recent_messages") or [], ensure_ascii=False),
]
).lower()
def available(skill_id: str) -> bool:
return skill_id in mounted_skill_ids
if any(keyword.lower() in text for keyword in URGENT_INTERVENTION_KEYWORDS) and available("safety.respond"):
selected = "safety.respond"
elif observation_dict.get("recent_messages") and available("social.reply"):
selected = "social.reply"
elif available("routine.daily"):
selected = "routine.daily"
elif mounted_skill_ids:
selected = mounted_skill_ids[0]
else:
selected = ""
return {
"selected_skill_id": selected,
"args": {},
"reason": f"后备技能选择:{reason}",
"public_summary": f"{self.name} 本步执行{selected or '空技能'}。",
"fallback": True,
}
def _parse_skill_result(
self,
raw_result: dict[str, Any],
selected_skill_id: str,
) -> dict[str, Any]:
stdout = str(raw_result.get("stdout") or "").strip()
parsed = self._extract_json_object(stdout) if stdout else None
if isinstance(parsed, dict):
result = parsed
else:
result = {
"schema_version": SKILL_RESULT_SCHEMA_VERSION,
"skill_id": selected_skill_id,
"summary": f"{selected_skill_id} 没有返回有效的 JSON。",
"reason": str(raw_result.get("stderr") or raw_result.get("error_type") or "技能输出无效"),
"confidence": 0.0,
"world_effect": None,
"speech_effect": None,
"memory_effects": [],
}
result.setdefault("schema_version", SKILL_RESULT_SCHEMA_VERSION)
result.setdefault("skill_id", selected_skill_id)
result.setdefault("memory_effects", [])
return result
@staticmethod
def _skill_label(skill_id: str) -> str:
return SKILL_CHINESE_LABELS.get(str(skill_id), "技能")
def _allowed_visible_latin_terms(self) -> list[str]:
terms = [self.name]
profile = _json_safe(self.get_profile())
if isinstance(profile, dict):
if profile.get("name"):
terms.append(str(profile["name"]))
social_network = profile.get("social_network")
if isinstance(social_network, dict):
terms.extend(str(name) for name in social_network.keys())
return terms
def _contains_visible_english(self, value: Any) -> bool:
return _contains_latin_text_outside_terms(
value,
self._allowed_visible_latin_terms(),
)
def _localize_skill_result(
self,
skill_result: dict[str, Any],
selected_skill_id: str,
) -> dict[str, Any]:
"""Keep executable skill outputs from leaking English into visible replay text."""
label = self._skill_label(selected_skill_id)
result = dict(skill_result)
if self._contains_visible_english(result.get("summary")):
result["summary"] = f"执行{label}。"
if self._contains_visible_english(result.get("reason")):
result["reason"] = f"根据当前观察选择{label}。"
world = result.get("world_effect")
if isinstance(world, dict):
world = dict(world)
if self._contains_visible_english(world.get("action")):
world["action"] = f"执行{label}"
if self._contains_visible_english(world.get("status")):
world["status"] = STATUS_LABELS.get(str(world.get("status")), "活跃")
if self._contains_visible_english(world.get("emotion")):
world["emotion"] = STATUS_LABELS.get(str(world.get("emotion")), "平静")
if self._contains_visible_english(world.get("reason")):
world["reason"] = f"执行{label}"
params = world.get("params")
if isinstance(params, dict) and self._contains_visible_english(params.get("message")):
params = dict(params)
params["message"] = f"执行{label}"
world["params"] = params
result["world_effect"] = world
speech = result.get("speech_effect")
if isinstance(speech, dict):
speech = dict(speech)
if self._contains_visible_english(speech.get("content")):
speech["content"] = "我会用中文同步当前处理。"
result["speech_effect"] = speech
memories = result.get("memory_effects")
if isinstance(memories, list):
localized_memories = []
for memory in memories:
if not isinstance(memory, dict):
localized_memories.append(memory)
continue
localized = dict(memory)
if self._contains_visible_english(localized.get("content")):
localized["content"] = f"记录了技能事件:{label}。"
localized_memories.append(localized)
result["memory_effects"] = localized_memories
return result
def _validate_skill_result(
self,
*,
selected_skill_id: str,
skill_result: dict[str, Any],
observation: Any,
) -> dict[str, Any]:
errors: list[str] = []
valid_effects = {"world_effect": False, "speech_effect": False, "memory_effects": True}
info = self._skill_registry.get_skill_info(selected_skill_id, load_content=False)
allowed = set(info.effects if info is not None else [])
if skill_result.get("schema_version") != SKILL_RESULT_SCHEMA_VERSION:
errors.append("invalid schema_version")
if str(skill_result.get("skill_id") or "") != selected_skill_id:
errors.append("skill_id does not match selected_skill_id")
observation_dict = observation if isinstance(observation, dict) else {}
known_locations = {
str(item.get("id") or "")
for item in observation_dict.get("known_locations", []) or []
if isinstance(item, dict)
}
known_interactions = {
str(item.get("id") or ""): item
for item in observation_dict.get("known_interactions", []) or []
if isinstance(item, dict)
}
current_location = str(observation_dict.get("location_id") or "")
world = skill_result.get("world_effect")
if isinstance(world, dict):
effect_type = str(world.get("type") or "")
if effect_type not in allowed:
errors.append(f"world_effect type '{effect_type}' is not allowed for {selected_skill_id}")
elif effect_type == "move":
location_id = str(world.get("location_id") or world.get("location") or "")
if not location_id or (known_locations and location_id not in known_locations):
errors.append(f"unknown move location_id: {location_id}")
else:
valid_effects["world_effect"] = True
elif effect_type == "interact":
interaction_id = str(world.get("interaction_id") or "")
interaction = known_interactions.get(interaction_id)
allowed_locations = (
interaction.get("allowed_location_ids")
if isinstance(interaction, dict)
else None
)
if not interaction:
errors.append(f"unknown interaction_id: {interaction_id}")
elif allowed_locations and current_location not in allowed_locations:
errors.append(f"interaction '{interaction_id}' is not available at {current_location}")
else:
valid_effects["world_effect"] = True
elif effect_type == "set_state":
valid_effects["world_effect"] = True
else:
errors.append(f"unsupported world_effect type: {effect_type}")
speech = skill_result.get("speech_effect")
if isinstance(speech, dict):
effect_type = str(speech.get("type") or "")
if effect_type not in allowed:
errors.append(f"speech_effect type '{effect_type}' is not allowed for {selected_skill_id}")
elif effect_type == "direct_message" and self._safe_int(speech.get("receiver_id")) <= 0:
errors.append("direct_message requires receiver_id")
elif effect_type == "group_message" and self._safe_int(speech.get("group_id")) <= 0:
errors.append("group_message requires group_id")
elif effect_type in {"direct_message", "group_message"}:
valid_effects["speech_effect"] = True
else:
errors.append(f"unsupported speech_effect type: {effect_type}")
memories = skill_result.get("memory_effects")
if memories is not None and not isinstance(memories, list):
errors.append("memory_effects must be a list")
valid_effects["memory_effects"] = False
if memories and "remember" not in allowed:
errors.append(f"memory_effects are not allowed for {selected_skill_id}")
valid_effects["memory_effects"] = False
return {"errors": errors, "valid_effects": valid_effects, "allowed_effects": sorted(allowed)}
@staticmethod
def _safe_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
@staticmethod
def _truthy_public_value(value: Any) -> bool:
if isinstance(value, bool):
return value
return str(value or "").strip().casefold() in {
"1",
"true",
"yes",
"public",
"broadcast",
"announcement",
"all",
}
def _proposal_allows_public_group(self, proposal: dict[str, Any]) -> bool:
for key in ("broadcast", "public", "is_public"):
if self._truthy_public_value(proposal.get(key)):
return True
for key in ("scope", "message_scope"):
if self._truthy_public_value(proposal.get(key)):
return True
return False
def _is_public_speech_effect(
self,
effect: dict[str, Any],
skill_result: dict[str, Any],
) -> bool:
if str(skill_result.get("skill_id") or "") in PUBLIC_GROUP_SKILL_IDS:
return True
for key in ("broadcast", "public", "is_public"):
if self._truthy_public_value(effect.get(key)):
return True
for key in ("scope", "message_scope"):
if self._truthy_public_value(effect.get(key)):
return True
return False
async def _nearby_receiver_id(
self,
agent_id: int,
social_env: Any | None = None,
) -> int | None:
social_env = social_env or self._find_social_environment()
observe_agent = getattr(social_env, "observe_agent", None)
if not callable(observe_agent):
return None
observed = observe_agent(agent_id)
if asyncio.iscoroutine(observed):
observed = await observed
if not isinstance(observed, dict):
return None
for item in observed.get("nearby_agents", []) or []:
if isinstance(item, dict):
receiver_id = self._safe_int(item.get("agent_id"))
else:
receiver_id = self._safe_int(item)
if receiver_id > 0 and receiver_id != int(agent_id):
return receiver_id
return None
async def _apply_skill_result(
self,
skill_result: dict[str, Any],
validation: dict[str, Any],
) -> list[dict[str, Any]]:
applied: list[dict[str, Any]] = []
valid_effects = validation.get("valid_effects") if isinstance(validation, dict) else {}
world = skill_result.get("world_effect")
if isinstance(world, dict) and valid_effects.get("world_effect"):
result = await self._apply_world_effect(world)
applied.append({"effect": "world_effect", "request": world, "result": result})
speech = skill_result.get("speech_effect")
if isinstance(speech, dict) and valid_effects.get("speech_effect"):
result = await self._apply_speech_effect(speech, skill_result)
applied.append({"effect": "speech_effect", "request": speech, "result": result})
memories = skill_result.get("memory_effects")
if isinstance(memories, list) and valid_effects.get("memory_effects"):
result = self._append_memory_effects(memories, skill_result)
applied.append({"effect": "memory_effects", "request": memories, "result": result})
if validation.get("errors"):
applied.append({"effect": "validation", "errors": validation.get("errors")})
return applied
async def _apply_world_effect(self, effect: dict[str, Any]) -> Any:
effect_type = str(effect.get("type") or "")
proposal: dict[str, Any]
if effect_type == "move":
proposal = {
"action_type": "move",
"agent_id": self.id,
"location_id": effect.get("location_id") or effect.get("location"),
"reason": effect.get("reason"),
}
elif effect_type == "interact":
proposal = {
"action_type": "interact",
"agent_id": self.id,
"interaction_id": effect.get("interaction_id"),
"params": effect.get("params") if isinstance(effect.get("params"), dict) else {},
"reason": effect.get("reason"),
}
elif effect_type == "set_state":
proposal = {
"action_type": "set_action",
"agent_id": self.id,
"action": effect.get("action") or effect.get("reason") or "继续日常安排",
"status": effect.get("status") or "活跃",
"emotion": effect.get("emotion") or "平静",
"reason": effect.get("reason"),
}
else:
return {"ok": False, "error": f"不支持的世界效果:{effect_type}"}
raw = await self._apply_action_proposal(proposal)
try:
return json.loads(raw)
except Exception:
return raw
async def _apply_speech_effect(
self,
effect: dict[str, Any],
skill_result: dict[str, Any] | None = None,
) -> Any:
effect_type = str(effect.get("type") or "")
if effect_type == "direct_message":
proposal = {
"action_type": "direct_message",
"agent_id": self.id,
"receiver_id": effect.get("receiver_id"),
"content": effect.get("content"),
}
elif effect_type == "group_message":
if self._is_public_speech_effect(effect, skill_result or {}):
proposal = {
"action_type": "group_message",
"agent_id": self.id,
"group_id": effect.get("group_id") or 1,
"content": effect.get("content"),
"public": True,
}
else:
receiver_id = await self._nearby_receiver_id(self.id)
if receiver_id is None:
return {
"ok": False,
"error": "no_nearby_agent",
"skipped_group_broadcast": True,
}
proposal = {
"action_type": "direct_message",
"agent_id": self.id,
"receiver_id": receiver_id,
"content": effect.get("content"),
"converted_from_group_message": True,
}
else:
return {"ok": False, "error": f"不支持的发言效果:{effect_type}"}
raw = await self._apply_action_proposal(proposal)
try:
return json.loads(raw)
except Exception:
return raw
def _append_memory_effects(
self,
memories: list[dict[str, Any]],
skill_result: dict[str, Any],
) -> dict[str, Any]:
target = self._runtime_path("memory/skill_memory.jsonl")
if target is None:
return {"ok": False, "error": "agent workspace is not initialized"}
written = 0
with target.open("a", encoding="utf-8") as f:
for memory in memories:
if not isinstance(memory, dict):
continue
entry = {
"time": datetime.now(timezone.utc).isoformat(),
"agent_id": self.id,
"skill_id": skill_result.get("skill_id"),
**memory,
}
f.write(json.dumps(_json_safe(entry), ensure_ascii=False) + "\n")
written += 1
return {"ok": True, "written": written, "path": str(target)}
def _legacy_action_from_skill_result(self, skill_result: dict[str, Any]) -> dict[str, Any]:
world = skill_result.get("world_effect")
if not isinstance(world, dict):
return {}
effect_type = str(world.get("type") or "")
if effect_type == "move":
return {
"source": skill_result.get("skill_id"),
"action_type": "move",
"agent_id": self.id,
"location_id": world.get("location_id") or world.get("location"),
"reason": world.get("reason"),
}
if effect_type == "interact":
return {
"source": skill_result.get("skill_id"),
"action_type": "interact",
"agent_id": self.id,
"interaction_id": world.get("interaction_id"),
"params": world.get("params") if isinstance(world.get("params"), dict) else {},
"reason": world.get("reason"),
}
if effect_type == "set_state":
return {
"source": skill_result.get("skill_id"),
"action_type": "set_action",
"agent_id": self.id,
"action": world.get("action"),
"status": world.get("status"),
"emotion": world.get("emotion"),
"reason": world.get("reason"),
}
return {}
def _action_proposal_from_decision(self, decision: dict[str, Any]) -> dict[str, Any]:
raw = decision.get("action_proposal")
if isinstance(raw, dict):
return raw
action_type = str(decision.get("action_type") or "").strip()
if not action_type:
return {}
proposal = {
"source": "jiuwen_decision",
"action_type": action_type,
"agent_id": self.id,
}
for key in (
"location_id",
"location",
"interaction_id",
"receiver_id",
"group_id",
"content",
"params",
"action",
"status",
"emotion",
"reason",
"broadcast",
"public",
"is_public",
"scope",
"message_scope",
):
if key in decision:
proposal[key] = decision[key]
return proposal
async def _apply_action_proposal(self, proposal: dict[str, Any]) -> str:
if not proposal:
return ""
action_type = str(proposal.get("action_type") or "").strip()
if not action_type or action_type == "none":
return ""
social_env = self._find_social_environment()
agent_id = int(proposal.get("agent_id") or self.id)
try:
if social_env is not None:
if action_type == "move" and callable(getattr(social_env, "move_agent", None)):
result = await social_env.move_agent(
agent_id=agent_id,
location=str(proposal.get("location_id") or proposal.get("location") or ""),
)
return json.dumps(result, ensure_ascii=False)
if action_type == "interact" and callable(getattr(social_env, "interact", None)):
result = await social_env.interact(
agent_id=agent_id,
interaction_id=str(proposal.get("interaction_id") or ""),
params=proposal.get("params") if isinstance(proposal.get("params"), dict) else {},
)
return json.dumps(result, ensure_ascii=False)
if action_type == "direct_message" and callable(getattr(social_env, "send_message", None)):
result = await social_env.send_message(
sender_id=agent_id,
receiver_id=int(proposal.get("receiver_id") or 0),
content=str(proposal.get("content") or ""),
)
return json.dumps(result, ensure_ascii=False)
if action_type == "group_message" and callable(getattr(social_env, "send_group_message", None)):
if not self._proposal_allows_public_group(proposal):
receiver_id = await self._nearby_receiver_id(agent_id, social_env)
if receiver_id is None:
return json.dumps(
{
"ok": False,
"error": "no_nearby_agent",
"skipped_group_broadcast": True,
},
ensure_ascii=False,
)
if callable(getattr(social_env, "send_message", None)):
result = await social_env.send_message(
sender_id=agent_id,
receiver_id=receiver_id,
content=str(proposal.get("content") or ""),
)
return json.dumps(result, ensure_ascii=False)
return json.dumps(
{
"ok": False,
"error": "direct_message_unavailable",
"skipped_group_broadcast": True,
},
ensure_ascii=False,
)
result = await social_env.send_group_message(
sender_id=agent_id,
group_id=int(proposal.get("group_id") or 1),
content=str(proposal.get("content") or ""),
)
return json.dumps(result, ensure_ascii=False)
if action_type == "set_action" and callable(getattr(social_env, "set_agent_action", None)):
result = await social_env.set_agent_action(
agent_id=agent_id,
action=str(proposal.get("action") or proposal.get("reason") or "继续日常安排"),
status=str(proposal.get("status") or "活跃"),
emotion=str(proposal.get("emotion") or "平静"),
)
return json.dumps(result, ensure_ascii=False)
except Exception as exc:
return f"动作执行失败:{exc}"
instruction = str(proposal.get("environment_instruction") or "").strip()
if not instruction:
instruction = f"请执行这个 AgentSociety 动作提案:{json.dumps(proposal, ensure_ascii=False)}"
try:
_, env_result = await self.ask_env(
{"variables": {}},
instruction,
readonly=False,
)
return str(env_result)
except Exception as exc:
return f"动作执行失败:{exc}"
async def _broadcast_urgent_interventions(self, instructions: list[str]) -> str:
urgent_instructions = [
instruction.strip()
for instruction in instructions
if instruction.strip() and self._is_urgent_intervention(instruction)
]
if not urgent_instructions:
return ""
content = (
"紧急通知:"
+ "".join(urgent_instructions)
+ "。请所有人立即暂停当前安排,确认自身安全,并同步撤离、联络和物资需求。"
)
env = self._find_social_environment()
if env is not None and callable(getattr(env, "send_group_message", None)):
try:
result = await env.send_group_message(
sender_id=self.id,
group_id=1,
content=content,
)
return f"已向 1 号群发送紧急干预通知:{result}"
except Exception as exc:
return f"步骤提示前发送紧急通知失败:{exc}"
try:
_, env_result = await self.ask_env(
{"variables": {}},
(
f"请让 {self.id} 号智能体向 1 号群发送这条紧急内容:{content}"
),
readonly=False,
)
return f"已通过环境路由发送紧急干预通知:{env_result}"
except Exception as exc:
return f"步骤提示前发送紧急通知失败:{exc}"
def _is_urgent_intervention(self, instruction: str) -> bool:
lowered = instruction.lower()
return any(keyword in lowered for keyword in URGENT_INTERVENTION_KEYWORDS)
def _find_social_environment(self) -> Any | None:
env = getattr(self, "_env", None)
env_modules = getattr(env, "env_modules", None)
if isinstance(env_modules, list):
for module in env_modules:
if callable(getattr(module, "send_group_message", None)):
return module
if isinstance(env_modules, dict):
for module in env_modules.values():
if callable(getattr(module, "send_group_message", None)):
return module
if callable(getattr(env, "send_group_message", None)):
return env
return None
def _runtime_path(self, relative_path: str) -> Path | None:
if self._agent_work_dir is None:
return None
work_root = self._agent_work_dir.resolve()
target = (work_root / relative_path).resolve()
try:
target.relative_to(work_root)
except ValueError:
raise ValueError(f"Path escapes agent workspace: {relative_path}")
target.parent.mkdir(parents=True, exist_ok=True)
return target
def _write_json(self, relative_path: str, data: Any) -> None:
target = self._runtime_path(relative_path)
if target is None:
return
target.write_text(
json.dumps(_json_safe(data), ensure_ascii=False, indent=2),
encoding="utf-8",
)
def _append_thread_message(
self,
role: str,
content: str,
*,
tick: int,
t: datetime,
) -> None:
target = self._runtime_path(".runtime/logs/thread_messages.jsonl")
if target is None:
return
entry = {
"tick": tick,
"time": t.isoformat(),
"role": role,
"content": content,
}
with target.open("a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
def _workspace_files(self) -> list[str]:
if self._agent_work_dir is None:
return []
return sorted(
str(path.relative_to(self._agent_work_dir))
for path in self._agent_work_dir.rglob("*")
if path.is_file()
)
def _persist_runtime_state(self, *, tick: int, t: datetime, status: str) -> None:
state = {
"agent_id": self.id,
"tick": tick,
"time": t.isoformat(),
"status": status,
"token_usage": {},
"selected_skills": sorted(self._last_selected_skills),
"activated_skills": sorted(self._last_activated_skills),
"mounted_skill_ids": self._mounted_skill_ids(),
"last_skill_decision": dict(self._last_skill_decision),
}
snapshot = {
**state,
"id": self.id,
"name": self.name,
"profile": _json_safe(self.get_profile()),
"agent_type": self.__class__.__name__,
"jiuwenclaw_ws_url": self._jiuwenclaw_ws_url,
"session_id": self._session_id,
"mode": self._mode,
"last_response": self._last_response,
"last_environment_result": self._last_environment_result,
"pending_interventions": list(self._pending_interventions),
"recent_live_questions": list(self._recent_live_questions),
"last_skill_results": list(self._last_skill_results),
"last_action_proposal": dict(self._last_action_proposal),
"last_social_action_proposal": dict(self._last_social_action_proposal),
"mounted_skill_ids": self._mounted_skill_ids(),
"last_skill_decision": dict(self._last_skill_decision),
"last_skill_result": dict(self._last_skill_result),
"last_environment_effects": list(self._last_environment_effects),
"skill_states": {
"mounted_skill_ids": self._mounted_skill_ids(),
"last_skill_decision": dict(self._last_skill_decision),
"last_skill_result": dict(self._last_skill_result),
"last_environment_effects": list(self._last_environment_effects),
},
"workspace_files": self._workspace_files(),
}
if self._enable_skill_runtime and self._agent_work_dir is not None:
self._skill_runtime.persist_session_state(
tick=tick,
t=t,
selected_skills=self._last_selected_skills,
activated_skills=self._last_activated_skills,
token_usage={},
runtime_snapshot=snapshot,
)
self._skill_runtime.append_step_replay(
tick=tick,
t=t,
selected_skills=self._last_selected_skills,
tool_history=list(self._last_skill_results),
)
self._skill_runtime.refresh_workspace_documents()
return
self._write_json(".runtime/logs/session_state.json", state)
self._write_json(".runtime/logs/agent_state_snapshot.json", snapshot)
async def dump(self) -> dict:
return {
"id": self._id,
"profile": _json_safe(self.get_profile()),
"name": self._name,
"jiuwenclaw_ws_url": self._jiuwenclaw_ws_url,
"session_id": self._session_id,
"mode": self._mode,
"trusted_dirs": list(self._trusted_dirs),
"request_timeout": self._request_timeout,
"enable_memory": self._enable_memory,
"channel_id": self._channel_id,
"enable_skill_runtime": self._enable_skill_runtime,
"common_skill_ids": list(self._common_skill_ids),
"skill_ids": list(self._skill_ids),
"mounted_skill_ids": self._mounted_skill_ids(),
"experiment_context": _json_safe(self._experiment_context),
"last_response": self._last_response,
"last_environment_result": self._last_environment_result,
"pending_interventions": list(self._pending_interventions),
"recent_live_questions": list(self._recent_live_questions),
"last_action_proposal": dict(self._last_action_proposal),
"last_social_action_proposal": dict(self._last_social_action_proposal),
"last_skill_decision": dict(self._last_skill_decision),
"last_skill_result": dict(self._last_skill_result),
"last_environment_effects": list(self._last_environment_effects),
}
async def load(self, dump_data: dict) -> None:
self._id = int(dump_data.get("id", self._id))
if "profile" in dump_data:
self._profile = dump_data["profile"]
self._name = str(dump_data.get("name", self._name))
self._jiuwenclaw_ws_url = str(
dump_data.get("jiuwenclaw_ws_url", self._jiuwenclaw_ws_url)
)
self._session_id = str(dump_data.get("session_id", self._session_id))
self._mode = str(dump_data.get("mode", self._mode))
trusted_dirs = dump_data.get("trusted_dirs")
if isinstance(trusted_dirs, list):
self._trusted_dirs = [str(item) for item in trusted_dirs if str(item)]
self._request_timeout = float(
dump_data.get("request_timeout", self._request_timeout)
)
self._enable_memory = bool(dump_data.get("enable_memory", self._enable_memory))
self._channel_id = str(dump_data.get("channel_id", self._channel_id))
self._enable_skill_runtime = True
if "experiment_context" in dump_data:
self._experiment_context = dump_data["experiment_context"]
common_skill_ids = dump_data.get("common_skill_ids")
if isinstance(common_skill_ids, list):
self._common_skill_ids = self._normalize_skill_ids(common_skill_ids)
skill_ids = dump_data.get("skill_ids")
if isinstance(skill_ids, list):
self._skill_ids = self._normalize_skill_ids(skill_ids)
elif isinstance(dump_data.get("mounted_skill_ids"), list):
common_set = set(self._common_skill_ids)
self._skill_ids = [
item
for item in self._normalize_skill_ids(dump_data["mounted_skill_ids"])
if item not in common_set
]
self._last_response = str(dump_data.get("last_response", ""))
self._last_environment_result = str(
dump_data.get("last_environment_result", "")
)
if isinstance(dump_data.get("last_action_proposal"), dict):
self._last_action_proposal = dict(dump_data["last_action_proposal"])
if isinstance(dump_data.get("last_social_action_proposal"), dict):
self._last_social_action_proposal = dict(
dump_data["last_social_action_proposal"]
)
if isinstance(dump_data.get("last_skill_decision"), dict):
self._last_skill_decision = dict(dump_data["last_skill_decision"])
if isinstance(dump_data.get("last_skill_result"), dict):
self._last_skill_result = dict(dump_data["last_skill_result"])
if isinstance(dump_data.get("last_environment_effects"), list):
self._last_environment_effects = [
item for item in dump_data["last_environment_effects"] if isinstance(item, dict)
]
pending_interventions = dump_data.get("pending_interventions")
if isinstance(pending_interventions, list):
self._pending_interventions = [
str(item) for item in pending_interventions if str(item).strip()
]
recent_live_questions = dump_data.get("recent_live_questions")
if isinstance(recent_live_questions, list):
self._recent_live_questions = [
item for item in recent_live_questions if isinstance(item, dict)
][-10:]
async def close(self) -> None:
if self._ws is not None:
close = getattr(self._ws, "close", None)
if callable(close):
await close()
self._ws = None
def _build_ask_prompt(self, message: str, readonly: bool) -> str:
readonly_text = (
"这是只读提问,不要产生任何副作用。"
if readonly
else "你可以推理行动,但只有 AgentSociety 可以改变模拟环境。"
)
return (
"你正在扮演一个 AgentSociety 模拟智能体。\n"
f"智能体编号:{self.id}\n"
f"智能体姓名:{self.name}\n"
f"角色档案:{json.dumps(_json_safe(self.get_profile()), ensure_ascii=False)}\n"
f"{self._experiment_context_text()}"
f"{CHINESE_OUTPUT_POLICY}\n"
f"约束:{readonly_text}\n\n"
f"用户或环境问题:\n{message}"
)
def _build_step_prompt(
self,
tick: int,
t: datetime,
observation: str,
pending_interventions: list[str] | None = None,
broadcast_result: str = "",
skill_runtime_result: dict[str, Any] | None = None,
) -> str:
intervention_text = ""
if pending_interventions:
lines = "\n".join(
f"{index}. {instruction}"
for index, instruction in enumerate(pending_interventions, start=1)
)
intervention_text = (
"\n\n本步骤前收到的实时用户干预:\n"
f"{lines}\n"
"除非客观上无法执行,否则本步骤必须吸收这些干预。"
"如果干预要求移动、更新状态、发送消息或改变行为,请把具体效果写入 environment_instruction。"
"如果是公共安全紧急情况,优先处理撤离、协作和沟通。\n"
)
broadcast_text = (
f"\n\n自动紧急干预处理结果:\n{broadcast_result}\n"
if broadcast_result
else ""
)
skill_runtime_text = ""
if skill_runtime_result:
skill_runtime_text = (
"\n\nAgentSociety 可执行技能运行结果:\n"
f"{json.dumps(_json_safe(skill_runtime_result), ensure_ascii=False, indent=2)}\n"
"请把它视为已落地的可执行上下文。如果明显错误可以覆盖;如果认可,请保持 "
"environment_instruction 为空,AgentSociety 会直接执行提案。\n"
)
return (
"你正在控制一个 AgentSociety 模拟智能体,只执行一个步骤。\n"
f"智能体编号:{self.id}\n"
f"智能体姓名:{self.name}\n"
f"角色档案:{json.dumps(_json_safe(self.get_profile()), ensure_ascii=False)}\n"
f"{self._experiment_context_text()}"
f"{CHINESE_OUTPUT_POLICY}\n"
f"模拟时间:{t.isoformat()}\n"
f"单步秒数:{tick}\n"
f"环境观察:\n{observation}"
f"{intervention_text}\n\n"
f"{broadcast_text}"
f"{skill_runtime_text}"
"如果观察中包含关于紧急情况的 recent_messages 或 latest_event,请把它当作实时信息并立即调整行动。\n\n"
"只返回一个 JSON 对象,结构如下:\n"
"{\n"
' "public_summary": "用中文简短描述本步骤",\n'
' "environment_instruction": "给 AgentSociety 环境的中文自然语言动作,或空字符串",\n'
' "action_proposal": {"action_type": "move|interact|direct_message|group_message|set_action", "location_id": "optional", "interaction_id": "optional", "receiver_id": "direct_message 必填", "content": "必须是中文", "public": "只有公共公告/安全广播/系统通知才可为 true"}\n'
"}\n"
"如果要使用上面的可执行技能提案,可以省略 action_proposal。\n"
"普通对话必须使用 direct_message,并且只能发给环境观察中的 nearby_agents。"
"group_message 只用于明确的公共公告、安全广播或系统通知;使用时必须设置 public=true。\n"
"不要用 Markdown 包裹 JSON。"
)
async def _send_jiuwenclaw_request(self, prompt: str) -> str:
request_id = f"agentsociety_{self.id}_{uuid.uuid4().hex[:12]}"
payload = {
"request_id": request_id,
"channel_id": self._channel_id,
"session_id": self._session_id,
"req_method": "chat.send",
"params": {
"query": prompt,
"mode": self._mode,
"trusted_dirs": list(self._trusted_dirs),
},
"is_stream": True,
"timestamp": time.time(),
"metadata": {
"source": "agentsociety",
"agent_id": self.id,
"agent_name": self.name,
"enable_memory": self._enable_memory,
},
}
request_started_at = time.perf_counter()
queue_ms = 0.0
status = "error"
semaphore, concurrency_limit = self._request_semaphore_for_current_loop()
try:
async with semaphore:
queue_ms = (time.perf_counter() - request_started_at) * 1000
async with self._ws_lock:
await self._ensure_connected()
await self._ws.send(json.dumps(payload, ensure_ascii=False))
try:
response = await asyncio.wait_for(
self._receive_matching_response(request_id),
timeout=self._request_timeout,
)
except Exception:
await self._reset_websocket()
raise
content = self._extract_response_content(response)
status = "success"
return content
finally:
total_ms = (time.perf_counter() - request_started_at) * 1000
logger.info(
"[JiuwenClawAgent] request timing: agent_id=%s request_id=%s "
"status=%s queue_ms=%.1f total_ms=%.1f prompt_chars=%s "
"concurrency_limit=%s",
self.id,
request_id,
status,
queue_ms,
total_ms,
len(prompt),
concurrency_limit,
)
async def _ensure_connected(self) -> None:
if self._ws is not None:
return
self._ws = await self._open_websocket(self._jiuwenclaw_ws_url)
await self._consume_connection_ack_if_present()
async def _open_websocket(self, uri: str) -> Any:
origin = self._build_ws_origin(uri)
try:
from websockets.asyncio.client import connect
except Exception:
from websockets import connect
return await connect(uri, origin=origin)
def _build_ws_origin(self, uri: str) -> str | None:
"""Match JiuwenClaw AgentServer's localhost Origin check."""
try:
parsed = urlsplit(uri)
except ValueError:
return None
if not parsed.netloc:
return None
scheme = "https" if parsed.scheme == "wss" else "http"
return f"{scheme}://{parsed.netloc}"
async def _consume_connection_ack_if_present(self) -> None:
try:
raw = await asyncio.wait_for(self._ws.recv(), timeout=2.0)
except asyncio.TimeoutError:
return
data = self._decode_wire_message(raw)
if data.get("type") == "event" and data.get("event") == "connection.ack":
return
self._pending_response = data
async def _receive_matching_response(self, request_id: str) -> dict:
content_parts: list[str] = []
final_content: str = ""
pending = getattr(self, "_pending_response", None)
if isinstance(pending, dict):
self._pending_response = None
if str(pending.get("request_id") or "") == request_id:
piece = self._extract_stream_piece(pending)
if piece["final"]:
final_content = piece["text"]
elif piece["text"]:
content_parts.append(piece["text"])
if pending.get("is_final"):
return self._with_stream_content(
pending,
final_content or "".join(content_parts),
)
while True:
raw = await self._ws.recv()
data = self._decode_wire_message(raw)
if data.get("type") == "event":
continue
if str(data.get("request_id") or "") == request_id:
piece = self._extract_stream_piece(data)
if piece["final"]:
final_content = piece["text"]
elif piece["text"]:
content_parts.append(piece["text"])
if data.get("is_final"):
return self._with_stream_content(
data,
final_content or "".join(content_parts),
)
def _decode_wire_message(self, raw: str | bytes) -> dict:
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
data = json.loads(raw)
if not isinstance(data, dict):
raise ValueError("JiuwenClaw WebSocket response must be a JSON object")
return data
def _extract_response_content(self, response: dict) -> str:
if "ok" in response:
if not response.get("ok", True):
payload = response.get("payload") or {}
raise RuntimeError(str(payload.get("error") or payload))
payload = response.get("payload") or {}
return str(payload.get("content") or payload.get("result") or payload)
body = response.get("body") or {}
status = str(response.get("status") or "")
response_kind = str(response.get("response_kind") or "")
if status == "failed" or response_kind.endswith(".error"):
raise RuntimeError(str(body.get("message") or body.get("error") or body))
result = body.get("result")
if isinstance(result, dict):
if "content" in result:
content = result["content"]
if isinstance(content, dict):
if "output" in content:
return str(content["output"])
if "content" in content:
return str(content["content"])
return str(content)
return json.dumps(result, ensure_ascii=False)
if result is not None:
return str(result)
if "content" in body:
return str(body["content"])
return json.dumps(body, ensure_ascii=False)
async def _reset_websocket(self) -> None:
if self._ws is None:
return
close = getattr(self._ws, "close", None)
try:
if callable(close):
await close()
finally:
self._ws = None
self._pending_response = None
def _with_stream_content(self, response: dict, content: str) -> dict:
if not content:
return response
next_response = dict(response)
body = dict(next_response.get("body") or {})
body["result"] = {"content": content}
next_response["body"] = body
return next_response
def _extract_stream_piece(self, response: dict) -> dict[str, Any]:
body = response.get("body") or {}
delta = body.get("delta")
if isinstance(delta, dict):
event_type = str(delta.get("event_type") or body.get("event_type") or "")
content = delta.get("content")
if content is None:
content = delta.get("delta")
if event_type == "chat.final" and content is not None:
return {"text": str(content), "final": True}
if event_type in {"chat.delta", "text.delta"} and content is not None:
return {"text": str(content), "final": False}
if body.get("delta_kind") == "text" and isinstance(delta, str):
return {"text": delta, "final": False}
payload = response.get("payload")
if isinstance(payload, dict):
event_type = str(payload.get("event_type") or "")
content = payload.get("content")
if event_type == "chat.final" and content is not None:
return {"text": str(content), "final": True}
if event_type == "chat.delta" and content is not None:
return {"text": str(content), "final": False}
return {"text": "", "final": False}
def _parse_step_decision(self, text: str) -> dict[str, Any]:
data = self._extract_json_object(text)
if not isinstance(data, dict):
return {"_parsed": False, "public_summary": text}
data["_parsed"] = True
return data
def _extract_json_object(self, text: str) -> Any:
stripped = text.strip()
fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", stripped, re.DOTALL)
if fence:
stripped = fence.group(1).strip()
if stripped.startswith("{"):
try:
return json.loads(stripped)
except json.JSONDecodeError:
pass
start = stripped.find("{")
while start != -1:
try:
obj, _ = json.JSONDecoder().raw_decode(stripped[start:])
return obj
except json.JSONDecodeError:
start = stripped.find("{", start + 1)
return None