666ghj commited on
Commit
78db009
·
1 Parent(s): 5fabca4

Implement Interview feature for agent interactions in simulations

Browse files

- Added a new Interview module to facilitate interactions with agents post-simulation, allowing for single and batch interviews.
- Introduced IPC communication mechanism for command and response handling between the Flask backend and simulation scripts.
- Updated README.md to include detailed instructions on the new Interview functionality, including API endpoints and usage examples.
- Enhanced simulation scripts to support waiting for commands after completion, improving user control over the simulation environment.
- Implemented error handling and logging for interview processes, ensuring robust operation and traceability.

backend/README.md CHANGED
@@ -73,6 +73,11 @@
73
  启动模拟 → 运行OASIS脚本 → 实时监控 → 记录动作 → (可选)更新Zep图谱记忆 → 状态查询
74
  ```
75
 
 
 
 
 
 
76
  ---
77
 
78
  ## 技术栈
@@ -152,6 +157,7 @@ backend/
152
  │ ├── simulation_config_generator.py # 配置生成
153
  │ ├── simulation_manager.py # 模拟管理
154
  │ ├── simulation_runner.py # 模拟运行
 
155
  │ └── zep_graph_memory_updater.py # 图谱记忆动态更新
156
  └── utils/ # 工具类
157
  ├── __init__.py
@@ -211,12 +217,43 @@ backend/
211
  4. 解析动作日志(actions.jsonl)
212
  5. (可选)将Agent活动实时更新到Zep图谱
213
  6. 实时更新运行状态
214
- 7. 支持停止/暂停/恢复
 
215
 
216
  **核心服务**:
217
  - `SimulationRunner`: 模拟运行器
218
  - `ZepGraphMemoryUpdater`: 图谱记忆动态更新器
219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  ---
221
 
222
  ## API接口文档
@@ -630,6 +667,290 @@ backend/
630
 
631
  ---
632
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
633
  #### 6. 获取运行状态
634
 
635
  **接口**: `GET /api/simulation/{simulation_id}/run-status`
@@ -1395,6 +1716,92 @@ POST /api/simulation/start
1395
 
1396
  ---
1397
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1398
  ## 工具类
1399
 
1400
  ### 1. FileParser (文件解析器)
@@ -1661,7 +2068,35 @@ curl -X POST http://localhost:5001/api/simulation/start \
1661
  # Step 8: 实时查询运行状态
1662
  curl http://localhost:5001/api/simulation/{sim_xxx}/run-status
1663
 
1664
- # Step 9: 停止模拟
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1665
  curl -X POST http://localhost:5001/api/simulation/stop \
1666
  -H "Content-Type: application/json" \
1667
  -d '{
@@ -1830,6 +2265,31 @@ MIT License
1830
 
1831
  ---
1832
 
1833
- **最后更新**: 2025-12-05
1834
- **版本**: v1.1.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1835
 
 
73
  启动模拟 → 运行OASIS脚本 → 实时监控 → 记录动作 → (可选)更新Zep图谱记忆 → 状态查询
74
  ```
75
 
76
+ 4. **Interview采访流程**:
77
+ ```
78
+ 模拟完成 → 环境进入等待模式 → 发送Interview命令 → Agent回答 → 获取结果 → (可选)关闭环境
79
+ ```
80
+
81
  ---
82
 
83
  ## 技术栈
 
157
  │ ├── simulation_config_generator.py # 配置生成
158
  │ ├── simulation_manager.py # 模拟管理
159
  │ ├── simulation_runner.py # 模拟运行
160
+ │ ├── simulation_ipc.py # 模拟IPC通信(Interview功能)
161
  │ └── zep_graph_memory_updater.py # 图谱记忆动态更新
162
  └── utils/ # 工具类
163
  ├── __init__.py
 
217
  4. 解析动作日志(actions.jsonl)
218
  5. (可选)将Agent活动实时更新到Zep图谱
219
  6. 实时更新运行状态
220
+ 7. 模拟完成后进入等待命令模式
221
+ 8. 支持停止/暂停/恢复
222
 
223
  **核心服务**:
224
  - `SimulationRunner`: 模拟运行器
225
  - `ZepGraphMemoryUpdater`: 图谱记忆动态更新器
226
 
227
+ ### 4. Agent采访(Interview)模块
228
+
229
+ **功能**: 在模拟完成后对Agent进行采访
230
+
231
+ **特点**:
232
+ - **模拟状态持久化**: 模拟完成后环境不立即关闭,进入等待命令模式
233
+ - **IPC通信机制**: 通过文件系统在Flask后端和模拟脚本之间通信
234
+ - **单个采访**: 对指定Agent提问并获取回答
235
+ - **批量采访**: 同时对多个Agent提不同问题
236
+ - **全局采访**: 使用相同问题采访所有Agent
237
+ - **采访历史**: 从数据库读取所有Interview记录
238
+
239
+ **核心服务**:
240
+ - `SimulationIPCClient`: IPC客户端(Flask端使用)
241
+ - `SimulationIPCServer`: IPC服务器(模拟脚本端使用)
242
+
243
+ **工作原理**:
244
+ ```
245
+ Flask后端 模拟脚本
246
+ │ │
247
+ │ 写入命令文件 │
248
+ │ ─────────────────────────→│
249
+ │ │ 轮询命令目录
250
+ │ │ 执行Interview
251
+ │ │ 写入响应文件
252
+ │←───────────────────────── │
253
+ │ 读取响应文件 │
254
+ │ │
255
+ ```
256
+
257
  ---
258
 
259
  ## API接口文档
 
667
 
668
  ---
669
 
670
+ ### Interview 采访接口
671
+
672
+ > **注意**: 所有Interview接口的参数都通过请求体(JSON)传递,包括simulation_id。
673
+ >
674
+ > **双平台模式说明**: 当不指定`platform`参数时,双平台模拟会同时采访两个平台并返回整合结果。
675
+
676
+ #### 1. 采访单个Agent
677
+
678
+ **接口**: `POST /api/simulation/interview`
679
+
680
+ **请求参数**:
681
+ ```json
682
+ {
683
+ "simulation_id": "sim_xxxx",
684
+ "agent_id": 0,
685
+ "prompt": "你对这件事有什么看法?",
686
+ "platform": "reddit",
687
+ "timeout": 60
688
+ }
689
+ ```
690
+
691
+ | 参数 | 类型 | 必填 | 默认值 | 说明 |
692
+ |------|------|------|--------|------|
693
+ | simulation_id | String | 是 | - | 模拟ID |
694
+ | agent_id | Integer | 是 | - | Agent ID |
695
+ | prompt | String | 是 | - | 采访问题 |
696
+ | platform | String | 否 | null | 指定平台(twitter/reddit),不指定则双平台同时采访 |
697
+ | timeout | Integer | 否 | 60 | 超时时间(秒) |
698
+
699
+ **返回示例(指定单平台)**:
700
+ ```json
701
+ {
702
+ "success": true,
703
+ "data": {
704
+ "success": true,
705
+ "agent_id": 0,
706
+ "prompt": "你对这件事有什么看法?",
707
+ "result": {
708
+ "agent_id": 0,
709
+ "response": "我认为这件事反映了...",
710
+ "platform": "reddit",
711
+ "timestamp": "2025-12-08T10:00:00"
712
+ },
713
+ "timestamp": "2025-12-08T10:00:01"
714
+ }
715
+ }
716
+ ```
717
+
718
+ **返回示例(不指定platform,双平台模式)**:
719
+ ```json
720
+ {
721
+ "success": true,
722
+ "data": {
723
+ "success": true,
724
+ "agent_id": 0,
725
+ "prompt": "你对这件事有什么看法?",
726
+ "result": {
727
+ "agent_id": 0,
728
+ "prompt": "你对这件事有什么看法?",
729
+ "platforms": {
730
+ "twitter": {
731
+ "agent_id": 0,
732
+ "response": "从Twitter视角来看...",
733
+ "platform": "twitter",
734
+ "timestamp": "2025-12-08T10:00:00"
735
+ },
736
+ "reddit": {
737
+ "agent_id": 0,
738
+ "response": "作为Reddit用户,我认为...",
739
+ "platform": "reddit",
740
+ "timestamp": "2025-12-08T10:00:00"
741
+ }
742
+ }
743
+ },
744
+ "timestamp": "2025-12-08T10:00:01"
745
+ }
746
+ }
747
+ ```
748
+
749
+ **注意**: 此功能需要模拟环境处于运行状态(完成模拟循环后进入等待命令模式)
750
+
751
+ ---
752
+
753
+ #### 2. 批量采访多个Agent
754
+
755
+ **接口**: `POST /api/simulation/interview/batch`
756
+
757
+ **请求参数**:
758
+ ```json
759
+ {
760
+ "simulation_id": "sim_xxxx",
761
+ "interviews": [
762
+ {"agent_id": 0, "prompt": "你对A有什么看法?", "platform": "twitter"},
763
+ {"agent_id": 1, "prompt": "你对B有什么看法?", "platform": "reddit"},
764
+ {"agent_id": 2, "prompt": "你对C有什么看法?"}
765
+ ],
766
+ "platform": "reddit",
767
+ "timeout": 120
768
+ }
769
+ ```
770
+
771
+ | 参数 | 类型 | 必填 | 默认值 | 说明 |
772
+ |------|------|------|--------|------|
773
+ | simulation_id | String | 是 | - | 模拟ID |
774
+ | interviews | Array | 是 | - | 采访列表,每项包含agent_id、prompt和可选的platform |
775
+ | platform | String | 否 | null | 默认平台(被每项的platform覆盖),不指定则双平台同时采访 |
776
+ | timeout | Integer | 否 | 120 | 超时时间(秒) |
777
+
778
+ **返回示例**:
779
+ ```json
780
+ {
781
+ "success": true,
782
+ "data": {
783
+ "success": true,
784
+ "interviews_count": 3,
785
+ "result": {
786
+ "interviews_count": 6,
787
+ "results": {
788
+ "twitter_0": {"agent_id": 0, "response": "...", "platform": "twitter"},
789
+ "reddit_1": {"agent_id": 1, "response": "...", "platform": "reddit"},
790
+ "twitter_2": {"agent_id": 2, "response": "...", "platform": "twitter"},
791
+ "reddit_2": {"agent_id": 2, "response": "...", "platform": "reddit"}
792
+ }
793
+ },
794
+ "timestamp": "2025-12-08T10:00:01"
795
+ }
796
+ }
797
+ ```
798
+
799
+ ---
800
+
801
+ #### 3. 全局采访(采访所有Agent)
802
+
803
+ **接口**: `POST /api/simulation/interview/all`
804
+
805
+ **请求参数**:
806
+ ```json
807
+ {
808
+ "simulation_id": "sim_xxxx",
809
+ "prompt": "你对这件事整体有什么看法?",
810
+ "platform": "reddit",
811
+ "timeout": 180
812
+ }
813
+ ```
814
+
815
+ | 参数 | 类型 | 必填 | 默认值 | 说明 |
816
+ |------|------|------|--------|------|
817
+ | simulation_id | String | 是 | - | 模拟ID |
818
+ | prompt | String | 是 | - | 采访问题(所有Agent使用相同问题) |
819
+ | platform | String | 否 | null | 指定平台(twitter/reddit),不指定则双平台同时采访 |
820
+ | timeout | Integer | 否 | 180 | 超时时间(秒) |
821
+
822
+ **返回示例**:
823
+ ```json
824
+ {
825
+ "success": true,
826
+ "data": {
827
+ "success": true,
828
+ "interviews_count": 50,
829
+ "result": {
830
+ "interviews_count": 100,
831
+ "results": {
832
+ "twitter_0": {"agent_id": 0, "response": "...", "platform": "twitter"},
833
+ "reddit_0": {"agent_id": 0, "response": "...", "platform": "reddit"},
834
+ "twitter_1": {"agent_id": 1, "response": "...", "platform": "twitter"},
835
+ "reddit_1": {"agent_id": 1, "response": "...", "platform": "reddit"},
836
+ ...
837
+ }
838
+ },
839
+ "timestamp": "2025-12-08T10:00:01"
840
+ }
841
+ }
842
+ ```
843
+
844
+ ---
845
+
846
+ #### 4. 获取Interview历史
847
+
848
+ **接口**: `POST /api/simulation/interview/history`
849
+
850
+ **请求参数**:
851
+ ```json
852
+ {
853
+ "simulation_id": "sim_xxxx",
854
+ "platform": "reddit",
855
+ "agent_id": 0,
856
+ "limit": 100
857
+ }
858
+ ```
859
+
860
+ | 参数 | 类型 | 必填 | 默认值 | 说明 |
861
+ |------|------|------|--------|------|
862
+ | simulation_id | String | 是 | - | 模拟ID |
863
+ | platform | String | 否 | reddit | 平台类型(reddit/twitter) |
864
+ | agent_id | Integer | 否 | - | 过滤Agent ID |
865
+ | limit | Integer | 否 | 100 | 返回数量限制 |
866
+
867
+ **返回示例**:
868
+ ```json
869
+ {
870
+ "success": true,
871
+ "data": {
872
+ "count": 10,
873
+ "history": [
874
+ {
875
+ "agent_id": 0,
876
+ "response": "我认为...",
877
+ "prompt": "你对这件事有什么看法?",
878
+ "timestamp": "2025-12-08T10:00:00",
879
+ "platform": "reddit"
880
+ },
881
+ ...
882
+ ]
883
+ }
884
+ }
885
+ ```
886
+
887
+ ---
888
+
889
+ #### 5. 获取模拟环境状态
890
+
891
+ **接口**: `POST /api/simulation/env-status`
892
+
893
+ **请求参数**:
894
+ ```json
895
+ {
896
+ "simulation_id": "sim_xxxx"
897
+ }
898
+ ```
899
+
900
+ | 参数 | 类型 | 必填 | 默认值 | 说明 |
901
+ |------|------|------|--------|------|
902
+ | simulation_id | String | 是 | - | 模拟ID |
903
+
904
+ **返回示例**:
905
+ ```json
906
+ {
907
+ "success": true,
908
+ "data": {
909
+ "simulation_id": "sim_xxxx",
910
+ "env_alive": true,
911
+ "twitter_available": true,
912
+ "reddit_available": true,
913
+ "message": "环境正在运行,可以接收Interview命令"
914
+ }
915
+ }
916
+ ```
917
+
918
+ ---
919
+
920
+ #### 6. 关闭模拟环境
921
+
922
+ **接口**: `POST /api/simulation/close-env`
923
+
924
+ **请求参数**:
925
+ ```json
926
+ {
927
+ "simulation_id": "sim_10b494550540",
928
+ "timeout": 30
929
+ }
930
+ ```
931
+
932
+ | 参数 | 类型 | 必填 | 默认值 | 说明 |
933
+ |------|------|------|--------|------|
934
+ | simulation_id | String | 是 | - | 模拟ID |
935
+ | timeout | Integer | 否 | 30 | 超时时间(秒) |
936
+
937
+ **返回示例**:
938
+ ```json
939
+ {
940
+ "success": true,
941
+ "data": {
942
+ "success": true,
943
+ "message": "环境关闭命令已发送",
944
+ "result": {"message": "环境即将关闭"},
945
+ "timestamp": "2025-12-08T10:00:01"
946
+ }
947
+ }
948
+ ```
949
+
950
+ **注意**: 此接口与 `/stop` 不同:
951
+ - `/stop`: 强制终止模拟进程
952
+ - `/close-env`: 优雅地关闭环境,让模拟进程正常退出
953
+
954
  #### 6. 获取运行状态
955
 
956
  **接口**: `GET /api/simulation/{simulation_id}/run-status`
 
1716
 
1717
  ---
1718
 
1719
+ ### 9. SimulationIPCClient/Server (IPC通信模块)
1720
+
1721
+ **文件**: `app/services/simulation_ipc.py`
1722
+
1723
+ **功能**: 实现Flask后端与模拟脚本之间的进程间通信
1724
+
1725
+ **核心类**:
1726
+
1727
+ ```python
1728
+ class SimulationIPCClient:
1729
+ """IPC客户端(Flask端使用)"""
1730
+
1731
+ def send_interview(agent_id: int, prompt: str, timeout: float) -> IPCResponse:
1732
+ """发送单个Agent采访命令"""
1733
+
1734
+ def send_batch_interview(interviews: List[Dict], timeout: float) -> IPCResponse:
1735
+ """发送批量采访命令"""
1736
+
1737
+ def send_close_env(timeout: float) -> IPCResponse:
1738
+ """发送关闭环境命令"""
1739
+
1740
+ def check_env_alive() -> bool:
1741
+ """检查模拟环境是否存活"""
1742
+ ```
1743
+
1744
+ ```python
1745
+ class SimulationIPCServer:
1746
+ """IPC服务器(模拟脚本端使用)"""
1747
+
1748
+ def poll_commands() -> Optional[IPCCommand]:
1749
+ """轮询获取待处理命令"""
1750
+
1751
+ def send_response(response: IPCResponse):
1752
+ """发送响应"""
1753
+ ```
1754
+
1755
+ **命令类型**:
1756
+
1757
+ | 命令类型 | 说明 |
1758
+ |----------|------|
1759
+ | interview | 单个Agent采访 |
1760
+ | batch_interview | 批量采访 |
1761
+ | close_env | 关闭环境 |
1762
+
1763
+ **文件结构**:
1764
+
1765
+ ```
1766
+ uploads/simulations/sim_xxx/
1767
+ ├── ipc_commands/ # 命令文件目录
1768
+ │ └── {command_id}.json # 待处理命令
1769
+ ├── ipc_responses/ # 响应文件目录
1770
+ │ └── {command_id}.json # 命令响应
1771
+ └── env_status.json # 环境状态文件
1772
+ ```
1773
+
1774
+ **使用示例**:
1775
+
1776
+ ```python
1777
+ # Flask端发送Interview命令
1778
+ from app.services import SimulationRunner
1779
+
1780
+ # 单个采访
1781
+ result = SimulationRunner.interview_agent(
1782
+ simulation_id="sim_xxx",
1783
+ agent_id=0,
1784
+ prompt="你对这件事有什么看法?"
1785
+ )
1786
+
1787
+ # 批量采访
1788
+ result = SimulationRunner.interview_agents_batch(
1789
+ simulation_id="sim_xxx",
1790
+ interviews=[
1791
+ {"agent_id": 0, "prompt": "问题A"},
1792
+ {"agent_id": 1, "prompt": "问题B"}
1793
+ ]
1794
+ )
1795
+
1796
+ # 全局采访
1797
+ result = SimulationRunner.interview_all_agents(
1798
+ simulation_id="sim_xxx",
1799
+ prompt="你认为事件会如何发展?"
1800
+ )
1801
+ ```
1802
+
1803
+ ---
1804
+
1805
  ## 工具类
1806
 
1807
  ### 1. FileParser (文件解析器)
 
2068
  # Step 8: 实时查询运行状态
2069
  curl http://localhost:5001/api/simulation/{sim_xxx}/run-status
2070
 
2071
+ # Step 9: 检查环境状态(模拟完成后环境会进入等待命令模式)
2072
+ curl http://localhost:5001/api/simulation/{sim_xxx}/env-status
2073
+
2074
+ # Step 10: 采访单个Agent
2075
+ curl -X POST http://localhost:5001/api/simulation/{sim_xxx}/interview \
2076
+ -H "Content-Type: application/json" \
2077
+ -d '{
2078
+ "agent_id": 0,
2079
+ "prompt": "你对这件事有什么看法?"
2080
+ }'
2081
+
2082
+ # Step 11: 全局采访(采访所有Agent)
2083
+ curl -X POST http://localhost:5001/api/simulation/{sim_xxx}/interview/all \
2084
+ -H "Content-Type: application/json" \
2085
+ -d '{
2086
+ "prompt": "你认为事件的后续发展会如何?"
2087
+ }'
2088
+
2089
+ # Step 12: 获取Interview历史
2090
+ curl http://localhost:5001/api/simulation/{sim_xxx}/interview/history
2091
+
2092
+ # Step 13: 关闭模拟环境(优雅退出)
2093
+ curl -X POST http://localhost:5001/api/simulation/close-env \
2094
+ -H "Content-Type: application/json" \
2095
+ -d '{
2096
+ "simulation_id": "sim_xxx"
2097
+ }'
2098
+
2099
+ # 或者强制停止模拟
2100
  curl -X POST http://localhost:5001/api/simulation/stop \
2101
  -H "Content-Type: application/json" \
2102
  -d '{
 
2265
 
2266
  ---
2267
 
2268
+ **最后更新**: 2025-12-08
2269
+ **版本**: v1.2.0
2270
+
2271
+ ### 更新日志
2272
+
2273
+ **v1.2.0 (2025-12-08)**:
2274
+ - 新增 Interview 采访功能
2275
+ - 支持单个Agent采访
2276
+ - 支持批量采访多个Agent
2277
+ - 支持全局采访(所有Agent使用相同问题)
2278
+ - 支持获取Interview历史记录
2279
+ - 新增模拟状态持久化
2280
+ - 模拟完成后环境不立即关闭,进入等待命令模式
2281
+ - 支持优雅关闭环境命令
2282
+ - 新增 IPC 通信机制
2283
+ - Flask后端与模拟脚本之间的进程间通信
2284
+ - 基于文件系统的命令/响应模式
2285
+
2286
+ **v1.1.0 (2025-12-05)**:
2287
+ - 新增图谱记忆动态更新功能
2288
+ - 支持 max_rounds 参数限制模拟轮数
2289
+
2290
+ **v1.0.0**:
2291
+ - 初始版本发布
2292
+ - 支持知识图谱构建
2293
+ - 支持Agent人设生成
2294
+ - 支持双平台模拟
2295
 
backend/app/api/simulation.py CHANGED
@@ -1723,3 +1723,568 @@ def get_simulation_comments(simulation_id: str):
1723
  "error": str(e),
1724
  "traceback": traceback.format_exc()
1725
  }), 500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1723
  "error": str(e),
1724
  "traceback": traceback.format_exc()
1725
  }), 500
1726
+
1727
+
1728
+ # ============== Interview 采访接口 ==============
1729
+
1730
+ @simulation_bp.route('/interview', methods=['POST'])
1731
+ def interview_agent():
1732
+ """
1733
+ 采访单个Agent
1734
+
1735
+ 注意:此功能需要模拟环境处于运行状态(完成模拟循环后进入等待命令模式)
1736
+
1737
+ 请求(JSON):
1738
+ {
1739
+ "simulation_id": "sim_xxxx", // 必填,模拟ID
1740
+ "agent_id": 0, // 必填,Agent ID
1741
+ "prompt": "你对这件事有什么看法?", // 必填,采访问题
1742
+ "platform": "twitter", // 可选,指定平台(twitter/reddit)
1743
+ // 不指定时:双平台模拟同时采访两个平台
1744
+ "timeout": 60 // 可选,超时时间(秒),默认60
1745
+ }
1746
+
1747
+ 返回(不指定platform,双平台模式):
1748
+ {
1749
+ "success": true,
1750
+ "data": {
1751
+ "agent_id": 0,
1752
+ "prompt": "你对这件事有什么看法?",
1753
+ "result": {
1754
+ "agent_id": 0,
1755
+ "prompt": "...",
1756
+ "platforms": {
1757
+ "twitter": {"agent_id": 0, "response": "...", "platform": "twitter"},
1758
+ "reddit": {"agent_id": 0, "response": "...", "platform": "reddit"}
1759
+ }
1760
+ },
1761
+ "timestamp": "2025-12-08T10:00:01"
1762
+ }
1763
+ }
1764
+
1765
+ 返回(指定platform):
1766
+ {
1767
+ "success": true,
1768
+ "data": {
1769
+ "agent_id": 0,
1770
+ "prompt": "你对这件事有什么看法?",
1771
+ "result": {
1772
+ "agent_id": 0,
1773
+ "response": "我认为...",
1774
+ "platform": "twitter",
1775
+ "timestamp": "2025-12-08T10:00:00"
1776
+ },
1777
+ "timestamp": "2025-12-08T10:00:01"
1778
+ }
1779
+ }
1780
+ """
1781
+ try:
1782
+ data = request.get_json() or {}
1783
+
1784
+ simulation_id = data.get('simulation_id')
1785
+ agent_id = data.get('agent_id')
1786
+ prompt = data.get('prompt')
1787
+ platform = data.get('platform') # 可选:twitter/reddit/None
1788
+ timeout = data.get('timeout', 60)
1789
+
1790
+ if not simulation_id:
1791
+ return jsonify({
1792
+ "success": False,
1793
+ "error": "请提供 simulation_id"
1794
+ }), 400
1795
+
1796
+ if agent_id is None:
1797
+ return jsonify({
1798
+ "success": False,
1799
+ "error": "请提供 agent_id"
1800
+ }), 400
1801
+
1802
+ if not prompt:
1803
+ return jsonify({
1804
+ "success": False,
1805
+ "error": "请提供 prompt(采访问题)"
1806
+ }), 400
1807
+
1808
+ # 验证platform参数
1809
+ if platform and platform not in ("twitter", "reddit"):
1810
+ return jsonify({
1811
+ "success": False,
1812
+ "error": "platform 参数只能是 'twitter' 或 'reddit'"
1813
+ }), 400
1814
+
1815
+ # 检查环境状态
1816
+ if not SimulationRunner.check_env_alive(simulation_id):
1817
+ return jsonify({
1818
+ "success": False,
1819
+ "error": "模拟环境未运行或已关闭。请确保模拟已完成并进入等待命令模式。"
1820
+ }), 400
1821
+
1822
+ result = SimulationRunner.interview_agent(
1823
+ simulation_id=simulation_id,
1824
+ agent_id=agent_id,
1825
+ prompt=prompt,
1826
+ platform=platform,
1827
+ timeout=timeout
1828
+ )
1829
+
1830
+ return jsonify({
1831
+ "success": result.get("success", False),
1832
+ "data": result
1833
+ })
1834
+
1835
+ except ValueError as e:
1836
+ return jsonify({
1837
+ "success": False,
1838
+ "error": str(e)
1839
+ }), 400
1840
+
1841
+ except TimeoutError as e:
1842
+ return jsonify({
1843
+ "success": False,
1844
+ "error": f"等待Interview响应超时: {str(e)}"
1845
+ }), 504
1846
+
1847
+ except Exception as e:
1848
+ logger.error(f"Interview失败: {str(e)}")
1849
+ return jsonify({
1850
+ "success": False,
1851
+ "error": str(e),
1852
+ "traceback": traceback.format_exc()
1853
+ }), 500
1854
+
1855
+
1856
+ @simulation_bp.route('/interview/batch', methods=['POST'])
1857
+ def interview_agents_batch():
1858
+ """
1859
+ 批量采访多个Agent
1860
+
1861
+ 注意:此功能需要模拟环境处于运行状态
1862
+
1863
+ 请求(JSON):
1864
+ {
1865
+ "simulation_id": "sim_xxxx", // 必填,模拟ID
1866
+ "interviews": [ // 必填,采访列表
1867
+ {
1868
+ "agent_id": 0,
1869
+ "prompt": "你对A有什么看法?",
1870
+ "platform": "twitter" // 可选,指定该Agent的采访平台
1871
+ },
1872
+ {
1873
+ "agent_id": 1,
1874
+ "prompt": "你对B有什么看法?" // 不指定platform则使用默认值
1875
+ }
1876
+ ],
1877
+ "platform": "reddit", // 可选,默认平台(被每项的platform覆盖)
1878
+ // 不指定时:双平台模拟每个Agent同时采访两个平台
1879
+ "timeout": 120 // 可选,超时时间(秒),默认120
1880
+ }
1881
+
1882
+ 返回:
1883
+ {
1884
+ "success": true,
1885
+ "data": {
1886
+ "interviews_count": 2,
1887
+ "result": {
1888
+ "interviews_count": 4,
1889
+ "results": {
1890
+ "twitter_0": {"agent_id": 0, "response": "...", "platform": "twitter"},
1891
+ "reddit_0": {"agent_id": 0, "response": "...", "platform": "reddit"},
1892
+ "twitter_1": {"agent_id": 1, "response": "...", "platform": "twitter"},
1893
+ "reddit_1": {"agent_id": 1, "response": "...", "platform": "reddit"}
1894
+ }
1895
+ },
1896
+ "timestamp": "2025-12-08T10:00:01"
1897
+ }
1898
+ }
1899
+ """
1900
+ try:
1901
+ data = request.get_json() or {}
1902
+
1903
+ simulation_id = data.get('simulation_id')
1904
+ interviews = data.get('interviews')
1905
+ platform = data.get('platform') # 可选:twitter/reddit/None
1906
+ timeout = data.get('timeout', 120)
1907
+
1908
+ if not simulation_id:
1909
+ return jsonify({
1910
+ "success": False,
1911
+ "error": "请提供 simulation_id"
1912
+ }), 400
1913
+
1914
+ if not interviews or not isinstance(interviews, list):
1915
+ return jsonify({
1916
+ "success": False,
1917
+ "error": "请提供 interviews(采访列表)"
1918
+ }), 400
1919
+
1920
+ # 验证platform参数
1921
+ if platform and platform not in ("twitter", "reddit"):
1922
+ return jsonify({
1923
+ "success": False,
1924
+ "error": "platform 参数只能是 'twitter' 或 'reddit'"
1925
+ }), 400
1926
+
1927
+ # 验证每个采访项
1928
+ for i, interview in enumerate(interviews):
1929
+ if 'agent_id' not in interview:
1930
+ return jsonify({
1931
+ "success": False,
1932
+ "error": f"采访列表第{i+1}项缺少 agent_id"
1933
+ }), 400
1934
+ if 'prompt' not in interview:
1935
+ return jsonify({
1936
+ "success": False,
1937
+ "error": f"采访列表第{i+1}项缺少 prompt"
1938
+ }), 400
1939
+ # 验证每项的platform(如果有)
1940
+ item_platform = interview.get('platform')
1941
+ if item_platform and item_platform not in ("twitter", "reddit"):
1942
+ return jsonify({
1943
+ "success": False,
1944
+ "error": f"采访列表第{i+1}项的platform只能是 'twitter' 或 'reddit'"
1945
+ }), 400
1946
+
1947
+ # 检查环境状态
1948
+ if not SimulationRunner.check_env_alive(simulation_id):
1949
+ return jsonify({
1950
+ "success": False,
1951
+ "error": "模拟环境未运行或已关闭。请确保模拟已完成并进入等待命令模式。"
1952
+ }), 400
1953
+
1954
+ result = SimulationRunner.interview_agents_batch(
1955
+ simulation_id=simulation_id,
1956
+ interviews=interviews,
1957
+ platform=platform,
1958
+ timeout=timeout
1959
+ )
1960
+
1961
+ return jsonify({
1962
+ "success": result.get("success", False),
1963
+ "data": result
1964
+ })
1965
+
1966
+ except ValueError as e:
1967
+ return jsonify({
1968
+ "success": False,
1969
+ "error": str(e)
1970
+ }), 400
1971
+
1972
+ except TimeoutError as e:
1973
+ return jsonify({
1974
+ "success": False,
1975
+ "error": f"等待批量Interview响应超时: {str(e)}"
1976
+ }), 504
1977
+
1978
+ except Exception as e:
1979
+ logger.error(f"批量Interview失败: {str(e)}")
1980
+ return jsonify({
1981
+ "success": False,
1982
+ "error": str(e),
1983
+ "traceback": traceback.format_exc()
1984
+ }), 500
1985
+
1986
+
1987
+ @simulation_bp.route('/interview/all', methods=['POST'])
1988
+ def interview_all_agents():
1989
+ """
1990
+ 全局采访 - 使用相同问题采访所有Agent
1991
+
1992
+ 注意:此功能需要模拟环境处于运行状态
1993
+
1994
+ 请求(JSON):
1995
+ {
1996
+ "simulation_id": "sim_xxxx", // 必填,模拟ID
1997
+ "prompt": "你对这件事整体有什么看法?", // 必填,采访问题(所有Agent使用相同问题)
1998
+ "platform": "reddit", // 可选,指定平台(twitter/reddit)
1999
+ // 不指定时:双平台模拟每个Agent同时采访两个平台
2000
+ "timeout": 180 // 可选,超时时间(秒),默认180
2001
+ }
2002
+
2003
+ 返回:
2004
+ {
2005
+ "success": true,
2006
+ "data": {
2007
+ "interviews_count": 50,
2008
+ "result": {
2009
+ "interviews_count": 100,
2010
+ "results": {
2011
+ "twitter_0": {"agent_id": 0, "response": "...", "platform": "twitter"},
2012
+ "reddit_0": {"agent_id": 0, "response": "...", "platform": "reddit"},
2013
+ ...
2014
+ }
2015
+ },
2016
+ "timestamp": "2025-12-08T10:00:01"
2017
+ }
2018
+ }
2019
+ """
2020
+ try:
2021
+ data = request.get_json() or {}
2022
+
2023
+ simulation_id = data.get('simulation_id')
2024
+ prompt = data.get('prompt')
2025
+ platform = data.get('platform') # 可选:twitter/reddit/None
2026
+ timeout = data.get('timeout', 180)
2027
+
2028
+ if not simulation_id:
2029
+ return jsonify({
2030
+ "success": False,
2031
+ "error": "请提供 simulation_id"
2032
+ }), 400
2033
+
2034
+ if not prompt:
2035
+ return jsonify({
2036
+ "success": False,
2037
+ "error": "请提供 prompt(采访问题)"
2038
+ }), 400
2039
+
2040
+ # 验证platform参数
2041
+ if platform and platform not in ("twitter", "reddit"):
2042
+ return jsonify({
2043
+ "success": False,
2044
+ "error": "platform 参数只能是 'twitter' 或 'reddit'"
2045
+ }), 400
2046
+
2047
+ # 检查环境状态
2048
+ if not SimulationRunner.check_env_alive(simulation_id):
2049
+ return jsonify({
2050
+ "success": False,
2051
+ "error": "模拟环境未运行或已关闭。请确保模拟已完成并进入等待命令模式。"
2052
+ }), 400
2053
+
2054
+ result = SimulationRunner.interview_all_agents(
2055
+ simulation_id=simulation_id,
2056
+ prompt=prompt,
2057
+ platform=platform,
2058
+ timeout=timeout
2059
+ )
2060
+
2061
+ return jsonify({
2062
+ "success": result.get("success", False),
2063
+ "data": result
2064
+ })
2065
+
2066
+ except ValueError as e:
2067
+ return jsonify({
2068
+ "success": False,
2069
+ "error": str(e)
2070
+ }), 400
2071
+
2072
+ except TimeoutError as e:
2073
+ return jsonify({
2074
+ "success": False,
2075
+ "error": f"等待全局Interview响应超时: {str(e)}"
2076
+ }), 504
2077
+
2078
+ except Exception as e:
2079
+ logger.error(f"全局Interview失败: {str(e)}")
2080
+ return jsonify({
2081
+ "success": False,
2082
+ "error": str(e),
2083
+ "traceback": traceback.format_exc()
2084
+ }), 500
2085
+
2086
+
2087
+ @simulation_bp.route('/interview/history', methods=['POST'])
2088
+ def get_interview_history():
2089
+ """
2090
+ 获取Interview历史记录
2091
+
2092
+ 从模拟数据库中读取所有Interview记录
2093
+
2094
+ 请求(JSON):
2095
+ {
2096
+ "simulation_id": "sim_xxxx", // 必填,模拟ID
2097
+ "platform": "reddit", // 可选,平台类型(reddit/twitter),默认reddit
2098
+ "agent_id": 0, // 可选,过滤Agent ID
2099
+ "limit": 100 // 可选,返回数量,默认100
2100
+ }
2101
+
2102
+ 返回:
2103
+ {
2104
+ "success": true,
2105
+ "data": {
2106
+ "count": 10,
2107
+ "history": [
2108
+ {
2109
+ "agent_id": 0,
2110
+ "response": "我认为...",
2111
+ "prompt": "你对这件事有什么看法?",
2112
+ "timestamp": "2025-12-08T10:00:00",
2113
+ "platform": "reddit"
2114
+ },
2115
+ ...
2116
+ ]
2117
+ }
2118
+ }
2119
+ """
2120
+ try:
2121
+ data = request.get_json() or {}
2122
+
2123
+ simulation_id = data.get('simulation_id')
2124
+ platform = data.get('platform', 'reddit')
2125
+ agent_id = data.get('agent_id')
2126
+ limit = data.get('limit', 100)
2127
+
2128
+ if not simulation_id:
2129
+ return jsonify({
2130
+ "success": False,
2131
+ "error": "请提供 simulation_id"
2132
+ }), 400
2133
+
2134
+ history = SimulationRunner.get_interview_history(
2135
+ simulation_id=simulation_id,
2136
+ platform=platform,
2137
+ agent_id=agent_id,
2138
+ limit=limit
2139
+ )
2140
+
2141
+ return jsonify({
2142
+ "success": True,
2143
+ "data": {
2144
+ "count": len(history),
2145
+ "history": history
2146
+ }
2147
+ })
2148
+
2149
+ except Exception as e:
2150
+ logger.error(f"获取Interview历史失败: {str(e)}")
2151
+ return jsonify({
2152
+ "success": False,
2153
+ "error": str(e),
2154
+ "traceback": traceback.format_exc()
2155
+ }), 500
2156
+
2157
+
2158
+ @simulation_bp.route('/env-status', methods=['POST'])
2159
+ def get_env_status():
2160
+ """
2161
+ 获取模拟环境状态
2162
+
2163
+ 检查模拟环境是否存活(可以接收Interview命令)
2164
+
2165
+ 请求(JSON):
2166
+ {
2167
+ "simulation_id": "sim_xxxx" // 必填,模拟ID
2168
+ }
2169
+
2170
+ 返回:
2171
+ {
2172
+ "success": true,
2173
+ "data": {
2174
+ "simulation_id": "sim_xxxx",
2175
+ "env_alive": true,
2176
+ "twitter_available": true,
2177
+ "reddit_available": true,
2178
+ "message": "环境正在运行,可以接收Interview命令"
2179
+ }
2180
+ }
2181
+ """
2182
+ try:
2183
+ data = request.get_json() or {}
2184
+
2185
+ simulation_id = data.get('simulation_id')
2186
+
2187
+ if not simulation_id:
2188
+ return jsonify({
2189
+ "success": False,
2190
+ "error": "请提供 simulation_id"
2191
+ }), 400
2192
+
2193
+ env_alive = SimulationRunner.check_env_alive(simulation_id)
2194
+
2195
+ # 获取更详细的状态信息
2196
+ env_status = SimulationRunner.get_env_status_detail(simulation_id)
2197
+
2198
+ if env_alive:
2199
+ message = "环境正在运行,可以接收Interview命令"
2200
+ else:
2201
+ message = "环境未运行或已关闭"
2202
+
2203
+ return jsonify({
2204
+ "success": True,
2205
+ "data": {
2206
+ "simulation_id": simulation_id,
2207
+ "env_alive": env_alive,
2208
+ "twitter_available": env_status.get("twitter_available", False),
2209
+ "reddit_available": env_status.get("reddit_available", False),
2210
+ "message": message
2211
+ }
2212
+ })
2213
+
2214
+ except Exception as e:
2215
+ logger.error(f"获取环境状态失败: {str(e)}")
2216
+ return jsonify({
2217
+ "success": False,
2218
+ "error": str(e),
2219
+ "traceback": traceback.format_exc()
2220
+ }), 500
2221
+
2222
+
2223
+ @simulation_bp.route('/close-env', methods=['POST'])
2224
+ def close_simulation_env():
2225
+ """
2226
+ 关闭模拟环境
2227
+
2228
+ 向模拟发送关闭环境命令,使其优雅退出等待命令模式。
2229
+
2230
+ 注意:这不同于 /stop 接口,/stop 会强制终止进程,
2231
+ 而此接口会让模拟优雅地关闭环境并退出。
2232
+
2233
+ 请求(JSON):
2234
+ {
2235
+ "simulation_id": "sim_xxxx", // 必填,模拟ID
2236
+ "timeout": 30 // 可选,超时时间(秒),默认30
2237
+ }
2238
+
2239
+ 返回:
2240
+ {
2241
+ "success": true,
2242
+ "data": {
2243
+ "message": "环境关闭命令已发送",
2244
+ "result": {...},
2245
+ "timestamp": "2025-12-08T10:00:01"
2246
+ }
2247
+ }
2248
+ """
2249
+ try:
2250
+ data = request.get_json() or {}
2251
+
2252
+ simulation_id = data.get('simulation_id')
2253
+ timeout = data.get('timeout', 30)
2254
+
2255
+ if not simulation_id:
2256
+ return jsonify({
2257
+ "success": False,
2258
+ "error": "请提供 simulation_id"
2259
+ }), 400
2260
+
2261
+ result = SimulationRunner.close_simulation_env(
2262
+ simulation_id=simulation_id,
2263
+ timeout=timeout
2264
+ )
2265
+
2266
+ # 更新模拟状态
2267
+ manager = SimulationManager()
2268
+ state = manager.get_simulation(simulation_id)
2269
+ if state:
2270
+ state.status = SimulationStatus.COMPLETED
2271
+ manager._save_simulation_state(state)
2272
+
2273
+ return jsonify({
2274
+ "success": result.get("success", False),
2275
+ "data": result
2276
+ })
2277
+
2278
+ except ValueError as e:
2279
+ return jsonify({
2280
+ "success": False,
2281
+ "error": str(e)
2282
+ }), 400
2283
+
2284
+ except Exception as e:
2285
+ logger.error(f"关闭环境失败: {str(e)}")
2286
+ return jsonify({
2287
+ "success": False,
2288
+ "error": str(e),
2289
+ "traceback": traceback.format_exc()
2290
+ }), 500
backend/app/services/__init__.py CHANGED
@@ -28,6 +28,14 @@ from .zep_graph_memory_updater import (
28
  ZepGraphMemoryManager,
29
  AgentActivity
30
  )
 
 
 
 
 
 
 
 
31
 
32
  __all__ = [
33
  'OntologyGenerator',
@@ -55,5 +63,11 @@ __all__ = [
55
  'ZepGraphMemoryUpdater',
56
  'ZepGraphMemoryManager',
57
  'AgentActivity',
 
 
 
 
 
 
58
  ]
59
 
 
28
  ZepGraphMemoryManager,
29
  AgentActivity
30
  )
31
+ from .simulation_ipc import (
32
+ SimulationIPCClient,
33
+ SimulationIPCServer,
34
+ IPCCommand,
35
+ IPCResponse,
36
+ CommandType,
37
+ CommandStatus
38
+ )
39
 
40
  __all__ = [
41
  'OntologyGenerator',
 
63
  'ZepGraphMemoryUpdater',
64
  'ZepGraphMemoryManager',
65
  'AgentActivity',
66
+ 'SimulationIPCClient',
67
+ 'SimulationIPCServer',
68
+ 'IPCCommand',
69
+ 'IPCResponse',
70
+ 'CommandType',
71
+ 'CommandStatus',
72
  ]
73
 
backend/app/services/simulation_ipc.py ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 模拟IPC通信模块
3
+ 用于Flask后端和模拟脚本之间的进程间通信
4
+
5
+ 通过文件系统实现简单的命令/响应模式:
6
+ 1. Flask写入命令到 commands/ 目录
7
+ 2. 模拟脚本轮询命令目录,执行命令并写入响应到 responses/ 目录
8
+ 3. Flask轮询响应目录获取结果
9
+ """
10
+
11
+ import os
12
+ import json
13
+ import time
14
+ import uuid
15
+ from typing import Dict, Any, Optional, List
16
+ from dataclasses import dataclass, field
17
+ from datetime import datetime
18
+ from enum import Enum
19
+
20
+ from ..utils.logger import get_logger
21
+
22
+ logger = get_logger('mirofish.simulation_ipc')
23
+
24
+
25
+ class CommandType(str, Enum):
26
+ """命令类型"""
27
+ INTERVIEW = "interview" # 单个Agent采访
28
+ BATCH_INTERVIEW = "batch_interview" # 批量采访
29
+ CLOSE_ENV = "close_env" # 关闭环境
30
+
31
+
32
+ class CommandStatus(str, Enum):
33
+ """命令状态"""
34
+ PENDING = "pending"
35
+ PROCESSING = "processing"
36
+ COMPLETED = "completed"
37
+ FAILED = "failed"
38
+
39
+
40
+ @dataclass
41
+ class IPCCommand:
42
+ """IPC命令"""
43
+ command_id: str
44
+ command_type: CommandType
45
+ args: Dict[str, Any]
46
+ timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
47
+
48
+ def to_dict(self) -> Dict[str, Any]:
49
+ return {
50
+ "command_id": self.command_id,
51
+ "command_type": self.command_type.value,
52
+ "args": self.args,
53
+ "timestamp": self.timestamp
54
+ }
55
+
56
+ @classmethod
57
+ def from_dict(cls, data: Dict[str, Any]) -> 'IPCCommand':
58
+ return cls(
59
+ command_id=data["command_id"],
60
+ command_type=CommandType(data["command_type"]),
61
+ args=data.get("args", {}),
62
+ timestamp=data.get("timestamp", datetime.now().isoformat())
63
+ )
64
+
65
+
66
+ @dataclass
67
+ class IPCResponse:
68
+ """IPC响应"""
69
+ command_id: str
70
+ status: CommandStatus
71
+ result: Optional[Dict[str, Any]] = None
72
+ error: Optional[str] = None
73
+ timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
74
+
75
+ def to_dict(self) -> Dict[str, Any]:
76
+ return {
77
+ "command_id": self.command_id,
78
+ "status": self.status.value,
79
+ "result": self.result,
80
+ "error": self.error,
81
+ "timestamp": self.timestamp
82
+ }
83
+
84
+ @classmethod
85
+ def from_dict(cls, data: Dict[str, Any]) -> 'IPCResponse':
86
+ return cls(
87
+ command_id=data["command_id"],
88
+ status=CommandStatus(data["status"]),
89
+ result=data.get("result"),
90
+ error=data.get("error"),
91
+ timestamp=data.get("timestamp", datetime.now().isoformat())
92
+ )
93
+
94
+
95
+ class SimulationIPCClient:
96
+ """
97
+ 模拟IPC客户端(Flask端使用)
98
+
99
+ 用于向模拟进程发送命令并等待响应
100
+ """
101
+
102
+ def __init__(self, simulation_dir: str):
103
+ """
104
+ 初始化IPC客户端
105
+
106
+ Args:
107
+ simulation_dir: 模拟数据目录
108
+ """
109
+ self.simulation_dir = simulation_dir
110
+ self.commands_dir = os.path.join(simulation_dir, "ipc_commands")
111
+ self.responses_dir = os.path.join(simulation_dir, "ipc_responses")
112
+
113
+ # 确保目录存在
114
+ os.makedirs(self.commands_dir, exist_ok=True)
115
+ os.makedirs(self.responses_dir, exist_ok=True)
116
+
117
+ def send_command(
118
+ self,
119
+ command_type: CommandType,
120
+ args: Dict[str, Any],
121
+ timeout: float = 60.0,
122
+ poll_interval: float = 0.5
123
+ ) -> IPCResponse:
124
+ """
125
+ 发送命令并等待响应
126
+
127
+ Args:
128
+ command_type: 命令类型
129
+ args: 命令参数
130
+ timeout: 超时时间(秒)
131
+ poll_interval: 轮询间隔(秒)
132
+
133
+ Returns:
134
+ IPCResponse
135
+
136
+ Raises:
137
+ TimeoutError: 等待响应超时
138
+ """
139
+ command_id = str(uuid.uuid4())
140
+ command = IPCCommand(
141
+ command_id=command_id,
142
+ command_type=command_type,
143
+ args=args
144
+ )
145
+
146
+ # 写入命令文件
147
+ command_file = os.path.join(self.commands_dir, f"{command_id}.json")
148
+ with open(command_file, 'w', encoding='utf-8') as f:
149
+ json.dump(command.to_dict(), f, ensure_ascii=False, indent=2)
150
+
151
+ logger.info(f"发送IPC命令: {command_type.value}, command_id={command_id}")
152
+
153
+ # 等待响应
154
+ response_file = os.path.join(self.responses_dir, f"{command_id}.json")
155
+ start_time = time.time()
156
+
157
+ while time.time() - start_time < timeout:
158
+ if os.path.exists(response_file):
159
+ try:
160
+ with open(response_file, 'r', encoding='utf-8') as f:
161
+ response_data = json.load(f)
162
+ response = IPCResponse.from_dict(response_data)
163
+
164
+ # 清理命令和响应文件
165
+ try:
166
+ os.remove(command_file)
167
+ os.remove(response_file)
168
+ except OSError:
169
+ pass
170
+
171
+ logger.info(f"收到IPC响应: command_id={command_id}, status={response.status.value}")
172
+ return response
173
+ except (json.JSONDecodeError, KeyError) as e:
174
+ logger.warning(f"解析响应失败: {e}")
175
+
176
+ time.sleep(poll_interval)
177
+
178
+ # 超时
179
+ logger.error(f"等待IPC响应超时: command_id={command_id}")
180
+
181
+ # 清理命令文件
182
+ try:
183
+ os.remove(command_file)
184
+ except OSError:
185
+ pass
186
+
187
+ raise TimeoutError(f"等待命令响应超时 ({timeout}秒)")
188
+
189
+ def send_interview(
190
+ self,
191
+ agent_id: int,
192
+ prompt: str,
193
+ platform: str = None,
194
+ timeout: float = 60.0
195
+ ) -> IPCResponse:
196
+ """
197
+ 发送单个Agent采访命令
198
+
199
+ Args:
200
+ agent_id: Agent ID
201
+ prompt: 采访问题
202
+ platform: 指定平台(可选)
203
+ - "twitter": 只采访Twitter平台
204
+ - "reddit": 只采访Reddit平台
205
+ - None: 双平台模拟时同时采访两个平台,单平台模拟时采访该平台
206
+ timeout: 超时时间
207
+
208
+ Returns:
209
+ IPCResponse,result字段包含采访结果
210
+ """
211
+ args = {
212
+ "agent_id": agent_id,
213
+ "prompt": prompt
214
+ }
215
+ if platform:
216
+ args["platform"] = platform
217
+
218
+ return self.send_command(
219
+ command_type=CommandType.INTERVIEW,
220
+ args=args,
221
+ timeout=timeout
222
+ )
223
+
224
+ def send_batch_interview(
225
+ self,
226
+ interviews: List[Dict[str, Any]],
227
+ platform: str = None,
228
+ timeout: float = 120.0
229
+ ) -> IPCResponse:
230
+ """
231
+ 发送批量采访命令
232
+
233
+ Args:
234
+ interviews: 采访列表,每个元素包含 {"agent_id": int, "prompt": str, "platform": str(可选)}
235
+ platform: 默认平台(可选,会被每个采访项的platform覆盖)
236
+ - "twitter": 默认只采访Twitter平台
237
+ - "reddit": 默认只采访Reddit平台
238
+ - None: 双平台模拟时每个Agent同时采访两个平台
239
+ timeout: 超时时间
240
+
241
+ Returns:
242
+ IPCResponse,result字段包含所有采访结果
243
+ """
244
+ args = {"interviews": interviews}
245
+ if platform:
246
+ args["platform"] = platform
247
+
248
+ return self.send_command(
249
+ command_type=CommandType.BATCH_INTERVIEW,
250
+ args=args,
251
+ timeout=timeout
252
+ )
253
+
254
+ def send_close_env(self, timeout: float = 30.0) -> IPCResponse:
255
+ """
256
+ 发送关闭环境命令
257
+
258
+ Args:
259
+ timeout: 超时时间
260
+
261
+ Returns:
262
+ IPCResponse
263
+ """
264
+ return self.send_command(
265
+ command_type=CommandType.CLOSE_ENV,
266
+ args={},
267
+ timeout=timeout
268
+ )
269
+
270
+ def check_env_alive(self) -> bool:
271
+ """
272
+ 检查模拟环境是否存活
273
+
274
+ 通过检查 env_status.json 文件来判断
275
+ """
276
+ status_file = os.path.join(self.simulation_dir, "env_status.json")
277
+ if not os.path.exists(status_file):
278
+ return False
279
+
280
+ try:
281
+ with open(status_file, 'r', encoding='utf-8') as f:
282
+ status = json.load(f)
283
+ return status.get("status") == "alive"
284
+ except (json.JSONDecodeError, OSError):
285
+ return False
286
+
287
+
288
+ class SimulationIPCServer:
289
+ """
290
+ 模拟IPC服务器(模拟脚本端使用)
291
+
292
+ 轮询命令目录,执行命令并返回响应
293
+ """
294
+
295
+ def __init__(self, simulation_dir: str):
296
+ """
297
+ 初始化IPC服务器
298
+
299
+ Args:
300
+ simulation_dir: 模拟数据目录
301
+ """
302
+ self.simulation_dir = simulation_dir
303
+ self.commands_dir = os.path.join(simulation_dir, "ipc_commands")
304
+ self.responses_dir = os.path.join(simulation_dir, "ipc_responses")
305
+
306
+ # 确保目录存在
307
+ os.makedirs(self.commands_dir, exist_ok=True)
308
+ os.makedirs(self.responses_dir, exist_ok=True)
309
+
310
+ # 环境状态
311
+ self._running = False
312
+
313
+ def start(self):
314
+ """标记服务器为运行状态"""
315
+ self._running = True
316
+ self._update_env_status("alive")
317
+
318
+ def stop(self):
319
+ """标记服务器为停止状态"""
320
+ self._running = False
321
+ self._update_env_status("stopped")
322
+
323
+ def _update_env_status(self, status: str):
324
+ """更新环境状态文件"""
325
+ status_file = os.path.join(self.simulation_dir, "env_status.json")
326
+ with open(status_file, 'w', encoding='utf-8') as f:
327
+ json.dump({
328
+ "status": status,
329
+ "timestamp": datetime.now().isoformat()
330
+ }, f, ensure_ascii=False, indent=2)
331
+
332
+ def poll_commands(self) -> Optional[IPCCommand]:
333
+ """
334
+ 轮询命令目录,返回第一个待处理的命令
335
+
336
+ Returns:
337
+ IPCCommand 或 None
338
+ """
339
+ if not os.path.exists(self.commands_dir):
340
+ return None
341
+
342
+ # 按时间排序获取命令文件
343
+ command_files = []
344
+ for filename in os.listdir(self.commands_dir):
345
+ if filename.endswith('.json'):
346
+ filepath = os.path.join(self.commands_dir, filename)
347
+ command_files.append((filepath, os.path.getmtime(filepath)))
348
+
349
+ command_files.sort(key=lambda x: x[1])
350
+
351
+ for filepath, _ in command_files:
352
+ try:
353
+ with open(filepath, 'r', encoding='utf-8') as f:
354
+ data = json.load(f)
355
+ return IPCCommand.from_dict(data)
356
+ except (json.JSONDecodeError, KeyError, OSError) as e:
357
+ logger.warning(f"读取命令文件失败: {filepath}, {e}")
358
+ continue
359
+
360
+ return None
361
+
362
+ def send_response(self, response: IPCResponse):
363
+ """
364
+ 发送响应
365
+
366
+ Args:
367
+ response: IPC响应
368
+ """
369
+ response_file = os.path.join(self.responses_dir, f"{response.command_id}.json")
370
+ with open(response_file, 'w', encoding='utf-8') as f:
371
+ json.dump(response.to_dict(), f, ensure_ascii=False, indent=2)
372
+
373
+ # 删除命令文件
374
+ command_file = os.path.join(self.commands_dir, f"{response.command_id}.json")
375
+ try:
376
+ os.remove(command_file)
377
+ except OSError:
378
+ pass
379
+
380
+ def send_success(self, command_id: str, result: Dict[str, Any]):
381
+ """发送成功响应"""
382
+ self.send_response(IPCResponse(
383
+ command_id=command_id,
384
+ status=CommandStatus.COMPLETED,
385
+ result=result
386
+ ))
387
+
388
+ def send_error(self, command_id: str, error: str):
389
+ """发送错误响应"""
390
+ self.send_response(IPCResponse(
391
+ command_id=command_id,
392
+ status=CommandStatus.FAILED,
393
+ error=error
394
+ ))
backend/app/services/simulation_runner.py CHANGED
@@ -12,7 +12,7 @@ import threading
12
  import subprocess
13
  import signal
14
  import atexit
15
- from typing import Dict, Any, List, Optional
16
  from dataclasses import dataclass, field
17
  from datetime import datetime
18
  from enum import Enum
@@ -21,6 +21,7 @@ from queue import Queue
21
  from ..config import Config
22
  from ..utils.logger import get_logger
23
  from .zep_graph_memory_updater import ZepGraphMemoryManager
 
24
 
25
  logger = get_logger('mirofish.simulation_runner')
26
 
@@ -989,4 +990,365 @@ class SimulationRunner:
989
  if process.poll() is None:
990
  running.append(sim_id)
991
  return running
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
992
 
 
12
  import subprocess
13
  import signal
14
  import atexit
15
+ from typing import Dict, Any, List, Optional, Union
16
  from dataclasses import dataclass, field
17
  from datetime import datetime
18
  from enum import Enum
 
21
  from ..config import Config
22
  from ..utils.logger import get_logger
23
  from .zep_graph_memory_updater import ZepGraphMemoryManager
24
+ from .simulation_ipc import SimulationIPCClient, CommandType, IPCResponse
25
 
26
  logger = get_logger('mirofish.simulation_runner')
27
 
 
990
  if process.poll() is None:
991
  running.append(sim_id)
992
  return running
993
+
994
+ # ============== Interview 功能 ==============
995
+
996
+ @classmethod
997
+ def check_env_alive(cls, simulation_id: str) -> bool:
998
+ """
999
+ 检查模拟环境是否存活(可以接收Interview命令)
1000
+
1001
+ Args:
1002
+ simulation_id: 模拟ID
1003
+
1004
+ Returns:
1005
+ True 表示环境存活,False 表示环境已关闭
1006
+ """
1007
+ sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
1008
+ if not os.path.exists(sim_dir):
1009
+ return False
1010
+
1011
+ ipc_client = SimulationIPCClient(sim_dir)
1012
+ return ipc_client.check_env_alive()
1013
+
1014
+ @classmethod
1015
+ def get_env_status_detail(cls, simulation_id: str) -> Dict[str, Any]:
1016
+ """
1017
+ 获取模拟环境的详细状态信息
1018
+
1019
+ Args:
1020
+ simulation_id: 模拟ID
1021
+
1022
+ Returns:
1023
+ 状态详情字典,包含 status, twitter_available, reddit_available, timestamp
1024
+ """
1025
+ sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
1026
+ status_file = os.path.join(sim_dir, "env_status.json")
1027
+
1028
+ default_status = {
1029
+ "status": "stopped",
1030
+ "twitter_available": False,
1031
+ "reddit_available": False,
1032
+ "timestamp": None
1033
+ }
1034
+
1035
+ if not os.path.exists(status_file):
1036
+ return default_status
1037
+
1038
+ try:
1039
+ with open(status_file, 'r', encoding='utf-8') as f:
1040
+ status = json.load(f)
1041
+ return {
1042
+ "status": status.get("status", "stopped"),
1043
+ "twitter_available": status.get("twitter_available", False),
1044
+ "reddit_available": status.get("reddit_available", False),
1045
+ "timestamp": status.get("timestamp")
1046
+ }
1047
+ except (json.JSONDecodeError, OSError):
1048
+ return default_status
1049
+
1050
+ @classmethod
1051
+ def interview_agent(
1052
+ cls,
1053
+ simulation_id: str,
1054
+ agent_id: int,
1055
+ prompt: str,
1056
+ platform: str = None,
1057
+ timeout: float = 60.0
1058
+ ) -> Dict[str, Any]:
1059
+ """
1060
+ 采访单个Agent
1061
+
1062
+ Args:
1063
+ simulation_id: 模拟ID
1064
+ agent_id: Agent ID
1065
+ prompt: 采访问题
1066
+ platform: 指定平台(可选)
1067
+ - "twitter": 只采访Twitter平台
1068
+ - "reddit": 只采访Reddit平台
1069
+ - None: 双平台模拟时同时采访两个平台,返回整合结果
1070
+ timeout: 超时时间(秒)
1071
+
1072
+ Returns:
1073
+ 采访结果字典
1074
+
1075
+ Raises:
1076
+ ValueError: 模拟不存在或环境未运行
1077
+ TimeoutError: 等待响应超时
1078
+ """
1079
+ sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
1080
+ if not os.path.exists(sim_dir):
1081
+ raise ValueError(f"模拟不存在: {simulation_id}")
1082
+
1083
+ ipc_client = SimulationIPCClient(sim_dir)
1084
+
1085
+ if not ipc_client.check_env_alive():
1086
+ raise ValueError(f"模拟环境未运行或已关闭,无法执行Interview: {simulation_id}")
1087
+
1088
+ logger.info(f"发送Interview命令: simulation_id={simulation_id}, agent_id={agent_id}, platform={platform}")
1089
+
1090
+ response = ipc_client.send_interview(
1091
+ agent_id=agent_id,
1092
+ prompt=prompt,
1093
+ platform=platform,
1094
+ timeout=timeout
1095
+ )
1096
+
1097
+ if response.status.value == "completed":
1098
+ return {
1099
+ "success": True,
1100
+ "agent_id": agent_id,
1101
+ "prompt": prompt,
1102
+ "result": response.result,
1103
+ "timestamp": response.timestamp
1104
+ }
1105
+ else:
1106
+ return {
1107
+ "success": False,
1108
+ "agent_id": agent_id,
1109
+ "prompt": prompt,
1110
+ "error": response.error,
1111
+ "timestamp": response.timestamp
1112
+ }
1113
+
1114
+ @classmethod
1115
+ def interview_agents_batch(
1116
+ cls,
1117
+ simulation_id: str,
1118
+ interviews: List[Dict[str, Any]],
1119
+ platform: str = None,
1120
+ timeout: float = 120.0
1121
+ ) -> Dict[str, Any]:
1122
+ """
1123
+ 批量采访多个Agent
1124
+
1125
+ Args:
1126
+ simulation_id: 模拟ID
1127
+ interviews: 采访列表,每个元素包含 {"agent_id": int, "prompt": str, "platform": str(可选)}
1128
+ platform: 默认平台(可选,会被每个采访项的platform覆盖)
1129
+ - "twitter": 默认只采访Twitter平台
1130
+ - "reddit": 默认只采访Reddit平台
1131
+ - None: 双平台模拟时每个Agent同时采访两个平台
1132
+ timeout: 超时时间(秒)
1133
+
1134
+ Returns:
1135
+ 批量采访结果字典
1136
+
1137
+ Raises:
1138
+ ValueError: 模拟不存在或环境未运行
1139
+ TimeoutError: 等待响应超时
1140
+ """
1141
+ sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
1142
+ if not os.path.exists(sim_dir):
1143
+ raise ValueError(f"模拟不存在: {simulation_id}")
1144
+
1145
+ ipc_client = SimulationIPCClient(sim_dir)
1146
+
1147
+ if not ipc_client.check_env_alive():
1148
+ raise ValueError(f"模拟环境未运行或已关闭,无法执行Interview: {simulation_id}")
1149
+
1150
+ logger.info(f"发送批量Interview命令: simulation_id={simulation_id}, count={len(interviews)}, platform={platform}")
1151
+
1152
+ response = ipc_client.send_batch_interview(
1153
+ interviews=interviews,
1154
+ platform=platform,
1155
+ timeout=timeout
1156
+ )
1157
+
1158
+ if response.status.value == "completed":
1159
+ return {
1160
+ "success": True,
1161
+ "interviews_count": len(interviews),
1162
+ "result": response.result,
1163
+ "timestamp": response.timestamp
1164
+ }
1165
+ else:
1166
+ return {
1167
+ "success": False,
1168
+ "interviews_count": len(interviews),
1169
+ "error": response.error,
1170
+ "timestamp": response.timestamp
1171
+ }
1172
+
1173
+ @classmethod
1174
+ def interview_all_agents(
1175
+ cls,
1176
+ simulation_id: str,
1177
+ prompt: str,
1178
+ platform: str = None,
1179
+ timeout: float = 180.0
1180
+ ) -> Dict[str, Any]:
1181
+ """
1182
+ 采访所有Agent(全局采访)
1183
+
1184
+ 使用相同的问题采访模拟中的所有Agent
1185
+
1186
+ Args:
1187
+ simulation_id: 模拟ID
1188
+ prompt: 采访问题(所有Agent使用相同问题)
1189
+ platform: 指定平台(可选)
1190
+ - "twitter": 只采访Twitter平台
1191
+ - "reddit": 只采访Reddit平台
1192
+ - None: 双平台模拟时每个Agent同时采访两个平台
1193
+ timeout: 超时时间(秒)
1194
+
1195
+ Returns:
1196
+ 全局采访结果字典
1197
+ """
1198
+ sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
1199
+ if not os.path.exists(sim_dir):
1200
+ raise ValueError(f"模拟不存在: {simulation_id}")
1201
+
1202
+ # 从配置文件获取所有Agent信息
1203
+ config_path = os.path.join(sim_dir, "simulation_config.json")
1204
+ if not os.path.exists(config_path):
1205
+ raise ValueError(f"模拟配置不存在: {simulation_id}")
1206
+
1207
+ with open(config_path, 'r', encoding='utf-8') as f:
1208
+ config = json.load(f)
1209
+
1210
+ agent_configs = config.get("agent_configs", [])
1211
+ if not agent_configs:
1212
+ raise ValueError(f"模拟配置中没有Agent: {simulation_id}")
1213
+
1214
+ # 构建批量采访列表
1215
+ interviews = []
1216
+ for agent_config in agent_configs:
1217
+ agent_id = agent_config.get("agent_id")
1218
+ if agent_id is not None:
1219
+ interviews.append({
1220
+ "agent_id": agent_id,
1221
+ "prompt": prompt
1222
+ })
1223
+
1224
+ logger.info(f"发送全局Interview命令: simulation_id={simulation_id}, agent_count={len(interviews)}, platform={platform}")
1225
+
1226
+ return cls.interview_agents_batch(
1227
+ simulation_id=simulation_id,
1228
+ interviews=interviews,
1229
+ platform=platform,
1230
+ timeout=timeout
1231
+ )
1232
+
1233
+ @classmethod
1234
+ def close_simulation_env(
1235
+ cls,
1236
+ simulation_id: str,
1237
+ timeout: float = 30.0
1238
+ ) -> Dict[str, Any]:
1239
+ """
1240
+ 关闭模拟环境(而不是停止模拟进程)
1241
+
1242
+ 向模拟发送关闭环境命令,使其优雅退出等待命令模式
1243
+
1244
+ Args:
1245
+ simulation_id: 模拟ID
1246
+ timeout: 超时时间(秒)
1247
+
1248
+ Returns:
1249
+ 操作结果字典
1250
+ """
1251
+ sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
1252
+ if not os.path.exists(sim_dir):
1253
+ raise ValueError(f"模拟不存在: {simulation_id}")
1254
+
1255
+ ipc_client = SimulationIPCClient(sim_dir)
1256
+
1257
+ if not ipc_client.check_env_alive():
1258
+ return {
1259
+ "success": True,
1260
+ "message": "环境已经关闭"
1261
+ }
1262
+
1263
+ logger.info(f"发送关闭环境命令: simulation_id={simulation_id}")
1264
+
1265
+ try:
1266
+ response = ipc_client.send_close_env(timeout=timeout)
1267
+
1268
+ return {
1269
+ "success": response.status.value == "completed",
1270
+ "message": "环境��闭命令已发送",
1271
+ "result": response.result,
1272
+ "timestamp": response.timestamp
1273
+ }
1274
+ except TimeoutError:
1275
+ # 超时可能是因为环境正在关闭
1276
+ return {
1277
+ "success": True,
1278
+ "message": "环境关闭命令已发送(等待响应超时,环境可能正在关闭)"
1279
+ }
1280
+
1281
+ @classmethod
1282
+ def get_interview_history(
1283
+ cls,
1284
+ simulation_id: str,
1285
+ platform: str = "reddit",
1286
+ agent_id: Optional[int] = None,
1287
+ limit: int = 100
1288
+ ) -> List[Dict[str, Any]]:
1289
+ """
1290
+ 获取Interview历史记录(从数据库读取)
1291
+
1292
+ Args:
1293
+ simulation_id: 模拟ID
1294
+ platform: 平台类型(reddit/twitter)
1295
+ agent_id: 过滤Agent ID(可选)
1296
+ limit: 返回数量限制
1297
+
1298
+ Returns:
1299
+ Interview历史记录列表
1300
+ """
1301
+ import sqlite3
1302
+
1303
+ sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
1304
+ db_path = os.path.join(sim_dir, f"{platform}_simulation.db")
1305
+
1306
+ if not os.path.exists(db_path):
1307
+ return []
1308
+
1309
+ results = []
1310
+
1311
+ try:
1312
+ conn = sqlite3.connect(db_path)
1313
+ cursor = conn.cursor()
1314
+
1315
+ # 构建查询
1316
+ # 注意:ActionType.INTERVIEW.value 应该是字符串形式
1317
+ if agent_id is not None:
1318
+ cursor.execute("""
1319
+ SELECT user_id, info, created_at
1320
+ FROM trace
1321
+ WHERE action = 'interview' AND user_id = ?
1322
+ ORDER BY created_at DESC
1323
+ LIMIT ?
1324
+ """, (agent_id, limit))
1325
+ else:
1326
+ cursor.execute("""
1327
+ SELECT user_id, info, created_at
1328
+ FROM trace
1329
+ WHERE action = 'interview'
1330
+ ORDER BY created_at DESC
1331
+ LIMIT ?
1332
+ """, (limit,))
1333
+
1334
+ for user_id, info_json, created_at in cursor.fetchall():
1335
+ try:
1336
+ info = json.loads(info_json) if info_json else {}
1337
+ except json.JSONDecodeError:
1338
+ info = {"raw": info_json}
1339
+
1340
+ results.append({
1341
+ "agent_id": user_id,
1342
+ "response": info.get("response", info),
1343
+ "prompt": info.get("prompt", ""),
1344
+ "timestamp": created_at,
1345
+ "platform": platform
1346
+ })
1347
+
1348
+ conn.close()
1349
+
1350
+ except Exception as e:
1351
+ logger.error(f"读取Interview历史失败: {e}")
1352
+
1353
+ return results
1354
 
backend/scripts/run_parallel_simulation.py CHANGED
@@ -2,8 +2,18 @@
2
  OASIS 双平台并行模拟预设脚本
3
  同时运行Twitter和Reddit模拟,读取相同的配置文件
4
 
 
 
 
 
 
 
 
5
  使用方式:
6
  python run_parallel_simulation.py --config simulation_config.json
 
 
 
7
 
8
  日志结构:
9
  sim_xxx/
@@ -119,7 +129,7 @@ except ImportError as e:
119
  sys.exit(1)
120
 
121
 
122
- # Twitter可用动作
123
  TWITTER_ACTIONS = [
124
  ActionType.CREATE_POST,
125
  ActionType.LIKE_POST,
@@ -129,7 +139,7 @@ TWITTER_ACTIONS = [
129
  ActionType.QUOTE_POST,
130
  ]
131
 
132
- # Reddit可用动作
133
  REDDIT_ACTIONS = [
134
  ActionType.LIKE_POST,
135
  ActionType.DISLIKE_POST,
@@ -147,6 +157,405 @@ REDDIT_ACTIONS = [
147
  ]
148
 
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  def load_config(config_path: str) -> Dict[str, Any]:
151
  """加载配置文件"""
152
  with open(config_path, 'r', encoding='utf-8') as f:
@@ -398,13 +807,21 @@ def get_active_agents_for_round(
398
  return active_agents
399
 
400
 
 
 
 
 
 
 
 
 
401
  async def run_twitter_simulation(
402
  config: Dict[str, Any],
403
  simulation_dir: str,
404
  action_logger: Optional[PlatformActionLogger] = None,
405
  main_logger: Optional[SimulationLogManager] = None,
406
  max_rounds: Optional[int] = None
407
- ):
408
  """运行Twitter模拟
409
 
410
  Args:
@@ -413,7 +830,12 @@ async def run_twitter_simulation(
413
  action_logger: 动作日志记录器
414
  main_logger: 主日志管理器
415
  max_rounds: 最大模拟轮数(可选,用于截断过长的模拟)
 
 
 
416
  """
 
 
417
  def log_info(msg):
418
  if main_logger:
419
  main_logger.info(f"[Twitter] {msg}")
@@ -428,9 +850,9 @@ async def run_twitter_simulation(
428
  profile_path = os.path.join(simulation_dir, "twitter_profiles.csv")
429
  if not os.path.exists(profile_path):
430
  log_info(f"错误: Profile文件不存在: {profile_path}")
431
- return
432
 
433
- agent_graph = await generate_twitter_agent_graph(
434
  profile_path=profile_path,
435
  model=model,
436
  available_actions=TWITTER_ACTIONS,
@@ -439,7 +861,7 @@ async def run_twitter_simulation(
439
  # 从配置文件获取 Agent 真实名称映射(使用 entity_name 而非默认的 Agent_X)
440
  agent_names = get_agent_names_from_config(config)
441
  # 如果配置中没有某个 agent,则使用 OASIS 的默认名称
442
- for agent_id, agent in agent_graph.get_agents():
443
  if agent_id not in agent_names:
444
  agent_names[agent_id] = getattr(agent, 'name', f'Agent_{agent_id}')
445
 
@@ -447,14 +869,14 @@ async def run_twitter_simulation(
447
  if os.path.exists(db_path):
448
  os.remove(db_path)
449
 
450
- env = oasis.make(
451
- agent_graph=agent_graph,
452
  platform=oasis.DefaultPlatformType.TWITTER,
453
  database_path=db_path,
454
  semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
455
  )
456
 
457
- await env.reset()
458
  log_info("环境已启动")
459
 
460
  if action_logger:
@@ -478,7 +900,7 @@ async def run_twitter_simulation(
478
  agent_id = post.get("poster_agent_id", 0)
479
  content = post.get("content", "")
480
  try:
481
- agent = env.agent_graph.get_agent(agent_id)
482
  initial_actions[agent] = ManualAction(
483
  action_type=ActionType.CREATE_POST,
484
  action_args={"content": content}
@@ -498,7 +920,7 @@ async def run_twitter_simulation(
498
  pass
499
 
500
  if initial_actions:
501
- await env.step(initial_actions)
502
  log_info(f"已发布 {len(initial_actions)} 条初始帖子")
503
 
504
  # 记录 round 0 结束
@@ -526,7 +948,7 @@ async def run_twitter_simulation(
526
  simulated_day = simulated_minutes // (60 * 24) + 1
527
 
528
  active_agents = get_active_agents_for_round(
529
- env, config, simulated_hour, round_num
530
  )
531
 
532
  # 无论是否有活跃agent,都记录round开始
@@ -540,7 +962,7 @@ async def run_twitter_simulation(
540
  continue
541
 
542
  actions = {agent: LLMAction() for _, agent in active_agents}
543
- await env.step(actions)
544
 
545
  # 从数据库获取实际执行的动作并记录
546
  actual_actions, last_rowid = fetch_new_actions_from_db(
@@ -567,13 +989,16 @@ async def run_twitter_simulation(
567
  progress = (round_num + 1) / total_rounds * 100
568
  log_info(f"Day {simulated_day}, {simulated_hour:02d}:00 - Round {round_num + 1}/{total_rounds} ({progress:.1f}%)")
569
 
570
- await env.close()
571
 
572
  if action_logger:
573
  action_logger.log_simulation_end(total_rounds, total_actions)
574
 
 
575
  elapsed = (datetime.now() - start_time).total_seconds()
576
- log_info(f"模拟完成! 耗时: {elapsed:.1f}秒, 总动作: {total_actions}")
 
 
577
 
578
 
579
  async def run_reddit_simulation(
@@ -582,7 +1007,7 @@ async def run_reddit_simulation(
582
  action_logger: Optional[PlatformActionLogger] = None,
583
  main_logger: Optional[SimulationLogManager] = None,
584
  max_rounds: Optional[int] = None
585
- ):
586
  """运行Reddit模拟
587
 
588
  Args:
@@ -591,7 +1016,12 @@ async def run_reddit_simulation(
591
  action_logger: 动作日志记录器
592
  main_logger: 主日志管理器
593
  max_rounds: 最大模拟轮数(可选,用于截断过长的模拟)
 
 
 
594
  """
 
 
595
  def log_info(msg):
596
  if main_logger:
597
  main_logger.info(f"[Reddit] {msg}")
@@ -605,9 +1035,9 @@ async def run_reddit_simulation(
605
  profile_path = os.path.join(simulation_dir, "reddit_profiles.json")
606
  if not os.path.exists(profile_path):
607
  log_info(f"错误: Profile文件不存在: {profile_path}")
608
- return
609
 
610
- agent_graph = await generate_reddit_agent_graph(
611
  profile_path=profile_path,
612
  model=model,
613
  available_actions=REDDIT_ACTIONS,
@@ -616,7 +1046,7 @@ async def run_reddit_simulation(
616
  # 从配置文件获取 Agent 真实名称映射(使用 entity_name 而非默认的 Agent_X)
617
  agent_names = get_agent_names_from_config(config)
618
  # 如果配置中没有某个 agent,则使用 OASIS 的默认名称
619
- for agent_id, agent in agent_graph.get_agents():
620
  if agent_id not in agent_names:
621
  agent_names[agent_id] = getattr(agent, 'name', f'Agent_{agent_id}')
622
 
@@ -624,14 +1054,14 @@ async def run_reddit_simulation(
624
  if os.path.exists(db_path):
625
  os.remove(db_path)
626
 
627
- env = oasis.make(
628
- agent_graph=agent_graph,
629
  platform=oasis.DefaultPlatformType.REDDIT,
630
  database_path=db_path,
631
  semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
632
  )
633
 
634
- await env.reset()
635
  log_info("环境已启动")
636
 
637
  if action_logger:
@@ -655,7 +1085,7 @@ async def run_reddit_simulation(
655
  agent_id = post.get("poster_agent_id", 0)
656
  content = post.get("content", "")
657
  try:
658
- agent = env.agent_graph.get_agent(agent_id)
659
  if agent in initial_actions:
660
  if not isinstance(initial_actions[agent], list):
661
  initial_actions[agent] = [initial_actions[agent]]
@@ -683,7 +1113,7 @@ async def run_reddit_simulation(
683
  pass
684
 
685
  if initial_actions:
686
- await env.step(initial_actions)
687
  log_info(f"已发布 {len(initial_actions)} 条初始帖子")
688
 
689
  # 记录 round 0 结束
@@ -711,7 +1141,7 @@ async def run_reddit_simulation(
711
  simulated_day = simulated_minutes // (60 * 24) + 1
712
 
713
  active_agents = get_active_agents_for_round(
714
- env, config, simulated_hour, round_num
715
  )
716
 
717
  # 无论是否有活跃agent,都记录round开始
@@ -725,7 +1155,7 @@ async def run_reddit_simulation(
725
  continue
726
 
727
  actions = {agent: LLMAction() for _, agent in active_agents}
728
- await env.step(actions)
729
 
730
  # 从数据库获取实际执行的动作并记录
731
  actual_actions, last_rowid = fetch_new_actions_from_db(
@@ -752,13 +1182,16 @@ async def run_reddit_simulation(
752
  progress = (round_num + 1) / total_rounds * 100
753
  log_info(f"Day {simulated_day}, {simulated_hour:02d}:00 - Round {round_num + 1}/{total_rounds} ({progress:.1f}%)")
754
 
755
- await env.close()
756
 
757
  if action_logger:
758
  action_logger.log_simulation_end(total_rounds, total_actions)
759
 
 
760
  elapsed = (datetime.now() - start_time).total_seconds()
761
- log_info(f"模拟完成! 耗时: {elapsed:.1f}秒, 总动作: {total_actions}")
 
 
762
 
763
 
764
  async def main():
@@ -785,6 +1218,12 @@ async def main():
785
  default=None,
786
  help='最大模拟轮数(可选,用于截断过长的模拟)'
787
  )
 
 
 
 
 
 
788
 
789
  args = parser.parse_args()
790
 
@@ -794,6 +1233,7 @@ async def main():
794
 
795
  config = load_config(args.config)
796
  simulation_dir = os.path.dirname(args.config) or "."
 
797
 
798
  # 初始化日志配置(禁用 OASIS 日志,清理旧文件)
799
  init_logging_for_simulation(simulation_dir)
@@ -807,6 +1247,7 @@ async def main():
807
  log_manager.info("OASIS 双平台并行模拟")
808
  log_manager.info(f"配置文件: {args.config}")
809
  log_manager.info(f"模拟ID: {config.get('simulation_id', 'unknown')}")
 
810
  log_manager.info("=" * 60)
811
 
812
  time_config = config.get("time_config", {})
@@ -832,20 +1273,70 @@ async def main():
832
 
833
  start_time = datetime.now()
834
 
 
 
 
 
835
  if args.twitter_only:
836
- await run_twitter_simulation(config, simulation_dir, twitter_logger, log_manager, args.max_rounds)
837
  elif args.reddit_only:
838
- await run_reddit_simulation(config, simulation_dir, reddit_logger, log_manager, args.max_rounds)
839
  else:
840
  # 并行运行(每个平台使用独立的日志记录器)
841
- await asyncio.gather(
842
  run_twitter_simulation(config, simulation_dir, twitter_logger, log_manager, args.max_rounds),
843
  run_reddit_simulation(config, simulation_dir, reddit_logger, log_manager, args.max_rounds),
844
  )
 
845
 
846
  total_elapsed = (datetime.now() - start_time).total_seconds()
847
  log_manager.info("=" * 60)
848
- log_manager.info(f"全部模拟完成! 总耗时: {total_elapsed:.1f}秒")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
849
  log_manager.info(f"日志文件:")
850
  log_manager.info(f" - {os.path.join(simulation_dir, 'simulation.log')}")
851
  log_manager.info(f" - {os.path.join(simulation_dir, 'twitter', 'actions.jsonl')}")
@@ -855,4 +1346,3 @@ async def main():
855
 
856
  if __name__ == "__main__":
857
  asyncio.run(main())
858
-
 
2
  OASIS 双平台并行模拟预设脚本
3
  同时运行Twitter和Reddit模拟,读取相同的配置文件
4
 
5
+ 功能特性:
6
+ - 双平台(Twitter + Reddit)并行模拟
7
+ - 完成模拟后不立即关闭环境,进入等待命令模式
8
+ - 支持通过IPC接收Interview命令
9
+ - 支持单个Agent采访和批量采访
10
+ - 支持远程关闭环境命令
11
+
12
  使用方式:
13
  python run_parallel_simulation.py --config simulation_config.json
14
+ python run_parallel_simulation.py --config simulation_config.json --no-wait # 完成后立即关闭
15
+ python run_parallel_simulation.py --config simulation_config.json --twitter-only
16
+ python run_parallel_simulation.py --config simulation_config.json --reddit-only
17
 
18
  日志结构:
19
  sim_xxx/
 
129
  sys.exit(1)
130
 
131
 
132
+ # Twitter可用动作(不包含INTERVIEW,INTERVIEW只能通过ManualAction手动触发)
133
  TWITTER_ACTIONS = [
134
  ActionType.CREATE_POST,
135
  ActionType.LIKE_POST,
 
139
  ActionType.QUOTE_POST,
140
  ]
141
 
142
+ # Reddit可用动作(不包含INTERVIEW,INTERVIEW只能通过ManualAction手动触发)
143
  REDDIT_ACTIONS = [
144
  ActionType.LIKE_POST,
145
  ActionType.DISLIKE_POST,
 
157
  ]
158
 
159
 
160
+ # IPC相关常量
161
+ IPC_COMMANDS_DIR = "ipc_commands"
162
+ IPC_RESPONSES_DIR = "ipc_responses"
163
+ ENV_STATUS_FILE = "env_status.json"
164
+
165
+ class CommandType:
166
+ """命令类型常量"""
167
+ INTERVIEW = "interview"
168
+ BATCH_INTERVIEW = "batch_interview"
169
+ CLOSE_ENV = "close_env"
170
+
171
+
172
+ class ParallelIPCHandler:
173
+ """
174
+ 双平台IPC命令处理器
175
+
176
+ 管理两个平台的环境,处理Interview命令
177
+ """
178
+
179
+ def __init__(
180
+ self,
181
+ simulation_dir: str,
182
+ twitter_env=None,
183
+ twitter_agent_graph=None,
184
+ reddit_env=None,
185
+ reddit_agent_graph=None
186
+ ):
187
+ self.simulation_dir = simulation_dir
188
+ self.twitter_env = twitter_env
189
+ self.twitter_agent_graph = twitter_agent_graph
190
+ self.reddit_env = reddit_env
191
+ self.reddit_agent_graph = reddit_agent_graph
192
+
193
+ self.commands_dir = os.path.join(simulation_dir, IPC_COMMANDS_DIR)
194
+ self.responses_dir = os.path.join(simulation_dir, IPC_RESPONSES_DIR)
195
+ self.status_file = os.path.join(simulation_dir, ENV_STATUS_FILE)
196
+
197
+ # 确保目录存在
198
+ os.makedirs(self.commands_dir, exist_ok=True)
199
+ os.makedirs(self.responses_dir, exist_ok=True)
200
+
201
+ def update_status(self, status: str):
202
+ """更新环境状态"""
203
+ with open(self.status_file, 'w', encoding='utf-8') as f:
204
+ json.dump({
205
+ "status": status,
206
+ "twitter_available": self.twitter_env is not None,
207
+ "reddit_available": self.reddit_env is not None,
208
+ "timestamp": datetime.now().isoformat()
209
+ }, f, ensure_ascii=False, indent=2)
210
+
211
+ def poll_command(self) -> Optional[Dict[str, Any]]:
212
+ """轮询获取待处理命令"""
213
+ if not os.path.exists(self.commands_dir):
214
+ return None
215
+
216
+ # 获取命令文件(按时间排序)
217
+ command_files = []
218
+ for filename in os.listdir(self.commands_dir):
219
+ if filename.endswith('.json'):
220
+ filepath = os.path.join(self.commands_dir, filename)
221
+ command_files.append((filepath, os.path.getmtime(filepath)))
222
+
223
+ command_files.sort(key=lambda x: x[1])
224
+
225
+ for filepath, _ in command_files:
226
+ try:
227
+ with open(filepath, 'r', encoding='utf-8') as f:
228
+ return json.load(f)
229
+ except (json.JSONDecodeError, OSError):
230
+ continue
231
+
232
+ return None
233
+
234
+ def send_response(self, command_id: str, status: str, result: Dict = None, error: str = None):
235
+ """发送响应"""
236
+ response = {
237
+ "command_id": command_id,
238
+ "status": status,
239
+ "result": result,
240
+ "error": error,
241
+ "timestamp": datetime.now().isoformat()
242
+ }
243
+
244
+ response_file = os.path.join(self.responses_dir, f"{command_id}.json")
245
+ with open(response_file, 'w', encoding='utf-8') as f:
246
+ json.dump(response, f, ensure_ascii=False, indent=2)
247
+
248
+ # 删除命令文件
249
+ command_file = os.path.join(self.commands_dir, f"{command_id}.json")
250
+ try:
251
+ os.remove(command_file)
252
+ except OSError:
253
+ pass
254
+
255
+ def _get_env_and_graph(self, platform: str):
256
+ """
257
+ 获取指定平台的环境和agent_graph
258
+
259
+ Args:
260
+ platform: 平台名称 ("twitter" 或 "reddit")
261
+
262
+ Returns:
263
+ (env, agent_graph, platform_name) 或 (None, None, None)
264
+ """
265
+ if platform == "twitter" and self.twitter_env:
266
+ return self.twitter_env, self.twitter_agent_graph, "twitter"
267
+ elif platform == "reddit" and self.reddit_env:
268
+ return self.reddit_env, self.reddit_agent_graph, "reddit"
269
+ else:
270
+ return None, None, None
271
+
272
+ async def _interview_single_platform(self, agent_id: int, prompt: str, platform: str) -> Dict[str, Any]:
273
+ """
274
+ 在单个平台上执行Interview
275
+
276
+ Returns:
277
+ 包含结果的字典,或包含error的字典
278
+ """
279
+ env, agent_graph, actual_platform = self._get_env_and_graph(platform)
280
+
281
+ if not env or not agent_graph:
282
+ return {"platform": platform, "error": f"{platform}平台不可用"}
283
+
284
+ try:
285
+ agent = agent_graph.get_agent(agent_id)
286
+ interview_action = ManualAction(
287
+ action_type=ActionType.INTERVIEW,
288
+ action_args={"prompt": prompt}
289
+ )
290
+ actions = {agent: interview_action}
291
+ await env.step(actions)
292
+
293
+ result = self._get_interview_result(agent_id, actual_platform)
294
+ result["platform"] = actual_platform
295
+ return result
296
+
297
+ except Exception as e:
298
+ return {"platform": platform, "error": str(e)}
299
+
300
+ async def handle_interview(self, command_id: str, agent_id: int, prompt: str, platform: str = None) -> bool:
301
+ """
302
+ 处理单个Agent采访命令
303
+
304
+ Args:
305
+ command_id: 命令ID
306
+ agent_id: Agent ID
307
+ prompt: 采访问题
308
+ platform: 指定平台(可选)
309
+ - "twitter": 只采访Twitter平台
310
+ - "reddit": 只采访Reddit平台
311
+ - None/不指定: 同时采访两个平台,返回整合结果
312
+
313
+ Returns:
314
+ True 表示成功,False 表示失败
315
+ """
316
+ # 如果指定了平台,只采访该平台
317
+ if platform in ("twitter", "reddit"):
318
+ result = await self._interview_single_platform(agent_id, prompt, platform)
319
+
320
+ if "error" in result:
321
+ self.send_response(command_id, "failed", error=result["error"])
322
+ print(f" Interview失败: agent_id={agent_id}, platform={platform}, error={result['error']}")
323
+ return False
324
+ else:
325
+ self.send_response(command_id, "completed", result=result)
326
+ print(f" Interview完成: agent_id={agent_id}, platform={platform}")
327
+ return True
328
+
329
+ # 未指定平台:同时采访两个平台
330
+ if not self.twitter_env and not self.reddit_env:
331
+ self.send_response(command_id, "failed", error="没有可用的模拟环境")
332
+ return False
333
+
334
+ results = {
335
+ "agent_id": agent_id,
336
+ "prompt": prompt,
337
+ "platforms": {}
338
+ }
339
+ success_count = 0
340
+
341
+ # 并行采访两个平台
342
+ tasks = []
343
+ platforms_to_interview = []
344
+
345
+ if self.twitter_env:
346
+ tasks.append(self._interview_single_platform(agent_id, prompt, "twitter"))
347
+ platforms_to_interview.append("twitter")
348
+
349
+ if self.reddit_env:
350
+ tasks.append(self._interview_single_platform(agent_id, prompt, "reddit"))
351
+ platforms_to_interview.append("reddit")
352
+
353
+ # 并行执行
354
+ platform_results = await asyncio.gather(*tasks)
355
+
356
+ for platform_name, platform_result in zip(platforms_to_interview, platform_results):
357
+ results["platforms"][platform_name] = platform_result
358
+ if "error" not in platform_result:
359
+ success_count += 1
360
+
361
+ if success_count > 0:
362
+ self.send_response(command_id, "completed", result=results)
363
+ print(f" Interview完成: agent_id={agent_id}, 成功平台数={success_count}/{len(platforms_to_interview)}")
364
+ return True
365
+ else:
366
+ errors = [f"{p}: {r.get('error', '未知错误')}" for p, r in results["platforms"].items()]
367
+ self.send_response(command_id, "failed", error="; ".join(errors))
368
+ print(f" Interview失败: agent_id={agent_id}, 所有平台都失败")
369
+ return False
370
+
371
+ async def handle_batch_interview(self, command_id: str, interviews: List[Dict], platform: str = None) -> bool:
372
+ """
373
+ 处理批量采访命令
374
+
375
+ Args:
376
+ command_id: 命令ID
377
+ interviews: [{"agent_id": int, "prompt": str, "platform": str(optional)}, ...]
378
+ platform: 默认平台(可被每个interview项覆盖)
379
+ - "twitter": 只采访Twitter平台
380
+ - "reddit": 只采访Reddit平台
381
+ - None/不指定: 每个Agent同时采访两个平台
382
+ """
383
+ # 按平台分组
384
+ twitter_interviews = []
385
+ reddit_interviews = []
386
+ both_platforms_interviews = [] # 需要同时采访两个平台的
387
+
388
+ for interview in interviews:
389
+ item_platform = interview.get("platform", platform)
390
+ if item_platform == "twitter":
391
+ twitter_interviews.append(interview)
392
+ elif item_platform == "reddit":
393
+ reddit_interviews.append(interview)
394
+ else:
395
+ # 未指定平台:两个平台都采访
396
+ both_platforms_interviews.append(interview)
397
+
398
+ # 把 both_platforms_interviews 拆分到两个平台
399
+ if both_platforms_interviews:
400
+ if self.twitter_env:
401
+ twitter_interviews.extend(both_platforms_interviews)
402
+ if self.reddit_env:
403
+ reddit_interviews.extend(both_platforms_interviews)
404
+
405
+ results = {}
406
+
407
+ # 处理Twitter平台的采访
408
+ if twitter_interviews and self.twitter_env:
409
+ try:
410
+ twitter_actions = {}
411
+ for interview in twitter_interviews:
412
+ agent_id = interview.get("agent_id")
413
+ prompt = interview.get("prompt", "")
414
+ try:
415
+ agent = self.twitter_agent_graph.get_agent(agent_id)
416
+ twitter_actions[agent] = ManualAction(
417
+ action_type=ActionType.INTERVIEW,
418
+ action_args={"prompt": prompt}
419
+ )
420
+ except Exception as e:
421
+ print(f" 警告: 无法获取Twitter Agent {agent_id}: {e}")
422
+
423
+ if twitter_actions:
424
+ await self.twitter_env.step(twitter_actions)
425
+
426
+ for interview in twitter_interviews:
427
+ agent_id = interview.get("agent_id")
428
+ result = self._get_interview_result(agent_id, "twitter")
429
+ result["platform"] = "twitter"
430
+ results[f"twitter_{agent_id}"] = result
431
+ except Exception as e:
432
+ print(f" Twitter批量Interview失败: {e}")
433
+
434
+ # 处理Reddit平台的采访
435
+ if reddit_interviews and self.reddit_env:
436
+ try:
437
+ reddit_actions = {}
438
+ for interview in reddit_interviews:
439
+ agent_id = interview.get("agent_id")
440
+ prompt = interview.get("prompt", "")
441
+ try:
442
+ agent = self.reddit_agent_graph.get_agent(agent_id)
443
+ reddit_actions[agent] = ManualAction(
444
+ action_type=ActionType.INTERVIEW,
445
+ action_args={"prompt": prompt}
446
+ )
447
+ except Exception as e:
448
+ print(f" 警告: 无法获取Reddit Agent {agent_id}: {e}")
449
+
450
+ if reddit_actions:
451
+ await self.reddit_env.step(reddit_actions)
452
+
453
+ for interview in reddit_interviews:
454
+ agent_id = interview.get("agent_id")
455
+ result = self._get_interview_result(agent_id, "reddit")
456
+ result["platform"] = "reddit"
457
+ results[f"reddit_{agent_id}"] = result
458
+ except Exception as e:
459
+ print(f" Reddit批量Interview失败: {e}")
460
+
461
+ if results:
462
+ self.send_response(command_id, "completed", result={
463
+ "interviews_count": len(results),
464
+ "results": results
465
+ })
466
+ print(f" 批量Interview完成: {len(results)} 个Agent")
467
+ return True
468
+ else:
469
+ self.send_response(command_id, "failed", error="没有成功的采访")
470
+ return False
471
+
472
+ def _get_interview_result(self, agent_id: int, platform: str) -> Dict[str, Any]:
473
+ """从数据库获取最新的Interview结果"""
474
+ db_path = os.path.join(self.simulation_dir, f"{platform}_simulation.db")
475
+
476
+ result = {
477
+ "agent_id": agent_id,
478
+ "response": None,
479
+ "timestamp": None
480
+ }
481
+
482
+ if not os.path.exists(db_path):
483
+ return result
484
+
485
+ try:
486
+ conn = sqlite3.connect(db_path)
487
+ cursor = conn.cursor()
488
+
489
+ # 查询最新的Interview记录
490
+ cursor.execute("""
491
+ SELECT user_id, info, created_at
492
+ FROM trace
493
+ WHERE action = ? AND user_id = ?
494
+ ORDER BY created_at DESC
495
+ LIMIT 1
496
+ """, (ActionType.INTERVIEW.value, agent_id))
497
+
498
+ row = cursor.fetchone()
499
+ if row:
500
+ user_id, info_json, created_at = row
501
+ try:
502
+ info = json.loads(info_json) if info_json else {}
503
+ result["response"] = info.get("response", info)
504
+ result["timestamp"] = created_at
505
+ except json.JSONDecodeError:
506
+ result["response"] = info_json
507
+
508
+ conn.close()
509
+
510
+ except Exception as e:
511
+ print(f" 读取Interview结果失败: {e}")
512
+
513
+ return result
514
+
515
+ async def process_commands(self) -> bool:
516
+ """
517
+ 处理所有待处理命令
518
+
519
+ Returns:
520
+ True 表示继续运行,False 表示应该退出
521
+ """
522
+ command = self.poll_command()
523
+ if not command:
524
+ return True
525
+
526
+ command_id = command.get("command_id")
527
+ command_type = command.get("command_type")
528
+ args = command.get("args", {})
529
+
530
+ print(f"\n收到IPC命令: {command_type}, id={command_id}")
531
+
532
+ if command_type == CommandType.INTERVIEW:
533
+ await self.handle_interview(
534
+ command_id,
535
+ args.get("agent_id", 0),
536
+ args.get("prompt", ""),
537
+ args.get("platform")
538
+ )
539
+ return True
540
+
541
+ elif command_type == CommandType.BATCH_INTERVIEW:
542
+ await self.handle_batch_interview(
543
+ command_id,
544
+ args.get("interviews", []),
545
+ args.get("platform")
546
+ )
547
+ return True
548
+
549
+ elif command_type == CommandType.CLOSE_ENV:
550
+ print("收到关闭环境命令")
551
+ self.send_response(command_id, "completed", result={"message": "环境即将关闭"})
552
+ return False
553
+
554
+ else:
555
+ self.send_response(command_id, "failed", error=f"未知命令类型: {command_type}")
556
+ return True
557
+
558
+
559
  def load_config(config_path: str) -> Dict[str, Any]:
560
  """加载配置文件"""
561
  with open(config_path, 'r', encoding='utf-8') as f:
 
807
  return active_agents
808
 
809
 
810
+ class PlatformSimulation:
811
+ """平台模拟结果容器"""
812
+ def __init__(self):
813
+ self.env = None
814
+ self.agent_graph = None
815
+ self.total_actions = 0
816
+
817
+
818
  async def run_twitter_simulation(
819
  config: Dict[str, Any],
820
  simulation_dir: str,
821
  action_logger: Optional[PlatformActionLogger] = None,
822
  main_logger: Optional[SimulationLogManager] = None,
823
  max_rounds: Optional[int] = None
824
+ ) -> PlatformSimulation:
825
  """运行Twitter模拟
826
 
827
  Args:
 
830
  action_logger: 动作日志记录器
831
  main_logger: 主日志管理器
832
  max_rounds: 最大模拟轮数(可选,用于截断过长的模拟)
833
+
834
+ Returns:
835
+ PlatformSimulation: 包含env和agent_graph的结果对象
836
  """
837
+ result = PlatformSimulation()
838
+
839
  def log_info(msg):
840
  if main_logger:
841
  main_logger.info(f"[Twitter] {msg}")
 
850
  profile_path = os.path.join(simulation_dir, "twitter_profiles.csv")
851
  if not os.path.exists(profile_path):
852
  log_info(f"错误: Profile文件不存在: {profile_path}")
853
+ return result
854
 
855
+ result.agent_graph = await generate_twitter_agent_graph(
856
  profile_path=profile_path,
857
  model=model,
858
  available_actions=TWITTER_ACTIONS,
 
861
  # 从配置文件获取 Agent 真实名称映射(使用 entity_name 而非默认的 Agent_X)
862
  agent_names = get_agent_names_from_config(config)
863
  # 如果配置中没有某个 agent,则使用 OASIS 的默认名称
864
+ for agent_id, agent in result.agent_graph.get_agents():
865
  if agent_id not in agent_names:
866
  agent_names[agent_id] = getattr(agent, 'name', f'Agent_{agent_id}')
867
 
 
869
  if os.path.exists(db_path):
870
  os.remove(db_path)
871
 
872
+ result.env = oasis.make(
873
+ agent_graph=result.agent_graph,
874
  platform=oasis.DefaultPlatformType.TWITTER,
875
  database_path=db_path,
876
  semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
877
  )
878
 
879
+ await result.env.reset()
880
  log_info("环境已启动")
881
 
882
  if action_logger:
 
900
  agent_id = post.get("poster_agent_id", 0)
901
  content = post.get("content", "")
902
  try:
903
+ agent = result.env.agent_graph.get_agent(agent_id)
904
  initial_actions[agent] = ManualAction(
905
  action_type=ActionType.CREATE_POST,
906
  action_args={"content": content}
 
920
  pass
921
 
922
  if initial_actions:
923
+ await result.env.step(initial_actions)
924
  log_info(f"已发布 {len(initial_actions)} 条初始帖子")
925
 
926
  # 记录 round 0 结束
 
948
  simulated_day = simulated_minutes // (60 * 24) + 1
949
 
950
  active_agents = get_active_agents_for_round(
951
+ result.env, config, simulated_hour, round_num
952
  )
953
 
954
  # 无论是否有活跃agent,都记录round开始
 
962
  continue
963
 
964
  actions = {agent: LLMAction() for _, agent in active_agents}
965
+ await result.env.step(actions)
966
 
967
  # 从数据库获取实际执行的动作并记录
968
  actual_actions, last_rowid = fetch_new_actions_from_db(
 
989
  progress = (round_num + 1) / total_rounds * 100
990
  log_info(f"Day {simulated_day}, {simulated_hour:02d}:00 - Round {round_num + 1}/{total_rounds} ({progress:.1f}%)")
991
 
992
+ # 注意:不关闭环境,保留给Interview使用
993
 
994
  if action_logger:
995
  action_logger.log_simulation_end(total_rounds, total_actions)
996
 
997
+ result.total_actions = total_actions
998
  elapsed = (datetime.now() - start_time).total_seconds()
999
+ log_info(f"模拟循环完成! 耗时: {elapsed:.1f}秒, 总动作: {total_actions}")
1000
+
1001
+ return result
1002
 
1003
 
1004
  async def run_reddit_simulation(
 
1007
  action_logger: Optional[PlatformActionLogger] = None,
1008
  main_logger: Optional[SimulationLogManager] = None,
1009
  max_rounds: Optional[int] = None
1010
+ ) -> PlatformSimulation:
1011
  """运行Reddit模拟
1012
 
1013
  Args:
 
1016
  action_logger: 动作日志记录器
1017
  main_logger: 主日志管理器
1018
  max_rounds: 最大模拟轮数(可选,用于截断过长的模拟)
1019
+
1020
+ Returns:
1021
+ PlatformSimulation: 包含env和agent_graph的结果对象
1022
  """
1023
+ result = PlatformSimulation()
1024
+
1025
  def log_info(msg):
1026
  if main_logger:
1027
  main_logger.info(f"[Reddit] {msg}")
 
1035
  profile_path = os.path.join(simulation_dir, "reddit_profiles.json")
1036
  if not os.path.exists(profile_path):
1037
  log_info(f"错误: Profile文件不存在: {profile_path}")
1038
+ return result
1039
 
1040
+ result.agent_graph = await generate_reddit_agent_graph(
1041
  profile_path=profile_path,
1042
  model=model,
1043
  available_actions=REDDIT_ACTIONS,
 
1046
  # 从配置文件获取 Agent 真实名称映射(使用 entity_name 而非默认的 Agent_X)
1047
  agent_names = get_agent_names_from_config(config)
1048
  # 如果配置中没有某个 agent,则使用 OASIS 的默认名称
1049
+ for agent_id, agent in result.agent_graph.get_agents():
1050
  if agent_id not in agent_names:
1051
  agent_names[agent_id] = getattr(agent, 'name', f'Agent_{agent_id}')
1052
 
 
1054
  if os.path.exists(db_path):
1055
  os.remove(db_path)
1056
 
1057
+ result.env = oasis.make(
1058
+ agent_graph=result.agent_graph,
1059
  platform=oasis.DefaultPlatformType.REDDIT,
1060
  database_path=db_path,
1061
  semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
1062
  )
1063
 
1064
+ await result.env.reset()
1065
  log_info("环境已启动")
1066
 
1067
  if action_logger:
 
1085
  agent_id = post.get("poster_agent_id", 0)
1086
  content = post.get("content", "")
1087
  try:
1088
+ agent = result.env.agent_graph.get_agent(agent_id)
1089
  if agent in initial_actions:
1090
  if not isinstance(initial_actions[agent], list):
1091
  initial_actions[agent] = [initial_actions[agent]]
 
1113
  pass
1114
 
1115
  if initial_actions:
1116
+ await result.env.step(initial_actions)
1117
  log_info(f"已发布 {len(initial_actions)} 条初始帖子")
1118
 
1119
  # 记录 round 0 结束
 
1141
  simulated_day = simulated_minutes // (60 * 24) + 1
1142
 
1143
  active_agents = get_active_agents_for_round(
1144
+ result.env, config, simulated_hour, round_num
1145
  )
1146
 
1147
  # 无论是否有活跃agent,都记录round开始
 
1155
  continue
1156
 
1157
  actions = {agent: LLMAction() for _, agent in active_agents}
1158
+ await result.env.step(actions)
1159
 
1160
  # 从数据库获取实际执行的动作并记录
1161
  actual_actions, last_rowid = fetch_new_actions_from_db(
 
1182
  progress = (round_num + 1) / total_rounds * 100
1183
  log_info(f"Day {simulated_day}, {simulated_hour:02d}:00 - Round {round_num + 1}/{total_rounds} ({progress:.1f}%)")
1184
 
1185
+ # 注意:不关闭环境,保留给Interview使用
1186
 
1187
  if action_logger:
1188
  action_logger.log_simulation_end(total_rounds, total_actions)
1189
 
1190
+ result.total_actions = total_actions
1191
  elapsed = (datetime.now() - start_time).total_seconds()
1192
+ log_info(f"模拟循环完成! 耗时: {elapsed:.1f}秒, 总动作: {total_actions}")
1193
+
1194
+ return result
1195
 
1196
 
1197
  async def main():
 
1218
  default=None,
1219
  help='最大模拟轮数(可选,用于截断过长的模拟)'
1220
  )
1221
+ parser.add_argument(
1222
+ '--no-wait',
1223
+ action='store_true',
1224
+ default=False,
1225
+ help='模拟完成后立即关闭环境,不进入等待命令模式'
1226
+ )
1227
 
1228
  args = parser.parse_args()
1229
 
 
1233
 
1234
  config = load_config(args.config)
1235
  simulation_dir = os.path.dirname(args.config) or "."
1236
+ wait_for_commands = not args.no_wait
1237
 
1238
  # 初始化日志配置(禁用 OASIS 日志,清理旧文件)
1239
  init_logging_for_simulation(simulation_dir)
 
1247
  log_manager.info("OASIS 双平台并行模拟")
1248
  log_manager.info(f"配置文件: {args.config}")
1249
  log_manager.info(f"模拟ID: {config.get('simulation_id', 'unknown')}")
1250
+ log_manager.info(f"等待命令模式: {'启用' if wait_for_commands else '禁用'}")
1251
  log_manager.info("=" * 60)
1252
 
1253
  time_config = config.get("time_config", {})
 
1273
 
1274
  start_time = datetime.now()
1275
 
1276
+ # 存储两个平台的模拟结果
1277
+ twitter_result: Optional[PlatformSimulation] = None
1278
+ reddit_result: Optional[PlatformSimulation] = None
1279
+
1280
  if args.twitter_only:
1281
+ twitter_result = await run_twitter_simulation(config, simulation_dir, twitter_logger, log_manager, args.max_rounds)
1282
  elif args.reddit_only:
1283
+ reddit_result = await run_reddit_simulation(config, simulation_dir, reddit_logger, log_manager, args.max_rounds)
1284
  else:
1285
  # 并行运行(每个平台使用独立的日志记录器)
1286
+ results = await asyncio.gather(
1287
  run_twitter_simulation(config, simulation_dir, twitter_logger, log_manager, args.max_rounds),
1288
  run_reddit_simulation(config, simulation_dir, reddit_logger, log_manager, args.max_rounds),
1289
  )
1290
+ twitter_result, reddit_result = results
1291
 
1292
  total_elapsed = (datetime.now() - start_time).total_seconds()
1293
  log_manager.info("=" * 60)
1294
+ log_manager.info(f"模拟循环完成! 总耗时: {total_elapsed:.1f}秒")
1295
+
1296
+ # 是否进入等待命令模式
1297
+ if wait_for_commands:
1298
+ log_manager.info("")
1299
+ log_manager.info("=" * 60)
1300
+ log_manager.info("进入等待命令模式 - 环境保持运行")
1301
+ log_manager.info("支持的命令: interview, batch_interview, close_env")
1302
+ log_manager.info("=" * 60)
1303
+
1304
+ # 创建IPC处理器
1305
+ ipc_handler = ParallelIPCHandler(
1306
+ simulation_dir=simulation_dir,
1307
+ twitter_env=twitter_result.env if twitter_result else None,
1308
+ twitter_agent_graph=twitter_result.agent_graph if twitter_result else None,
1309
+ reddit_env=reddit_result.env if reddit_result else None,
1310
+ reddit_agent_graph=reddit_result.agent_graph if reddit_result else None
1311
+ )
1312
+ ipc_handler.update_status("alive")
1313
+
1314
+ # 等待命令循环
1315
+ try:
1316
+ while True:
1317
+ should_continue = await ipc_handler.process_commands()
1318
+ if not should_continue:
1319
+ break
1320
+ await asyncio.sleep(0.5) # 轮询间隔
1321
+ except KeyboardInterrupt:
1322
+ print("\n收到中断信号")
1323
+ except Exception as e:
1324
+ print(f"\n命令处理出错: {e}")
1325
+
1326
+ log_manager.info("\n关闭环境...")
1327
+ ipc_handler.update_status("stopped")
1328
+
1329
+ # 关闭环境
1330
+ if twitter_result and twitter_result.env:
1331
+ await twitter_result.env.close()
1332
+ log_manager.info("[Twitter] 环境已关闭")
1333
+
1334
+ if reddit_result and reddit_result.env:
1335
+ await reddit_result.env.close()
1336
+ log_manager.info("[Reddit] 环境已关闭")
1337
+
1338
+ log_manager.info("=" * 60)
1339
+ log_manager.info(f"全部完成!")
1340
  log_manager.info(f"日志文件:")
1341
  log_manager.info(f" - {os.path.join(simulation_dir, 'simulation.log')}")
1342
  log_manager.info(f" - {os.path.join(simulation_dir, 'twitter', 'actions.jsonl')}")
 
1346
 
1347
  if __name__ == "__main__":
1348
  asyncio.run(main())
 
backend/scripts/run_reddit_simulation.py CHANGED
@@ -2,8 +2,15 @@
2
  OASIS Reddit模拟预设脚本
3
  此脚本读取配置文件中的参数来执行模拟,实现全程自动化
4
 
 
 
 
 
 
 
5
  使用方式:
6
  python run_reddit_simulation.py --config /path/to/simulation_config.json
 
7
  """
8
 
9
  import argparse
@@ -13,8 +20,9 @@ import logging
13
  import os
14
  import random
15
  import sys
 
16
  from datetime import datetime
17
- from typing import Dict, Any, List
18
 
19
  # 添加项目路径
20
  _scripts_dir = os.path.dirname(os.path.abspath(__file__))
@@ -118,10 +126,261 @@ except ImportError as e:
118
  sys.exit(1)
119
 
120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  class RedditSimulationRunner:
122
  """Reddit模拟运行器"""
123
 
124
- # Reddit可用动作
125
  AVAILABLE_ACTIONS = [
126
  ActionType.LIKE_POST,
127
  ActionType.DISLIKE_POST,
@@ -138,16 +397,21 @@ class RedditSimulationRunner:
138
  ActionType.MUTE,
139
  ]
140
 
141
- def __init__(self, config_path: str):
142
  """
143
  初始化模拟运行器
144
 
145
  Args:
146
  config_path: 配置文件路径 (simulation_config.json)
 
147
  """
148
  self.config_path = config_path
149
  self.config = self._load_config()
150
  self.simulation_dir = os.path.dirname(config_path)
 
 
 
 
151
 
152
  def _load_config(self) -> Dict[str, Any]:
153
  """加载配置文件"""
@@ -261,6 +525,7 @@ class RedditSimulationRunner:
261
  print("OASIS Reddit模拟")
262
  print(f"配置文件: {self.config_path}")
263
  print(f"模拟ID: {self.config.get('simulation_id', 'unknown')}")
 
264
  print("=" * 60)
265
 
266
  time_config = self.config.get("time_config", {})
@@ -292,7 +557,7 @@ class RedditSimulationRunner:
292
  print(f"错误: Profile文件不存在: {profile_path}")
293
  return
294
 
295
- agent_graph = await generate_reddit_agent_graph(
296
  profile_path=profile_path,
297
  model=model,
298
  available_actions=self.AVAILABLE_ACTIONS,
@@ -304,16 +569,20 @@ class RedditSimulationRunner:
304
  print(f"已删除旧数据库: {db_path}")
305
 
306
  print("创建OASIS环境...")
307
- env = oasis.make(
308
- agent_graph=agent_graph,
309
  platform=oasis.DefaultPlatformType.REDDIT,
310
  database_path=db_path,
311
  semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
312
  )
313
 
314
- await env.reset()
315
  print("环境初始化完成\n")
316
 
 
 
 
 
317
  # 执行初始事件
318
  event_config = self.config.get("event_config", {})
319
  initial_posts = event_config.get("initial_posts", [])
@@ -325,7 +594,7 @@ class RedditSimulationRunner:
325
  agent_id = post.get("poster_agent_id", 0)
326
  content = post.get("content", "")
327
  try:
328
- agent = env.agent_graph.get_agent(agent_id)
329
  if agent in initial_actions:
330
  if not isinstance(initial_actions[agent], list):
331
  initial_actions[agent] = [initial_actions[agent]]
@@ -342,7 +611,7 @@ class RedditSimulationRunner:
342
  print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}")
343
 
344
  if initial_actions:
345
- await env.step(initial_actions)
346
  print(f" 已发布 {len(initial_actions)} 条初始帖子")
347
 
348
  # 主模拟循环
@@ -355,7 +624,7 @@ class RedditSimulationRunner:
355
  simulated_day = simulated_minutes // (60 * 24) + 1
356
 
357
  active_agents = self._get_active_agents_for_round(
358
- env, simulated_hour, round_num
359
  )
360
 
361
  if not active_agents:
@@ -366,7 +635,7 @@ class RedditSimulationRunner:
366
  for _, agent in active_agents
367
  }
368
 
369
- await env.step(actions)
370
 
371
  if (round_num + 1) % 10 == 0 or round_num == 0:
372
  elapsed = (datetime.now() - start_time).total_seconds()
@@ -376,12 +645,39 @@ class RedditSimulationRunner:
376
  f"- {len(active_agents)} agents active "
377
  f"- elapsed: {elapsed:.1f}s")
378
 
379
- await env.close()
380
-
381
  total_elapsed = (datetime.now() - start_time).total_seconds()
382
- print(f"\n模拟完成!")
383
  print(f" - 总耗时: {total_elapsed:.1f}秒")
384
  print(f" - 数据库: {db_path}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
  print("=" * 60)
386
 
387
 
@@ -399,6 +695,12 @@ async def main():
399
  default=None,
400
  help='最大模拟轮数(可选,用于截断过长的模拟)'
401
  )
 
 
 
 
 
 
402
 
403
  args = parser.parse_args()
404
 
@@ -410,7 +712,10 @@ async def main():
410
  simulation_dir = os.path.dirname(args.config) or "."
411
  setup_oasis_logging(os.path.join(simulation_dir, "log"))
412
 
413
- runner = RedditSimulationRunner(args.config)
 
 
 
414
  await runner.run(max_rounds=args.max_rounds)
415
 
416
 
 
2
  OASIS Reddit模拟预设脚本
3
  此脚本读取配置文件中的参数来执行模拟,实现全程自动化
4
 
5
+ 功能特性:
6
+ - 完成模拟后不立即关闭环境,进入等待命令模式
7
+ - 支持通过IPC接收Interview命令
8
+ - 支持单个Agent采访和批量采访
9
+ - 支持远程关闭环境命令
10
+
11
  使用方式:
12
  python run_reddit_simulation.py --config /path/to/simulation_config.json
13
+ python run_reddit_simulation.py --config /path/to/simulation_config.json --no-wait # 完成后立即关闭
14
  """
15
 
16
  import argparse
 
20
  import os
21
  import random
22
  import sys
23
+ import sqlite3
24
  from datetime import datetime
25
+ from typing import Dict, Any, List, Optional
26
 
27
  # 添加项目路径
28
  _scripts_dir = os.path.dirname(os.path.abspath(__file__))
 
126
  sys.exit(1)
127
 
128
 
129
+ # IPC相关常量
130
+ IPC_COMMANDS_DIR = "ipc_commands"
131
+ IPC_RESPONSES_DIR = "ipc_responses"
132
+ ENV_STATUS_FILE = "env_status.json"
133
+
134
+ class CommandType:
135
+ """命令类型常量"""
136
+ INTERVIEW = "interview"
137
+ BATCH_INTERVIEW = "batch_interview"
138
+ CLOSE_ENV = "close_env"
139
+
140
+
141
+ class IPCHandler:
142
+ """IPC命令处理器"""
143
+
144
+ def __init__(self, simulation_dir: str, env, agent_graph):
145
+ self.simulation_dir = simulation_dir
146
+ self.env = env
147
+ self.agent_graph = agent_graph
148
+ self.commands_dir = os.path.join(simulation_dir, IPC_COMMANDS_DIR)
149
+ self.responses_dir = os.path.join(simulation_dir, IPC_RESPONSES_DIR)
150
+ self.status_file = os.path.join(simulation_dir, ENV_STATUS_FILE)
151
+ self._running = True
152
+
153
+ # 确保目录存在
154
+ os.makedirs(self.commands_dir, exist_ok=True)
155
+ os.makedirs(self.responses_dir, exist_ok=True)
156
+
157
+ def update_status(self, status: str):
158
+ """更新环境状态"""
159
+ with open(self.status_file, 'w', encoding='utf-8') as f:
160
+ json.dump({
161
+ "status": status,
162
+ "timestamp": datetime.now().isoformat()
163
+ }, f, ensure_ascii=False, indent=2)
164
+
165
+ def poll_command(self) -> Optional[Dict[str, Any]]:
166
+ """轮询获取待处理命令"""
167
+ if not os.path.exists(self.commands_dir):
168
+ return None
169
+
170
+ # 获取命令文件(按时间排序)
171
+ command_files = []
172
+ for filename in os.listdir(self.commands_dir):
173
+ if filename.endswith('.json'):
174
+ filepath = os.path.join(self.commands_dir, filename)
175
+ command_files.append((filepath, os.path.getmtime(filepath)))
176
+
177
+ command_files.sort(key=lambda x: x[1])
178
+
179
+ for filepath, _ in command_files:
180
+ try:
181
+ with open(filepath, 'r', encoding='utf-8') as f:
182
+ return json.load(f)
183
+ except (json.JSONDecodeError, OSError):
184
+ continue
185
+
186
+ return None
187
+
188
+ def send_response(self, command_id: str, status: str, result: Dict = None, error: str = None):
189
+ """发送响应"""
190
+ response = {
191
+ "command_id": command_id,
192
+ "status": status,
193
+ "result": result,
194
+ "error": error,
195
+ "timestamp": datetime.now().isoformat()
196
+ }
197
+
198
+ response_file = os.path.join(self.responses_dir, f"{command_id}.json")
199
+ with open(response_file, 'w', encoding='utf-8') as f:
200
+ json.dump(response, f, ensure_ascii=False, indent=2)
201
+
202
+ # 删除命令文件
203
+ command_file = os.path.join(self.commands_dir, f"{command_id}.json")
204
+ try:
205
+ os.remove(command_file)
206
+ except OSError:
207
+ pass
208
+
209
+ async def handle_interview(self, command_id: str, agent_id: int, prompt: str) -> bool:
210
+ """
211
+ 处理单个Agent采访命令
212
+
213
+ Returns:
214
+ True 表示成功,False 表示失败
215
+ """
216
+ try:
217
+ # 获取Agent
218
+ agent = self.agent_graph.get_agent(agent_id)
219
+
220
+ # 创建Interview动作
221
+ interview_action = ManualAction(
222
+ action_type=ActionType.INTERVIEW,
223
+ action_args={"prompt": prompt}
224
+ )
225
+
226
+ # 执行Interview
227
+ actions = {agent: interview_action}
228
+ await self.env.step(actions)
229
+
230
+ # 从数据库获取结果
231
+ result = self._get_interview_result(agent_id)
232
+
233
+ self.send_response(command_id, "completed", result=result)
234
+ print(f" Interview完成: agent_id={agent_id}")
235
+ return True
236
+
237
+ except Exception as e:
238
+ error_msg = str(e)
239
+ print(f" Interview失败: agent_id={agent_id}, error={error_msg}")
240
+ self.send_response(command_id, "failed", error=error_msg)
241
+ return False
242
+
243
+ async def handle_batch_interview(self, command_id: str, interviews: List[Dict]) -> bool:
244
+ """
245
+ 处理批量采访命令
246
+
247
+ Args:
248
+ interviews: [{"agent_id": int, "prompt": str}, ...]
249
+ """
250
+ try:
251
+ # 构建动作字典
252
+ actions = {}
253
+ agent_prompts = {} # 记录每个agent的prompt
254
+
255
+ for interview in interviews:
256
+ agent_id = interview.get("agent_id")
257
+ prompt = interview.get("prompt", "")
258
+
259
+ try:
260
+ agent = self.agent_graph.get_agent(agent_id)
261
+ actions[agent] = ManualAction(
262
+ action_type=ActionType.INTERVIEW,
263
+ action_args={"prompt": prompt}
264
+ )
265
+ agent_prompts[agent_id] = prompt
266
+ except Exception as e:
267
+ print(f" 警告: 无法获取Agent {agent_id}: {e}")
268
+
269
+ if not actions:
270
+ self.send_response(command_id, "failed", error="没有有效的Agent")
271
+ return False
272
+
273
+ # 执行批量Interview
274
+ await self.env.step(actions)
275
+
276
+ # 获取所有结果
277
+ results = {}
278
+ for agent_id in agent_prompts.keys():
279
+ result = self._get_interview_result(agent_id)
280
+ results[agent_id] = result
281
+
282
+ self.send_response(command_id, "completed", result={
283
+ "interviews_count": len(results),
284
+ "results": results
285
+ })
286
+ print(f" 批量Interview完成: {len(results)} 个Agent")
287
+ return True
288
+
289
+ except Exception as e:
290
+ error_msg = str(e)
291
+ print(f" 批量Interview失败: {error_msg}")
292
+ self.send_response(command_id, "failed", error=error_msg)
293
+ return False
294
+
295
+ def _get_interview_result(self, agent_id: int) -> Dict[str, Any]:
296
+ """从数据库获取最新的Interview结果"""
297
+ db_path = os.path.join(self.simulation_dir, "reddit_simulation.db")
298
+
299
+ result = {
300
+ "agent_id": agent_id,
301
+ "response": None,
302
+ "timestamp": None
303
+ }
304
+
305
+ if not os.path.exists(db_path):
306
+ return result
307
+
308
+ try:
309
+ conn = sqlite3.connect(db_path)
310
+ cursor = conn.cursor()
311
+
312
+ # 查询最新的Interview记录
313
+ cursor.execute("""
314
+ SELECT user_id, info, created_at
315
+ FROM trace
316
+ WHERE action = ? AND user_id = ?
317
+ ORDER BY created_at DESC
318
+ LIMIT 1
319
+ """, (ActionType.INTERVIEW.value, agent_id))
320
+
321
+ row = cursor.fetchone()
322
+ if row:
323
+ user_id, info_json, created_at = row
324
+ try:
325
+ info = json.loads(info_json) if info_json else {}
326
+ result["response"] = info.get("response", info)
327
+ result["timestamp"] = created_at
328
+ except json.JSONDecodeError:
329
+ result["response"] = info_json
330
+
331
+ conn.close()
332
+
333
+ except Exception as e:
334
+ print(f" 读取Interview结果失败: {e}")
335
+
336
+ return result
337
+
338
+ async def process_commands(self) -> bool:
339
+ """
340
+ 处理所有待处理命令
341
+
342
+ Returns:
343
+ True 表示继续运行,False 表示应该退出
344
+ """
345
+ command = self.poll_command()
346
+ if not command:
347
+ return True
348
+
349
+ command_id = command.get("command_id")
350
+ command_type = command.get("command_type")
351
+ args = command.get("args", {})
352
+
353
+ print(f"\n收到IPC命令: {command_type}, id={command_id}")
354
+
355
+ if command_type == CommandType.INTERVIEW:
356
+ await self.handle_interview(
357
+ command_id,
358
+ args.get("agent_id", 0),
359
+ args.get("prompt", "")
360
+ )
361
+ return True
362
+
363
+ elif command_type == CommandType.BATCH_INTERVIEW:
364
+ await self.handle_batch_interview(
365
+ command_id,
366
+ args.get("interviews", [])
367
+ )
368
+ return True
369
+
370
+ elif command_type == CommandType.CLOSE_ENV:
371
+ print("收到关闭环境命令")
372
+ self.send_response(command_id, "completed", result={"message": "环境即将关闭"})
373
+ return False
374
+
375
+ else:
376
+ self.send_response(command_id, "failed", error=f"未知命令类型: {command_type}")
377
+ return True
378
+
379
+
380
  class RedditSimulationRunner:
381
  """Reddit模拟运行器"""
382
 
383
+ # Reddit可用动作(不包含INTERVIEW,INTERVIEW只能通过ManualAction手动触发)
384
  AVAILABLE_ACTIONS = [
385
  ActionType.LIKE_POST,
386
  ActionType.DISLIKE_POST,
 
397
  ActionType.MUTE,
398
  ]
399
 
400
+ def __init__(self, config_path: str, wait_for_commands: bool = True):
401
  """
402
  初始化模拟运行器
403
 
404
  Args:
405
  config_path: 配置文件路径 (simulation_config.json)
406
+ wait_for_commands: 模拟完成后是否等待命令(默认True)
407
  """
408
  self.config_path = config_path
409
  self.config = self._load_config()
410
  self.simulation_dir = os.path.dirname(config_path)
411
+ self.wait_for_commands = wait_for_commands
412
+ self.env = None
413
+ self.agent_graph = None
414
+ self.ipc_handler = None
415
 
416
  def _load_config(self) -> Dict[str, Any]:
417
  """加载配置文件"""
 
525
  print("OASIS Reddit模拟")
526
  print(f"配置文件: {self.config_path}")
527
  print(f"模拟ID: {self.config.get('simulation_id', 'unknown')}")
528
+ print(f"等待命令模式: {'启用' if self.wait_for_commands else '禁用'}")
529
  print("=" * 60)
530
 
531
  time_config = self.config.get("time_config", {})
 
557
  print(f"错误: Profile文件不存在: {profile_path}")
558
  return
559
 
560
+ self.agent_graph = await generate_reddit_agent_graph(
561
  profile_path=profile_path,
562
  model=model,
563
  available_actions=self.AVAILABLE_ACTIONS,
 
569
  print(f"已删除旧数据库: {db_path}")
570
 
571
  print("创建OASIS环境...")
572
+ self.env = oasis.make(
573
+ agent_graph=self.agent_graph,
574
  platform=oasis.DefaultPlatformType.REDDIT,
575
  database_path=db_path,
576
  semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
577
  )
578
 
579
+ await self.env.reset()
580
  print("环境初始化完成\n")
581
 
582
+ # 初始化IPC处理器
583
+ self.ipc_handler = IPCHandler(self.simulation_dir, self.env, self.agent_graph)
584
+ self.ipc_handler.update_status("running")
585
+
586
  # 执行初始事件
587
  event_config = self.config.get("event_config", {})
588
  initial_posts = event_config.get("initial_posts", [])
 
594
  agent_id = post.get("poster_agent_id", 0)
595
  content = post.get("content", "")
596
  try:
597
+ agent = self.env.agent_graph.get_agent(agent_id)
598
  if agent in initial_actions:
599
  if not isinstance(initial_actions[agent], list):
600
  initial_actions[agent] = [initial_actions[agent]]
 
611
  print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}")
612
 
613
  if initial_actions:
614
+ await self.env.step(initial_actions)
615
  print(f" 已发布 {len(initial_actions)} 条初始帖子")
616
 
617
  # 主模拟循环
 
624
  simulated_day = simulated_minutes // (60 * 24) + 1
625
 
626
  active_agents = self._get_active_agents_for_round(
627
+ self.env, simulated_hour, round_num
628
  )
629
 
630
  if not active_agents:
 
635
  for _, agent in active_agents
636
  }
637
 
638
+ await self.env.step(actions)
639
 
640
  if (round_num + 1) % 10 == 0 or round_num == 0:
641
  elapsed = (datetime.now() - start_time).total_seconds()
 
645
  f"- {len(active_agents)} agents active "
646
  f"- elapsed: {elapsed:.1f}s")
647
 
 
 
648
  total_elapsed = (datetime.now() - start_time).total_seconds()
649
+ print(f"\n模拟循环完成!")
650
  print(f" - 总耗时: {total_elapsed:.1f}秒")
651
  print(f" - 数据库: {db_path}")
652
+
653
+ # 是否进入等待命令模式
654
+ if self.wait_for_commands:
655
+ print("\n" + "=" * 60)
656
+ print("进入等待命令模式 - 环境保持运行")
657
+ print("支持的命令: interview, batch_interview, close_env")
658
+ print("=" * 60)
659
+
660
+ self.ipc_handler.update_status("alive")
661
+
662
+ # 等待命令循环
663
+ try:
664
+ while True:
665
+ should_continue = await self.ipc_handler.process_commands()
666
+ if not should_continue:
667
+ break
668
+ await asyncio.sleep(0.5) # 轮询间隔
669
+ except KeyboardInterrupt:
670
+ print("\n收到中断信号")
671
+ except Exception as e:
672
+ print(f"\n命令处理出错: {e}")
673
+
674
+ print("\n关闭环境...")
675
+
676
+ # 关闭环境
677
+ self.ipc_handler.update_status("stopped")
678
+ await self.env.close()
679
+
680
+ print("环境已关闭")
681
  print("=" * 60)
682
 
683
 
 
695
  default=None,
696
  help='最大模拟轮数(可选,用于截断过长的模拟)'
697
  )
698
+ parser.add_argument(
699
+ '--no-wait',
700
+ action='store_true',
701
+ default=False,
702
+ help='模拟完成后立即关闭环境,不进入等待命令模式'
703
+ )
704
 
705
  args = parser.parse_args()
706
 
 
712
  simulation_dir = os.path.dirname(args.config) or "."
713
  setup_oasis_logging(os.path.join(simulation_dir, "log"))
714
 
715
+ runner = RedditSimulationRunner(
716
+ config_path=args.config,
717
+ wait_for_commands=not args.no_wait
718
+ )
719
  await runner.run(max_rounds=args.max_rounds)
720
 
721
 
backend/scripts/run_twitter_simulation.py CHANGED
@@ -2,8 +2,15 @@
2
  OASIS Twitter模拟预设脚本
3
  此脚本读取配置文件中的参数来执行模拟,实现全程自动化
4
 
 
 
 
 
 
 
5
  使用方式:
6
  python run_twitter_simulation.py --config /path/to/simulation_config.json
 
7
  """
8
 
9
  import argparse
@@ -13,8 +20,9 @@ import logging
13
  import os
14
  import random
15
  import sys
 
16
  from datetime import datetime
17
- from typing import Dict, Any, List
18
 
19
  # 添加项目路径
20
  _scripts_dir = os.path.dirname(os.path.abspath(__file__))
@@ -118,10 +126,261 @@ except ImportError as e:
118
  sys.exit(1)
119
 
120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  class TwitterSimulationRunner:
122
  """Twitter模拟运行器"""
123
 
124
- # Twitter可用动作
125
  AVAILABLE_ACTIONS = [
126
  ActionType.CREATE_POST,
127
  ActionType.LIKE_POST,
@@ -131,16 +390,21 @@ class TwitterSimulationRunner:
131
  ActionType.QUOTE_POST,
132
  ]
133
 
134
- def __init__(self, config_path: str):
135
  """
136
  初始化模拟运行器
137
 
138
  Args:
139
  config_path: 配置文件路径 (simulation_config.json)
 
140
  """
141
  self.config_path = config_path
142
  self.config = self._load_config()
143
  self.simulation_dir = os.path.dirname(config_path)
 
 
 
 
144
 
145
  def _load_config(self) -> Dict[str, Any]:
146
  """加载配置文件"""
@@ -269,6 +533,7 @@ class TwitterSimulationRunner:
269
  print("OASIS Twitter模拟")
270
  print(f"配置文件: {self.config_path}")
271
  print(f"模拟ID: {self.config.get('simulation_id', 'unknown')}")
 
272
  print("=" * 60)
273
 
274
  # 加载时间配置
@@ -305,7 +570,7 @@ class TwitterSimulationRunner:
305
  print(f"错误: Profile文件不存在: {profile_path}")
306
  return
307
 
308
- agent_graph = await generate_twitter_agent_graph(
309
  profile_path=profile_path,
310
  model=model,
311
  available_actions=self.AVAILABLE_ACTIONS,
@@ -319,16 +584,20 @@ class TwitterSimulationRunner:
319
 
320
  # 创建环境
321
  print("创建OASIS环境...")
322
- env = oasis.make(
323
- agent_graph=agent_graph,
324
  platform=oasis.DefaultPlatformType.TWITTER,
325
  database_path=db_path,
326
  semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
327
  )
328
 
329
- await env.reset()
330
  print("环境初始化完成\n")
331
 
 
 
 
 
332
  # 执行初始事件
333
  event_config = self.config.get("event_config", {})
334
  initial_posts = event_config.get("initial_posts", [])
@@ -340,7 +609,7 @@ class TwitterSimulationRunner:
340
  agent_id = post.get("poster_agent_id", 0)
341
  content = post.get("content", "")
342
  try:
343
- agent = env.agent_graph.get_agent(agent_id)
344
  initial_actions[agent] = ManualAction(
345
  action_type=ActionType.CREATE_POST,
346
  action_args={"content": content}
@@ -349,7 +618,7 @@ class TwitterSimulationRunner:
349
  print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}")
350
 
351
  if initial_actions:
352
- await env.step(initial_actions)
353
  print(f" 已发布 {len(initial_actions)} 条初始帖子")
354
 
355
  # 主模拟循环
@@ -364,7 +633,7 @@ class TwitterSimulationRunner:
364
 
365
  # 获取本轮激活的Agent
366
  active_agents = self._get_active_agents_for_round(
367
- env, simulated_hour, round_num
368
  )
369
 
370
  if not active_agents:
@@ -377,7 +646,7 @@ class TwitterSimulationRunner:
377
  }
378
 
379
  # 执行动作
380
- await env.step(actions)
381
 
382
  # 打印进度
383
  if (round_num + 1) % 10 == 0 or round_num == 0:
@@ -388,13 +657,39 @@ class TwitterSimulationRunner:
388
  f"- {len(active_agents)} agents active "
389
  f"- elapsed: {elapsed:.1f}s")
390
 
391
- # 关闭环境
392
- await env.close()
393
-
394
  total_elapsed = (datetime.now() - start_time).total_seconds()
395
- print(f"\n模拟完成!")
396
  print(f" - 总耗时: {total_elapsed:.1f}秒")
397
  print(f" - 数据库: {db_path}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398
  print("=" * 60)
399
 
400
 
@@ -412,6 +707,12 @@ async def main():
412
  default=None,
413
  help='最大模拟轮数(可选,用于截断过长的模拟)'
414
  )
 
 
 
 
 
 
415
 
416
  args = parser.parse_args()
417
 
@@ -423,10 +724,12 @@ async def main():
423
  simulation_dir = os.path.dirname(args.config) or "."
424
  setup_oasis_logging(os.path.join(simulation_dir, "log"))
425
 
426
- runner = TwitterSimulationRunner(args.config)
 
 
 
427
  await runner.run(max_rounds=args.max_rounds)
428
 
429
 
430
  if __name__ == "__main__":
431
  asyncio.run(main())
432
-
 
2
  OASIS Twitter模拟预设脚本
3
  此脚本读取配置文件中的参数来执行模拟,实现全程自动化
4
 
5
+ 功能特性:
6
+ - 完成模拟后不立即关闭环境,进入等待命令模式
7
+ - 支持通过IPC接收Interview命令
8
+ - 支持单个Agent采访和批量采访
9
+ - 支持远程关闭环境命令
10
+
11
  使用方式:
12
  python run_twitter_simulation.py --config /path/to/simulation_config.json
13
+ python run_twitter_simulation.py --config /path/to/simulation_config.json --no-wait # 完成后立即关闭
14
  """
15
 
16
  import argparse
 
20
  import os
21
  import random
22
  import sys
23
+ import sqlite3
24
  from datetime import datetime
25
+ from typing import Dict, Any, List, Optional
26
 
27
  # 添加项目路径
28
  _scripts_dir = os.path.dirname(os.path.abspath(__file__))
 
126
  sys.exit(1)
127
 
128
 
129
+ # IPC相关常量
130
+ IPC_COMMANDS_DIR = "ipc_commands"
131
+ IPC_RESPONSES_DIR = "ipc_responses"
132
+ ENV_STATUS_FILE = "env_status.json"
133
+
134
+ class CommandType:
135
+ """命令类型常量"""
136
+ INTERVIEW = "interview"
137
+ BATCH_INTERVIEW = "batch_interview"
138
+ CLOSE_ENV = "close_env"
139
+
140
+
141
+ class IPCHandler:
142
+ """IPC命令处理器"""
143
+
144
+ def __init__(self, simulation_dir: str, env, agent_graph):
145
+ self.simulation_dir = simulation_dir
146
+ self.env = env
147
+ self.agent_graph = agent_graph
148
+ self.commands_dir = os.path.join(simulation_dir, IPC_COMMANDS_DIR)
149
+ self.responses_dir = os.path.join(simulation_dir, IPC_RESPONSES_DIR)
150
+ self.status_file = os.path.join(simulation_dir, ENV_STATUS_FILE)
151
+ self._running = True
152
+
153
+ # 确保目录存在
154
+ os.makedirs(self.commands_dir, exist_ok=True)
155
+ os.makedirs(self.responses_dir, exist_ok=True)
156
+
157
+ def update_status(self, status: str):
158
+ """更新环境状态"""
159
+ with open(self.status_file, 'w', encoding='utf-8') as f:
160
+ json.dump({
161
+ "status": status,
162
+ "timestamp": datetime.now().isoformat()
163
+ }, f, ensure_ascii=False, indent=2)
164
+
165
+ def poll_command(self) -> Optional[Dict[str, Any]]:
166
+ """轮询获取待处理命令"""
167
+ if not os.path.exists(self.commands_dir):
168
+ return None
169
+
170
+ # 获取命令文件(按时间排序)
171
+ command_files = []
172
+ for filename in os.listdir(self.commands_dir):
173
+ if filename.endswith('.json'):
174
+ filepath = os.path.join(self.commands_dir, filename)
175
+ command_files.append((filepath, os.path.getmtime(filepath)))
176
+
177
+ command_files.sort(key=lambda x: x[1])
178
+
179
+ for filepath, _ in command_files:
180
+ try:
181
+ with open(filepath, 'r', encoding='utf-8') as f:
182
+ return json.load(f)
183
+ except (json.JSONDecodeError, OSError):
184
+ continue
185
+
186
+ return None
187
+
188
+ def send_response(self, command_id: str, status: str, result: Dict = None, error: str = None):
189
+ """发送响应"""
190
+ response = {
191
+ "command_id": command_id,
192
+ "status": status,
193
+ "result": result,
194
+ "error": error,
195
+ "timestamp": datetime.now().isoformat()
196
+ }
197
+
198
+ response_file = os.path.join(self.responses_dir, f"{command_id}.json")
199
+ with open(response_file, 'w', encoding='utf-8') as f:
200
+ json.dump(response, f, ensure_ascii=False, indent=2)
201
+
202
+ # 删除命令文件
203
+ command_file = os.path.join(self.commands_dir, f"{command_id}.json")
204
+ try:
205
+ os.remove(command_file)
206
+ except OSError:
207
+ pass
208
+
209
+ async def handle_interview(self, command_id: str, agent_id: int, prompt: str) -> bool:
210
+ """
211
+ 处理单个Agent采访命令
212
+
213
+ Returns:
214
+ True 表示成功,False 表示失败
215
+ """
216
+ try:
217
+ # 获取Agent
218
+ agent = self.agent_graph.get_agent(agent_id)
219
+
220
+ # 创建Interview动作
221
+ interview_action = ManualAction(
222
+ action_type=ActionType.INTERVIEW,
223
+ action_args={"prompt": prompt}
224
+ )
225
+
226
+ # 执行Interview
227
+ actions = {agent: interview_action}
228
+ await self.env.step(actions)
229
+
230
+ # 从数据库获取结果
231
+ result = self._get_interview_result(agent_id)
232
+
233
+ self.send_response(command_id, "completed", result=result)
234
+ print(f" Interview完成: agent_id={agent_id}")
235
+ return True
236
+
237
+ except Exception as e:
238
+ error_msg = str(e)
239
+ print(f" Interview失败: agent_id={agent_id}, error={error_msg}")
240
+ self.send_response(command_id, "failed", error=error_msg)
241
+ return False
242
+
243
+ async def handle_batch_interview(self, command_id: str, interviews: List[Dict]) -> bool:
244
+ """
245
+ 处理批量采访命令
246
+
247
+ Args:
248
+ interviews: [{"agent_id": int, "prompt": str}, ...]
249
+ """
250
+ try:
251
+ # 构建动作字典
252
+ actions = {}
253
+ agent_prompts = {} # 记录每个agent的prompt
254
+
255
+ for interview in interviews:
256
+ agent_id = interview.get("agent_id")
257
+ prompt = interview.get("prompt", "")
258
+
259
+ try:
260
+ agent = self.agent_graph.get_agent(agent_id)
261
+ actions[agent] = ManualAction(
262
+ action_type=ActionType.INTERVIEW,
263
+ action_args={"prompt": prompt}
264
+ )
265
+ agent_prompts[agent_id] = prompt
266
+ except Exception as e:
267
+ print(f" 警告: 无法获取Agent {agent_id}: {e}")
268
+
269
+ if not actions:
270
+ self.send_response(command_id, "failed", error="没有有效的Agent")
271
+ return False
272
+
273
+ # 执行批量Interview
274
+ await self.env.step(actions)
275
+
276
+ # 获取所有结果
277
+ results = {}
278
+ for agent_id in agent_prompts.keys():
279
+ result = self._get_interview_result(agent_id)
280
+ results[agent_id] = result
281
+
282
+ self.send_response(command_id, "completed", result={
283
+ "interviews_count": len(results),
284
+ "results": results
285
+ })
286
+ print(f" 批量Interview完成: {len(results)} 个Agent")
287
+ return True
288
+
289
+ except Exception as e:
290
+ error_msg = str(e)
291
+ print(f" 批量Interview失败: {error_msg}")
292
+ self.send_response(command_id, "failed", error=error_msg)
293
+ return False
294
+
295
+ def _get_interview_result(self, agent_id: int) -> Dict[str, Any]:
296
+ """从数据库获取最新的Interview结果"""
297
+ db_path = os.path.join(self.simulation_dir, "twitter_simulation.db")
298
+
299
+ result = {
300
+ "agent_id": agent_id,
301
+ "response": None,
302
+ "timestamp": None
303
+ }
304
+
305
+ if not os.path.exists(db_path):
306
+ return result
307
+
308
+ try:
309
+ conn = sqlite3.connect(db_path)
310
+ cursor = conn.cursor()
311
+
312
+ # 查询最新的Interview记录
313
+ cursor.execute("""
314
+ SELECT user_id, info, created_at
315
+ FROM trace
316
+ WHERE action = ? AND user_id = ?
317
+ ORDER BY created_at DESC
318
+ LIMIT 1
319
+ """, (ActionType.INTERVIEW.value, agent_id))
320
+
321
+ row = cursor.fetchone()
322
+ if row:
323
+ user_id, info_json, created_at = row
324
+ try:
325
+ info = json.loads(info_json) if info_json else {}
326
+ result["response"] = info.get("response", info)
327
+ result["timestamp"] = created_at
328
+ except json.JSONDecodeError:
329
+ result["response"] = info_json
330
+
331
+ conn.close()
332
+
333
+ except Exception as e:
334
+ print(f" 读取Interview结果失败: {e}")
335
+
336
+ return result
337
+
338
+ async def process_commands(self) -> bool:
339
+ """
340
+ 处理所有待处理命令
341
+
342
+ Returns:
343
+ True 表示继续运行,False 表示应该退出
344
+ """
345
+ command = self.poll_command()
346
+ if not command:
347
+ return True
348
+
349
+ command_id = command.get("command_id")
350
+ command_type = command.get("command_type")
351
+ args = command.get("args", {})
352
+
353
+ print(f"\n收到IPC命令: {command_type}, id={command_id}")
354
+
355
+ if command_type == CommandType.INTERVIEW:
356
+ await self.handle_interview(
357
+ command_id,
358
+ args.get("agent_id", 0),
359
+ args.get("prompt", "")
360
+ )
361
+ return True
362
+
363
+ elif command_type == CommandType.BATCH_INTERVIEW:
364
+ await self.handle_batch_interview(
365
+ command_id,
366
+ args.get("interviews", [])
367
+ )
368
+ return True
369
+
370
+ elif command_type == CommandType.CLOSE_ENV:
371
+ print("收到关闭环境命令")
372
+ self.send_response(command_id, "completed", result={"message": "环境即将关闭"})
373
+ return False
374
+
375
+ else:
376
+ self.send_response(command_id, "failed", error=f"未知命令类型: {command_type}")
377
+ return True
378
+
379
+
380
  class TwitterSimulationRunner:
381
  """Twitter模拟运行器"""
382
 
383
+ # Twitter可用动作(不包含INTERVIEW,INTERVIEW只能通过ManualAction手动触发)
384
  AVAILABLE_ACTIONS = [
385
  ActionType.CREATE_POST,
386
  ActionType.LIKE_POST,
 
390
  ActionType.QUOTE_POST,
391
  ]
392
 
393
+ def __init__(self, config_path: str, wait_for_commands: bool = True):
394
  """
395
  初始化模拟运行器
396
 
397
  Args:
398
  config_path: 配置文件路径 (simulation_config.json)
399
+ wait_for_commands: 模拟完成后是否等待命令(默认True)
400
  """
401
  self.config_path = config_path
402
  self.config = self._load_config()
403
  self.simulation_dir = os.path.dirname(config_path)
404
+ self.wait_for_commands = wait_for_commands
405
+ self.env = None
406
+ self.agent_graph = None
407
+ self.ipc_handler = None
408
 
409
  def _load_config(self) -> Dict[str, Any]:
410
  """加载配置文件"""
 
533
  print("OASIS Twitter模拟")
534
  print(f"配置文件: {self.config_path}")
535
  print(f"模拟ID: {self.config.get('simulation_id', 'unknown')}")
536
+ print(f"等待命令模式: {'启用' if self.wait_for_commands else '禁用'}")
537
  print("=" * 60)
538
 
539
  # 加载时间配置
 
570
  print(f"错误: Profile文件不存在: {profile_path}")
571
  return
572
 
573
+ self.agent_graph = await generate_twitter_agent_graph(
574
  profile_path=profile_path,
575
  model=model,
576
  available_actions=self.AVAILABLE_ACTIONS,
 
584
 
585
  # 创建环境
586
  print("创建OASIS环境...")
587
+ self.env = oasis.make(
588
+ agent_graph=self.agent_graph,
589
  platform=oasis.DefaultPlatformType.TWITTER,
590
  database_path=db_path,
591
  semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
592
  )
593
 
594
+ await self.env.reset()
595
  print("环境初始化完成\n")
596
 
597
+ # 初始化IPC处理器
598
+ self.ipc_handler = IPCHandler(self.simulation_dir, self.env, self.agent_graph)
599
+ self.ipc_handler.update_status("running")
600
+
601
  # 执行初始事件
602
  event_config = self.config.get("event_config", {})
603
  initial_posts = event_config.get("initial_posts", [])
 
609
  agent_id = post.get("poster_agent_id", 0)
610
  content = post.get("content", "")
611
  try:
612
+ agent = self.env.agent_graph.get_agent(agent_id)
613
  initial_actions[agent] = ManualAction(
614
  action_type=ActionType.CREATE_POST,
615
  action_args={"content": content}
 
618
  print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}")
619
 
620
  if initial_actions:
621
+ await self.env.step(initial_actions)
622
  print(f" 已发布 {len(initial_actions)} 条初始帖子")
623
 
624
  # 主模拟循环
 
633
 
634
  # 获取本轮激活的Agent
635
  active_agents = self._get_active_agents_for_round(
636
+ self.env, simulated_hour, round_num
637
  )
638
 
639
  if not active_agents:
 
646
  }
647
 
648
  # 执行动作
649
+ await self.env.step(actions)
650
 
651
  # 打印进度
652
  if (round_num + 1) % 10 == 0 or round_num == 0:
 
657
  f"- {len(active_agents)} agents active "
658
  f"- elapsed: {elapsed:.1f}s")
659
 
 
 
 
660
  total_elapsed = (datetime.now() - start_time).total_seconds()
661
+ print(f"\n模拟循环完成!")
662
  print(f" - 总耗时: {total_elapsed:.1f}秒")
663
  print(f" - 数据库: {db_path}")
664
+
665
+ # 是否进入等待命令模式
666
+ if self.wait_for_commands:
667
+ print("\n" + "=" * 60)
668
+ print("进入等待命令模式 - 环境保持运行")
669
+ print("支持的命令: interview, batch_interview, close_env")
670
+ print("=" * 60)
671
+
672
+ self.ipc_handler.update_status("alive")
673
+
674
+ # 等待命令循环
675
+ try:
676
+ while True:
677
+ should_continue = await self.ipc_handler.process_commands()
678
+ if not should_continue:
679
+ break
680
+ await asyncio.sleep(0.5) # 轮询间隔
681
+ except KeyboardInterrupt:
682
+ print("\n收到中断信号")
683
+ except Exception as e:
684
+ print(f"\n命令处理出错: {e}")
685
+
686
+ print("\n关闭环境...")
687
+
688
+ # 关闭环境
689
+ self.ipc_handler.update_status("stopped")
690
+ await self.env.close()
691
+
692
+ print("环境已关闭")
693
  print("=" * 60)
694
 
695
 
 
707
  default=None,
708
  help='最大模拟轮数(可选,用于截断过长的模拟)'
709
  )
710
+ parser.add_argument(
711
+ '--no-wait',
712
+ action='store_true',
713
+ default=False,
714
+ help='模拟完成后立即关闭环境,不进入等待命令模式'
715
+ )
716
 
717
  args = parser.parse_args()
718
 
 
724
  simulation_dir = os.path.dirname(args.config) or "."
725
  setup_oasis_logging(os.path.join(simulation_dir, "log"))
726
 
727
+ runner = TwitterSimulationRunner(
728
+ config_path=args.config,
729
+ wait_for_commands=not args.no_wait
730
+ )
731
  await runner.run(max_rounds=args.max_rounds)
732
 
733
 
734
  if __name__ == "__main__":
735
  asyncio.run(main())