Refactor MiroFish into staged agent engine

This commit is contained in:
Flowershangfromthebranches 2026-06-07 13:49:44 +08:00
parent 96096ea0ff
commit 300c79e13f
69 changed files with 8757 additions and 1701 deletions

View File

@ -1,16 +1,37 @@
# LLM API配置支持 OpenAI SDK 格式的任意 LLM API
# 推荐使用阿里百炼平台qwen-plus模型https://bailian.console.aliyun.com/
# 注意消耗较大可先进行小于40轮的模拟尝试
LLM_API_KEY=your_api_key_here
# ===== MiroFish Agent Engine 默认配置 =====
MIROFISH_MODE=agent
MIROFISH_LLM_PROVIDER=agent_queue
MIROFISH_GRAPH_PROVIDER=graphiti
MIROFISH_RUNS_DIR=./runs
# ===== Graphiti / Neo4j 图谱配置 =====
NEO4J_URI=bolt://localhost:7687
NEO4J_USER=neo4j
NEO4J_PASSWORD=password
NEO4J_DATABASE=neo4j
# ===== Embedding 配置(可选)=====
# GraphitiCompatibilityStore 的 no-LLM 主路径不需要 embedding。
# fulltext 不需要 Ollama仅 semantic/hybrid + ollama 时 doctor 会强制检查 Ollama。
MIROFISH_GRAPH_SEARCH_MODE=fulltext
MIROFISH_EMBEDDING_PROVIDER=none
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# ===== Legacy OpenAI-compatible LLM provider可选=====
# 仅当 MIROFISH_LLM_PROVIDER=openai_compatible 时需要。
LLM_API_KEY=
LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
LLM_MODEL_NAME=qwen-plus
# ===== ZEP记忆图谱配置 =====
# 每月免费额度即可支撑简单使用https://app.getzep.com/
ZEP_API_KEY=your_zep_api_key_here
# ===== Legacy Zep Cloud graph provider可选=====
# 仅当 MIROFISH_GRAPH_PROVIDER=zep 时需要。
ZEP_API_KEY=
# ===== 加速 LLM 配置(可选)=====
# 注意如果不使用加速配置env文件中就不要出现下面的配置项
LLM_BOOST_API_KEY=your_api_key_here
LLM_BOOST_BASE_URL=your_base_url_here
LLM_BOOST_MODEL_NAME=your_model_name_here
# ===== 加速 LLM 配置legacy 可选)=====
LLM_BOOST_API_KEY=
LLM_BOOST_BASE_URL=
LLM_BOOST_MODEL_NAME=
# ===== Legacy UI / simulation behavior =====
MIROFISH_PRESERVE_SIMULATIONS_ON_RELOAD=true

83
AGENT_KIT.md Normal file
View File

@ -0,0 +1,83 @@
# MiroFish Agent Kit
MiroFish now includes a full agent engine for desktop tools such as Codex, Claude Code, Cursor, and opencode.
Default agent mode:
```bash
export MIROFISH_MODE=agent
export MIROFISH_LLM_PROVIDER=agent_queue
export MIROFISH_GRAPH_PROVIDER=graphiti
export MIROFISH_RUNS_DIR=./runs
```
Agent mode does not require `LLM_API_KEY`, `OPENAI_API_KEY`, or `ZEP_API_KEY`. Model work is written to `runs/<run_id>/requests/*.json`; a desktop agent writes matching response JSON into `runs/<run_id>/responses/*.json`; MiroFish validates the response and resumes the run.
Local graph service setup:
```bash
cd /Users/leaf/Documents/future/MiroFish
bash scripts/setup_agent_deps.sh --neo4j desktop
```
Agent dependencies are installed through the backend `agent` optional extra. Legacy OpenAI-compatible and Zep Cloud SDKs are not part of the default agent path; install them only when explicitly using `MIROFISH_LLM_PROVIDER=openai_compatible` or `MIROFISH_GRAPH_PROVIDER=zep`:
```bash
cd /Users/leaf/Documents/future/MiroFish/backend
uv sync --extra legacy
```
Neo4j Desktop, Homebrew/native Neo4j, and existing Neo4j instances are supported. Docker Compose is optional only. `mirofish-agent doctor --json` does not fail because Docker is missing; it fails when required Graphiti/Neo4j dependencies, Neo4j connectivity, or Neo4j `5.26+` are unavailable. Ollama checks are required only when `MIROFISH_GRAPH_SEARCH_MODE=semantic` or `hybrid` and `MIROFISH_EMBEDDING_PROVIDER=ollama`; `fulltext` search with `MIROFISH_EMBEDDING_PROVIDER=none` does not require Ollama.
Minimal demo:
```bash
cd /Users/leaf/Documents/future/MiroFish/backend
uv run mirofish-agent init --seed /path/to/seed.md --requirement "预测未来10年全球芯片能力格局变化" --output ../runs/chip-2036
uv run mirofish-agent run --run ../runs/chip-2036 --json
uv run mirofish-agent requests show --run ../runs/chip-2036 --request-id req_000001 --json
uv run mirofish-agent responses validate --run ../runs/chip-2036 --response ../runs/chip-2036/responses/req_000001.json --json
uv run mirofish-agent resume --run ../runs/chip-2036 --json
```
Follow-up questions also use the same provider boundary and queue:
```bash
uv run mirofish-agent followup ask --run ../runs/chip-2036 --question "先进AI芯片出口限制有什么影响?" --json
uv run mirofish-agent followup show --run ../runs/chip-2036 --request-id req_000007 --json
```
Supported agent queue task types:
```text
extract_triples
generate_ontology
generate_oasis_profiles
generate_simulation_config
simulate_agent_action
summarize_round
update_memory
generate_report
answer_followup_question
validate_json_output
repair_invalid_json
```
Full smoke:
```bash
cd /Users/leaf/Documents/future/MiroFish
bash scripts/smoke_agent_queue_full.sh
bash scripts/smoke_mcp_full.sh
python scripts/check_provider_boundaries.py
```
Tool-specific guides:
- [Codex](./docs/agent-usage/codex.md)
- [Claude Code](./docs/agent-usage/claude-code.md)
- [Cursor](./docs/agent-usage/cursor.md)
- [opencode](./docs/agent-usage/opencode.md)
- [MCP](./docs/agent-usage/mcp.md)
The Flask/Vue UI is preserved as the legacy interactive interface. Legacy API calls that hit `agent_queue` return a structured HTTP 202 `need_agent_response` payload instead of a 500, but full checkpointed agent-mode orchestration remains CLI/MCP-first.

201
IMPLEMENTATION_SUMMARY.md Normal file
View File

@ -0,0 +1,201 @@
# Implementation Summary
## Changed Areas
- Added `backend/app/agent_engine/` for strict request/response schemas, filesystem queue, persistent run state, output contracts, shared runner, and CLI.
- Added `backend/app/adapters/llm/` with `AgentRuntime`, `agent_queue`, `mock`, legacy `openai_compatible`, and `AgentModelBackendAdapter`.
- Added `backend/app/adapters/graph/` with `GraphProvider`, `GraphitiGraphProvider`, `GraphitiCompatibilityStore`, and legacy `ZepGraphProvider`.
- Added `backend/app/mcp_server/server.py` with FastMCP lifecycle tools.
- Refactored direct model/Zep call sites in LLM client, graph builder, Zep entity/tools/memory facades, OASIS profile/config generators, and simulation scripts.
- Tightened agent response validation so `skipped` outputs still match `expected_schema`, `req_*.json` filenames must match response `request_id`, and invalid stage responses create `repair_invalid_json` requests when retry policy allows.
- Added stable output schemas for every declared agent task type: `extract_triples`, `generate_ontology`, `generate_oasis_profiles`, `generate_simulation_config`, `simulate_agent_action`, `summarize_round`, `update_memory`, `generate_report`, `answer_followup_question`, `validate_json_output`, and `repair_invalid_json`.
- Persisted graph provider overrides in run metadata so `graph build --provider graphiti` survives the `waiting_agent` pause/resume boundary.
- Made explicit stage entrypoints idempotent while waiting for agent responses: repeated `graph build`, `simulate start`, and `report generate` calls return the existing request instead of creating duplicates.
- Made `responses submit` attach generated `repair_invalid_json` requests to the current waiting stage, so later `resume` reuses the repair request instead of creating duplicates.
- Extended `GraphitiCompatibilityStore` Neo4j mode so episodes, agent memory, snapshot export/import, and timeline data stay in the Neo4j-backed compatibility layer instead of falling back to file storage.
- Aligned `GraphitiCompatibilityStore` default behavior with production `doctor`: `MIROFISH_GRAPHITI_STORE=auto` uses Neo4j, and offline file storage requires explicit `MIROFISH_GRAPHITI_STORE=file`.
- Added CLI and MCP follow-up Q&A through `answer_followup_question`, with GraphProvider retrieval context and queue-validated responses.
- Added staged workflow support alongside the existing auto workflow. Staged runs pause after `seed_input`, `prediction_requirement`, `simulation_settings`, `graph_build`, `profile_and_config`, and `simulation_run` until the user approves, rejects, updates settings, or reruns a stage.
- Added hard simulation settings to CLI/MCP run creation: `rounds`, `round_unit`, `minutes_per_round`, `pause_each_round`, `agent_count`, and `simulation_name`. `rounds` is persisted in `state.json` and no longer depends on natural-language requirement parsing.
- Added staged CLI commands under `mirofish-agent stage ...` and matching MCP tools for current-stage inspection, settings updates, stage approval/rejection, and reruns.
- Exposed `AgentModelBackendAdapter.last_need_agent_response` so OASIS/CAMEL runtime calls that hit `agent_queue` can be detected by orchestration code even when CAMEL expects a `ChatCompletion` object.
- Added a Flask `NeedAgentResponse` handler so legacy API calls return HTTP 202 structured `need_agent_response` payloads instead of generic 500s.
- Removed API-key default reads from legacy business facades; provider keys are now validated only inside legacy providers or provider-aware Flask guards.
- Moved OpenAI-compatible and Zep Cloud SDK packages out of default backend dependencies and into the optional `legacy` extra, so default agent installs do not require legacy SDKs.
- Strengthened `scripts/check_provider_boundaries.py` to reject direct legacy provider imports and Graphiti/Neo4j schema assumptions outside `GraphitiCompatibilityStore`.
- Changed ontology Python code generation to use provider-neutral Pydantic base classes instead of emitting graph SDK import strings.
- Added tests under `backend/tests/` and smoke/static scripts under `scripts/`.
- Added `docker-compose.agent.yml` and `scripts/setup_agent_deps.sh` for local agent dependencies and services.
- Updated `.env.example`, `AGENT_KIT.md`, README, and `docs/agent-usage/*`, including opencode usage.
## Providers
- `MIROFISH_LLM_PROVIDER=agent_queue`: writes strict request JSON and returns `need_agent_response`.
- `MIROFISH_LLM_PROVIDER=mock`: deterministic offline provider for tests.
- `MIROFISH_LLM_PROVIDER=openai_compatible`: legacy provider; the OpenAI SDK import is isolated to `backend/app/adapters/llm/openai_compatible.py`.
- `MIROFISH_GRAPH_PROVIDER=graphiti`: default agent graph provider.
- `MIROFISH_GRAPH_PROVIDER=zep`: legacy provider; Zep SDK imports are isolated to `backend/app/adapters/graph/zep.py`.
## Dependencies And Local Services
- Graphiti source code is not copied, cloned, or vendored into this repository.
- Graphiti is installed through backend dependency management with the optional `agent` extra: `uv sync --extra agent --group dev`.
- The `agent` extra includes `graphiti-core`, `neo4j`, and `mcp`.
- Legacy OpenAI-compatible and Zep Cloud SDKs are isolated in the optional `legacy` extra: `uv sync --extra legacy`.
- Neo4j is an external local graph database service. Supported non-Docker setup paths are Neo4j Desktop, Homebrew/native install, or an existing Neo4j 5.26+ instance.
- Docker Compose remains available through `docker-compose.agent.yml`, but Docker and Docker Compose are optional and are not required by `doctor`.
- Ollama is conditional. `doctor` only hard-fails Ollama checks when `MIROFISH_GRAPH_SEARCH_MODE=semantic` or `hybrid` and `MIROFISH_EMBEDDING_PROVIDER=ollama`.
- MiroFish only maintains `GraphitiGraphProvider` and `GraphitiCompatibilityStore`; all Graphiti/Neo4j schema assumptions are isolated there.
## Minimal Demo
```bash
cd /Users/leaf/Documents/future/MiroFish
bash scripts/smoke_agent_queue_full.sh
```
Manual CLI:
```bash
cd /Users/leaf/Documents/future/MiroFish/backend
uv run mirofish-agent init --seed /path/to/seed.md --requirement "预测未来10年全球芯片能力格局变化" --output ../runs/chip-2036 --json
uv run mirofish-agent run --run ../runs/chip-2036 --json
```
Staged CLI:
```bash
cd /Users/leaf/Documents/future/MiroFish/backend
uv run mirofish-agent create-run \
--seed /path/to/seed.md \
--requirement "预测未来10年全球芯片能力格局变化" \
--output ../runs/chip-2036 \
--mode staged \
--rounds 10 \
--round-unit year \
--json
uv run mirofish-agent stage status --run ../runs/chip-2036 --json
uv run mirofish-agent stage approve --run ../runs/chip-2036 --json
uv run mirofish-agent resume --run ../runs/chip-2036 --json
```
When `need_agent_response` is returned, write the response file and run:
```bash
uv run mirofish-agent responses validate --run ../runs/chip-2036 --response ../runs/chip-2036/responses/req_000001.json --json
uv run mirofish-agent resume --run ../runs/chip-2036 --json
```
## Codex Triple Extraction
Use this prompt when processing an `extract_triples` request:
```text
读取 request_file严格按照 expected_schema 生成 response_file。只输出 JSON不要添加解释。每个 triple 必须包含 subject、predicate、object、fact、evidence、confidence。不要编造现实种子中没有的事实。无法确认的关系不要写入或将 confidence 降低。
```
## Agent Engine Coverage
Implemented full CLI/MCP lifecycle for:
- ontology request
- triple extraction request
- profile request
- simulation config request
- batched simulation action request
- report request
- follow-up Q&A request
- `report.md`, `verdict.json`, `timeline.json`, `graph_snapshot.json`
Staged workflow maps to the original UI-style process:
- `seed_input`: saves and summarizes `seed.md`.
- `prediction_requirement`: records the prediction target.
- `simulation_settings`: records hard settings such as `rounds=10` and `round_unit=year`.
- `graph_build`: runs ontology and triple extraction requests, then writes Graphiti/Neo4j.
- `profile_and_config`: generates profiles and simulation config; config rounds are forcibly taken from `simulation_settings`.
- `simulation_run`: runs the configured number of rounds and updates simulation progress.
- `report_generation`: writes report artifacts and records actual rounds in `verdict.json` and `timeline.json`.
- `followup_question`: uses AgentRuntime plus GraphProvider retrieval.
Auto mode remains available for scripts and smoke tests. Staged mode is intended for QoderWork, Codex, Claude Code, Cursor, and other desktop agents that need user confirmation between phases.
Legacy Flask/Vue UI is preserved but not fully upgraded to drive every checkpointed `agent_queue` stage. Legacy API calls return structured HTTP 202 `need_agent_response` payloads when model work needs an external agent. Full agent-mode orchestration is CLI/MCP-first.
## Graphiti Limits
`GraphitiCompatibilityStore` is a version-sensitive compatibility layer. It provides a no-LLM triplet write path and hides all Neo4j/Cypher/schema assumptions from business code. When Graphitis public fact triple API is available and stable in the installed version, this layer can be adapted internally without changing `GraphProvider`.
Offline tests use explicit `MIROFISH_GRAPHITI_STORE=file`. Production Graphiti/Neo4j mode uses `MIROFISH_GRAPHITI_STORE=auto` or `neo4j` with `NEO4J_URI`, `NEO4J_USER`, `NEO4J_PASSWORD`, and `NEO4J_DATABASE`.
## Verification Results
Latest local verification:
```bash
cd /Users/leaf/Documents/future/MiroFish/backend && uv run pytest -q
# 46 passed, 414 warnings
cd /Users/leaf/Documents/future/MiroFish && bash scripts/smoke_agent_queue_full.sh
# CLI full agent_queue smoke passed, including follow-up Q&A
cd /Users/leaf/Documents/future/MiroFish && bash scripts/smoke_agent_queue_staged.sh
# CLI staged agent_queue smoke passed, including stage approvals and rounds=10 artifacts
cd /Users/leaf/Documents/future/MiroFish && bash scripts/smoke_mcp_full.sh
# MCP lifecycle smoke passed, including staged tool/schema checks, request validation, graph search/export, doctor, artifacts, and follow-up Q&A
cd /Users/leaf/Documents/future/MiroFish && python scripts/check_provider_boundaries.py
# Provider boundary check passed
cd /Users/leaf/Documents/future/MiroFish && bash -n scripts/setup_agent_deps.sh
# script syntax check passed
cd /Users/leaf/Documents/future/MiroFish/backend && uv run mirofish-agent doctor --json
# status: ok; Neo4j 2025.04.0 connectable; hard_failures: []
# Live Graphiti/Neo4j provider smoke:
# add_triples/search/export_snapshot/clear_run_graph passed with MIROFISH_GRAPHITI_STORE=auto.
# Live CLI Graphiti/Neo4j smoke:
# CLI Neo4j agent_queue smoke passed with graph search and final artifacts.
```
Frontend build must be run from the detected frontend package directory:
```bash
cd /Users/leaf/Documents/future/MiroFish/frontend && npm run build
# built successfully; Vite reported only chunk-size/dynamic-import warnings
```
Doctor result in the current shell:
```bash
cd /Users/leaf/Documents/future/MiroFish/backend && uv run mirofish-agent doctor --json
# status: ok
# graphiti_package: ok
# mcp_package: ok
# neo4j_package: ok
# neo4j_connectable: ok, bolt://localhost:7687
# neo4j_version_supported: ok, 2025.04.0
# docker: optional warning only when Docker is not installed
# docker_compose: optional warning only when Docker Compose is not installed
# graph_search_mode: fulltext
# embedding_provider: none
# ollama_connectable: optional; fulltext search does not require Ollama
# ollama_embedding_model: optional
# hard_failures: []
```
Setup helper result in the current shell:
```bash
cd /Users/leaf/Documents/future/MiroFish && bash scripts/setup_agent_deps.sh --neo4j desktop --skip-services
# Python agent dependencies installed/audited
# warning: Docker optional, skipped
# ok: Neo4j version 2025.04.0
# warning: Ollama optional, skipped because MIROFISH_GRAPH_SEARCH_MODE=fulltext uses no semantic embedding
# ok: agent dependency and required service checks completed
```
This means the offline no-LLM compatibility path and the production Graphiti/Neo4j path are both verified. Docker is not required. Ollama is only required for semantic/hybrid graph search when `MIROFISH_EMBEDDING_PROVIDER=ollama`.

View File

@ -93,6 +93,26 @@ Click the image to watch MiroFish's deep prediction of the lost ending based on
## 🚀 Quick Start
### Agent Engine Mode
MiroFish now supports CLI/MCP-driven agent mode. In this mode desktop agents process model tasks through request/response files, so `LLM_API_KEY`, `OPENAI_API_KEY`, and `ZEP_API_KEY` are not required.
See [AGENT_KIT.md](./AGENT_KIT.md), [docs/agent-usage/codex.md](./docs/agent-usage/codex.md), and [docs/agent-usage/mcp.md](./docs/agent-usage/mcp.md).
Install agent engine dependencies from the backend when using CLI/MCP mode:
```bash
cd backend
uv sync --extra agent --group dev
```
Legacy OpenAI-compatible and Zep Cloud SDKs are optional:
```bash
cd backend
uv sync --extra legacy
```
### Option 1: Source Code Deployment (Recommended)
#### Prerequisites
@ -109,10 +129,30 @@ Click the image to watch MiroFish's deep prediction of the lost ending based on
# Copy the example configuration file
cp .env.example .env
# Edit the .env file and fill in the required API keys
# Edit the .env file. Agent mode does not require model or Zep API keys.
```
**Required Environment Variables:**
**Default Agent Environment Variables:**
```env
MIROFISH_MODE=agent
MIROFISH_LLM_PROVIDER=agent_queue
MIROFISH_GRAPH_PROVIDER=graphiti
MIROFISH_RUNS_DIR=./runs
NEO4J_URI=bolt://localhost:7687
NEO4J_USER=neo4j
NEO4J_PASSWORD=password
NEO4J_DATABASE=neo4j
MIROFISH_GRAPH_SEARCH_MODE=fulltext
MIROFISH_EMBEDDING_PROVIDER=none
```
**Legacy Compatibility Variables:**
These are required only when explicitly using `MIROFISH_LLM_PROVIDER=openai_compatible` or `MIROFISH_GRAPH_PROVIDER=zep`.
Install the optional legacy SDKs with `uv sync --extra legacy`.
```env
# LLM API Configuration (supports any LLM API with OpenAI SDK format)
@ -200,4 +240,4 @@ MiroFish's simulation engine is powered by **[OASIS (Open Agent Social Interacti
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&legend=top-left" />
</picture>
</a>
</a>

View File

@ -9,10 +9,11 @@ import warnings
# 需要在所有其他导入之前设置
warnings.filterwarnings("ignore", message=".*resource_tracker.*")
from flask import Flask, request
from flask import Flask, jsonify, request
from flask_cors import CORS
from .config import Config
from .adapters.llm.agent_runtime import NeedAgentResponse
from .utils.logger import setup_logger, get_logger
@ -61,6 +62,11 @@ def create_app(config_class=Config):
logger = get_logger('mirofish.request')
logger.debug(f"响应: {response.status_code}")
return response
@app.errorhandler(NeedAgentResponse)
def handle_need_agent_response(error):
"""Expose agent_queue waits as structured API responses instead of 500s."""
return jsonify(error.result.to_dict()), 202
# 注册蓝图
from .api import graph_bp, simulation_bp, report_bp
@ -77,4 +83,3 @@ def create_app(config_class=Config):
logger.info("MiroFish Backend 启动完成")
return app

View File

@ -0,0 +1 @@
"""Provider adapters for model and graph backends."""

View File

@ -0,0 +1,6 @@
"""Graph provider adapters."""
from .base import GraphProvider, GraphTriple
from .factory import create_graph_provider
__all__ = ["GraphProvider", "GraphTriple", "create_graph_provider"]

View File

@ -0,0 +1,76 @@
"""Unified graph provider contract."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field
class GraphTriple(BaseModel):
model_config = ConfigDict(extra="forbid")
subject: str
predicate: str
object: str
fact: str
valid_at: Optional[str] = None
invalid_at: Optional[str] = None
source: Optional[str] = None
source_file: Optional[str] = None
evidence: str
confidence: float = Field(ge=0.0, le=1.0)
metadata: Dict[str, Any] = Field(default_factory=dict)
class GraphProvider(ABC):
name: str
@abstractmethod
def add_episode(self, run_id: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
raise NotImplementedError
@abstractmethod
def add_triples(self, run_id: str, triples: List[Dict[str, Any] | GraphTriple]) -> Dict[str, Any]:
raise NotImplementedError
@abstractmethod
def search(self, run_id: str, query: str, limit: int = 20) -> List[Dict[str, Any]]:
raise NotImplementedError
@abstractmethod
def neighbors(self, run_id: str, entity: str, depth: int = 2) -> List[Dict[str, Any]]:
raise NotImplementedError
@abstractmethod
def list_entities(self, run_id: str) -> List[Dict[str, Any]]:
raise NotImplementedError
@abstractmethod
def get_entity(self, run_id: str, entity: str) -> Optional[Dict[str, Any]]:
raise NotImplementedError
@abstractmethod
def update_memory(self, run_id: str, agent_id: str, memory: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError
@abstractmethod
def get_agent_memory(self, run_id: str, agent_id: str) -> Dict[str, Any]:
raise NotImplementedError
@abstractmethod
def write_agent_memory(self, run_id: str, agent_id: str, memory: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError
@abstractmethod
def export_snapshot(self, run_id: str, output_path: str) -> Dict[str, Any]:
raise NotImplementedError
@abstractmethod
def import_snapshot(self, run_id: str, input_path: str) -> Dict[str, Any]:
raise NotImplementedError
@abstractmethod
def clear_run_graph(self, run_id: str) -> Dict[str, Any]:
raise NotImplementedError

View File

@ -0,0 +1,24 @@
"""Graph provider factory."""
from __future__ import annotations
import os
from typing import Optional
from .base import GraphProvider
from .graphiti import GraphitiGraphProvider
from .zep import ZepGraphProvider
def create_graph_provider(provider: Optional[str] = None) -> GraphProvider:
provider_name = provider or os.environ.get("MIROFISH_GRAPH_PROVIDER")
if not provider_name:
mode = os.environ.get("MIROFISH_MODE", "agent")
provider_name = "graphiti" if mode == "agent" else "zep"
provider_name = provider_name.lower()
if provider_name == "graphiti":
return GraphitiGraphProvider()
if provider_name == "zep":
return ZepGraphProvider()
raise ValueError(f"Unsupported MIROFISH_GRAPH_PROVIDER: {provider_name}")

View File

@ -0,0 +1,642 @@
"""Graphiti provider with no-LLM triplet write compatibility.
The compatibility store intentionally hides all Neo4j/Cypher and Graphiti
schema assumptions from business code.
"""
from __future__ import annotations
import hashlib
import importlib.util
import json
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional
from .base import GraphProvider, GraphTriple
class GraphitiDependencyError(RuntimeError):
pass
class GraphitiCompatibilityStore:
"""Version-sensitive Graphiti/Neo4j no-LLM triplet store."""
def __init__(self, *, store_path: str | None = None, require_neo4j: bool = False):
self.store_mode = os.environ.get("MIROFISH_GRAPHITI_STORE", "auto").lower()
if self.store_mode not in {"auto", "neo4j", "file"}:
raise GraphitiDependencyError("MIROFISH_GRAPHITI_STORE must be auto, neo4j, or file")
self.neo4j_uri = os.environ.get("NEO4J_URI")
if self.store_mode in {"auto", "neo4j"} and not self.neo4j_uri:
self.neo4j_uri = "bolt://localhost:7687"
self.neo4j_user = os.environ.get("NEO4J_USER", "neo4j")
self.neo4j_password = os.environ.get("NEO4J_PASSWORD", "password")
self.neo4j_database = os.environ.get("NEO4J_DATABASE", "neo4j")
self.store_path = Path(store_path or os.environ.get("MIROFISH_GRAPHITI_COMPAT_PATH", "./runs/.graphiti_compat_store.json"))
self.driver = None
should_use_neo4j = self.store_mode in {"auto", "neo4j"}
if should_use_neo4j:
spec = importlib.util.find_spec("neo4j")
if not spec:
raise GraphitiDependencyError(
"neo4j Python package is required for GraphitiCompatibilityStore neo4j/auto mode; "
"set MIROFISH_GRAPHITI_STORE=file for offline compatibility tests"
)
else:
from neo4j import GraphDatabase
self.driver = GraphDatabase.driver(
self.neo4j_uri,
auth=(self.neo4j_user, self.neo4j_password),
)
self._ensure_constraints()
if not self.driver:
self.store_path.parent.mkdir(parents=True, exist_ok=True)
if not self.store_path.exists():
self._write_file_store({"runs": {}})
def close(self) -> None:
if self.driver:
self.driver.close()
def normalize_entity(self, value: str) -> str:
return re.sub(r"\s+", " ", value.strip()).casefold()
def add_triplet(self, run_id: str, triple: GraphTriple) -> Dict[str, Any]:
if self.driver:
return self._add_triplet_neo4j(run_id, triple)
return self._add_triplet_file(run_id, triple)
def add_triples(self, run_id: str, triples: List[GraphTriple]) -> Dict[str, Any]:
for triple in triples:
self.add_triplet(run_id, triple)
return {"provider": "graphiti", "store": "neo4j" if self.driver else "file", "triples_added": len(triples)}
def get_or_create_entity_node(self, run_id: str, name: str, labels: Optional[List[str]] = None) -> Dict[str, Any]:
normalized = self.normalize_entity(name)
if self.driver:
with self.driver.session(database=self.neo4j_database) as session:
record = session.run(
"""
MERGE (e:MiroFishEntity {group_id: $run_id, normalized_name: $normalized})
ON CREATE SET e.uuid = $uuid, e.name = $name, e.labels = $labels, e.created_at = datetime()
ON MATCH SET e.name = coalesce(e.name, $name)
RETURN e
""",
run_id=run_id,
normalized=normalized,
uuid=self._entity_uuid(run_id, normalized),
name=name,
labels=labels or ["Entity"],
).single()
node = record["e"]
return self._to_jsonable(dict(node.items()))
data = self._read_file_store()
run = self._file_run(data, run_id)
entities = run["entities"]
if normalized not in entities:
entities[normalized] = {
"uuid": self._entity_uuid(run_id, normalized),
"name": name,
"normalized_name": normalized,
"labels": labels or ["Entity"],
"summary": "",
"attributes": {},
}
self._write_file_store(data)
return entities[normalized]
def search_facts(self, run_id: str, query: str, limit: int = 20) -> List[Dict[str, Any]]:
if self.driver:
with self.driver.session(database=self.neo4j_database) as session:
result = session.run(
"""
MATCH (s:MiroFishEntity {group_id: $run_id})-[r:MIROFISH_FACT]->(o:MiroFishEntity {group_id: $run_id})
WHERE toLower(r.fact) CONTAINS toLower($search_query)
OR toLower(s.name) CONTAINS toLower($search_query)
OR toLower(o.name) CONTAINS toLower($search_query)
RETURN s, r, o
LIMIT $limit
""",
run_id=run_id,
search_query=query,
limit=limit,
)
return [self._record_to_fact(row) for row in result]
data = self._read_file_store()
run = self._file_run(data, run_id)
query_lower = query.casefold()
terms = self._query_terms(query_lower)
matches = []
for triple in run["triples"].values():
haystack = " ".join(
[
triple.get("subject", ""),
triple.get("predicate", ""),
triple.get("object", ""),
triple.get("fact", ""),
triple.get("evidence", ""),
]
).casefold()
compact_haystack = re.sub(r"\W+", "", haystack)
if query_lower in haystack or any(term in haystack or term in compact_haystack for term in terms):
matches.append(triple)
return matches[:limit]
def search_nodes(self, run_id: str, query: str, limit: int = 20) -> List[Dict[str, Any]]:
nodes = self.list_entities(run_id)
query_lower = query.casefold()
return [node for node in nodes if query_lower in node.get("name", "").casefold()][:limit]
def neighbors(self, run_id: str, entity: str, depth: int = 2) -> List[Dict[str, Any]]:
normalized = self.normalize_entity(entity)
max_depth = max(1, min(int(depth), 10))
if self.driver:
with self.driver.session(database=self.neo4j_database) as session:
result = session.run(
f"""
MATCH path=(start:MiroFishEntity {{group_id: $run_id, normalized_name: $normalized}})-[*1..{max_depth}]-(n:MiroFishEntity {{group_id: $run_id}})
RETURN nodes(path) AS nodes, relationships(path) AS relationships
LIMIT 100
""",
run_id=run_id,
normalized=normalized,
)
return [
{
"nodes": [self._to_jsonable(dict(n.items())) for n in row["nodes"]],
"relationships": [self._to_jsonable(dict(r.items())) for r in row["relationships"]],
}
for row in result
]
data = self._read_file_store()
run = self._file_run(data, run_id)
frontier = {normalized}
seen = {normalized}
facts = []
for _ in range(max(depth, 1)):
next_frontier = set()
for triple in run["triples"].values():
subject = self.normalize_entity(triple["subject"])
obj = self.normalize_entity(triple["object"])
if subject in frontier or obj in frontier:
facts.append(triple)
if subject not in seen:
next_frontier.add(subject)
if obj not in seen:
next_frontier.add(obj)
seen.update(next_frontier)
frontier = next_frontier
if not frontier:
break
return facts
def list_entities(self, run_id: str) -> List[Dict[str, Any]]:
if self.driver:
with self.driver.session(database=self.neo4j_database) as session:
result = session.run(
"MATCH (e:MiroFishEntity {group_id: $run_id}) RETURN e ORDER BY e.name",
run_id=run_id,
)
return [self._to_jsonable(dict(row["e"].items())) for row in result]
data = self._read_file_store()
return list(self._file_run(data, run_id)["entities"].values())
def get_entity(self, run_id: str, entity: str) -> Optional[Dict[str, Any]]:
normalized = self.normalize_entity(entity)
if self.driver:
with self.driver.session(database=self.neo4j_database) as session:
row = session.run(
"MATCH (e:MiroFishEntity {group_id: $run_id, normalized_name: $normalized}) RETURN e",
run_id=run_id,
normalized=normalized,
).single()
return self._to_jsonable(dict(row["e"].items())) if row else None
data = self._read_file_store()
return self._file_run(data, run_id)["entities"].get(normalized)
def update_memory(self, run_id: str, agent_id: str, memory: Dict[str, Any]) -> Dict[str, Any]:
current = self.get_agent_memory(run_id, agent_id)
current.update(memory)
return self.write_agent_memory(run_id, agent_id, current)
def get_agent_memory(self, run_id: str, agent_id: str) -> Dict[str, Any]:
if self.driver:
with self.driver.session(database=self.neo4j_database) as session:
row = session.run(
"""
MATCH (m:MiroFishAgentMemory {group_id: $run_id, agent_id: $agent_id})
RETURN m.memory_json AS memory_json
""",
run_id=run_id,
agent_id=agent_id,
).single()
if not row:
return {}
return json.loads(row["memory_json"] or "{}")
data = self._read_file_store()
return self._file_run(data, run_id)["memory"].get(agent_id, {})
def write_agent_memory(self, run_id: str, agent_id: str, memory: Dict[str, Any]) -> Dict[str, Any]:
if self.driver:
with self.driver.session(database=self.neo4j_database) as session:
session.run(
"""
MERGE (m:MiroFishAgentMemory {group_id: $run_id, agent_id: $agent_id})
ON CREATE SET m.uuid = $uuid, m.created_at = datetime()
SET m.memory_json = $memory_json, m.updated_at = datetime()
""",
run_id=run_id,
agent_id=agent_id,
uuid=self._memory_uuid(run_id, agent_id),
memory_json=json.dumps(memory, ensure_ascii=False),
)
return {"run_id": run_id, "agent_id": agent_id, "memory": memory}
data = self._read_file_store()
run = self._file_run(data, run_id)
run["memory"][agent_id] = memory
self._write_file_store(data)
return {"run_id": run_id, "agent_id": agent_id, "memory": memory}
def export_snapshot(self, run_id: str, output_path: str) -> Dict[str, Any]:
path = Path(output_path)
path.parent.mkdir(parents=True, exist_ok=True)
snapshot = self.snapshot(run_id)
path.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2), encoding="utf-8")
return {"output_path": str(path), "nodes": len(snapshot["entities"]), "triples": len(snapshot["triples"])}
def import_snapshot(self, run_id: str, input_path: str) -> Dict[str, Any]:
snapshot = json.loads(Path(input_path).read_text(encoding="utf-8"))
if self.driver:
self.clear_run_graph(run_id)
for entity in snapshot.get("entities", []):
name = entity.get("name")
if name:
self.get_or_create_entity_node(run_id, name, entity.get("labels"))
triples = [GraphTriple.model_validate(self._clean_triple_payload(triple)) for triple in snapshot.get("triples", [])]
self.add_triples(run_id, triples)
for episode in snapshot.get("episodes", []):
self.add_episode(run_id, episode.get("content", ""), episode.get("metadata", {}))
for agent_id, memory in snapshot.get("memory", {}).items():
self.write_agent_memory(run_id, agent_id, memory)
return {
"run_id": run_id,
"imported": True,
"entities": len(snapshot.get("entities", [])),
"triples": len(triples),
"episodes": len(snapshot.get("episodes", [])),
}
data = self._read_file_store()
data["runs"][run_id] = {
"entities": {self.normalize_entity(e["name"]): e for e in snapshot.get("entities", [])},
"triples": {self._triple_uuid(run_id, t): t for t in snapshot.get("triples", [])},
"episodes": snapshot.get("episodes", []),
"memory": snapshot.get("memory", {}),
}
self._write_file_store(data)
return {"run_id": run_id, "imported": True}
def clear_run_graph(self, run_id: str) -> Dict[str, Any]:
if self.driver:
with self.driver.session(database=self.neo4j_database) as session:
session.run(
"""
MATCH (n {group_id: $run_id})
DETACH DELETE n
""",
run_id=run_id,
)
return {"run_id": run_id, "cleared": True}
data = self._read_file_store()
data["runs"].pop(run_id, None)
self._write_file_store(data)
return {"run_id": run_id, "cleared": True}
def add_episode(self, run_id: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
metadata_json = json.dumps(metadata or {}, ensure_ascii=False, sort_keys=True)
episode_uuid = self._episode_uuid(run_id, content, metadata_json)
if self.driver:
with self.driver.session(database=self.neo4j_database) as session:
session.run(
"""
MERGE (ep:MiroFishEpisode {group_id: $run_id, uuid: $uuid})
ON CREATE SET ep.created_at = datetime()
SET ep.content = $content,
ep.metadata_json = $metadata_json,
ep.updated_at = datetime()
""",
run_id=run_id,
uuid=episode_uuid,
content=content,
metadata_json=metadata_json,
)
return {"run_id": run_id, "uuid": episode_uuid}
data = self._read_file_store()
run = self._file_run(data, run_id)
episode = {"content": content, "metadata": metadata or {}}
run["episodes"].append(episode)
self._write_file_store(data)
return {"run_id": run_id, "episode_index": len(run["episodes"]) - 1}
def snapshot(self, run_id: str) -> Dict[str, Any]:
if self.driver:
return {
"run_id": run_id,
"provider": "graphiti",
"store": "neo4j",
"entities": self.list_entities(run_id),
"triples": self._list_facts_neo4j(run_id),
"episodes": self._list_episodes_neo4j(run_id),
"memory": self._list_memory_neo4j(run_id),
}
data = self._read_file_store()
run = self._file_run(data, run_id)
return {
"run_id": run_id,
"provider": "graphiti",
"store": "neo4j" if self.driver else "file",
"entities": list(run["entities"].values()),
"triples": list(run["triples"].values()),
"episodes": run["episodes"],
"memory": run["memory"],
}
def timeline(self, run_id: str) -> List[Dict[str, Any]]:
triples = self.snapshot(run_id)["triples"]
return sorted(
[
{
"valid_at": triple.get("valid_at"),
"invalid_at": triple.get("invalid_at"),
"fact": triple.get("fact"),
"source": triple.get("source"),
}
for triple in triples
],
key=lambda item: item.get("valid_at") or "",
)
def _ensure_constraints(self) -> None:
with self.driver.session(database=self.neo4j_database) as session:
session.run(
"CREATE CONSTRAINT mirofish_entity IF NOT EXISTS FOR (e:MiroFishEntity) REQUIRE (e.group_id, e.normalized_name) IS UNIQUE"
)
session.run(
"CREATE CONSTRAINT mirofish_episode IF NOT EXISTS FOR (ep:MiroFishEpisode) REQUIRE (ep.group_id, ep.uuid) IS UNIQUE"
)
session.run(
"CREATE CONSTRAINT mirofish_agent_memory IF NOT EXISTS FOR (m:MiroFishAgentMemory) REQUIRE (m.group_id, m.agent_id) IS UNIQUE"
)
def _list_facts_neo4j(self, run_id: str, limit: int = 100_000) -> List[Dict[str, Any]]:
with self.driver.session(database=self.neo4j_database) as session:
result = session.run(
"""
MATCH (s:MiroFishEntity {group_id: $run_id})-[r:MIROFISH_FACT]->(o:MiroFishEntity {group_id: $run_id})
RETURN s, r, o
LIMIT $limit
""",
run_id=run_id,
limit=limit,
)
return [self._record_to_fact(row) for row in result]
def _list_episodes_neo4j(self, run_id: str) -> List[Dict[str, Any]]:
with self.driver.session(database=self.neo4j_database) as session:
result = session.run(
"""
MATCH (ep:MiroFishEpisode {group_id: $run_id})
RETURN ep
ORDER BY ep.created_at
""",
run_id=run_id,
)
episodes = []
for row in result:
episode = dict(row["ep"].items())
metadata = json.loads(episode.pop("metadata_json", "{}") or "{}")
episodes.append({"content": episode.get("content", ""), "metadata": metadata, "uuid": episode.get("uuid")})
return episodes
def _list_memory_neo4j(self, run_id: str) -> Dict[str, Any]:
with self.driver.session(database=self.neo4j_database) as session:
result = session.run(
"""
MATCH (m:MiroFishAgentMemory {group_id: $run_id})
RETURN m.agent_id AS agent_id, properties(m) AS props
""",
run_id=run_id,
)
return {row["agent_id"]: json.loads((row["props"] or {}).get("memory_json") or "{}") for row in result}
def _add_triplet_neo4j(self, run_id: str, triple: GraphTriple) -> Dict[str, Any]:
subject_norm = self.normalize_entity(triple.subject)
object_norm = self.normalize_entity(triple.object)
triple_id = self._triple_uuid(run_id, triple.model_dump())
with self.driver.session(database=self.neo4j_database) as session:
session.run(
"""
MERGE (s:MiroFishEntity {group_id: $run_id, normalized_name: $subject_norm})
ON CREATE SET s.uuid = $subject_uuid, s.name = $subject, s.labels = ['Entity'], s.created_at = datetime()
MERGE (o:MiroFishEntity {group_id: $run_id, normalized_name: $object_norm})
ON CREATE SET o.uuid = $object_uuid, o.name = $object, o.labels = ['Entity'], o.created_at = datetime()
MERGE (s)-[r:MIROFISH_FACT {uuid: $triple_id, group_id: $run_id}]->(o)
SET r.predicate = $predicate,
r.fact = $fact,
r.valid_at = $valid_at,
r.invalid_at = $invalid_at,
r.source = $source,
r.source_file = $source_file,
r.evidence = $evidence,
r.confidence = $confidence,
r.metadata_json = $metadata_json
""",
run_id=run_id,
subject_norm=subject_norm,
object_norm=object_norm,
subject_uuid=self._entity_uuid(run_id, subject_norm),
object_uuid=self._entity_uuid(run_id, object_norm),
subject=triple.subject,
object=triple.object,
triple_id=triple_id,
predicate=triple.predicate,
fact=triple.fact,
valid_at=triple.valid_at,
invalid_at=triple.invalid_at,
source=triple.source,
source_file=triple.source_file,
evidence=triple.evidence,
confidence=triple.confidence,
metadata_json=json.dumps(triple.metadata, ensure_ascii=False),
)
return {"uuid": triple_id}
def _add_triplet_file(self, run_id: str, triple: GraphTriple) -> Dict[str, Any]:
data = self._read_file_store()
run = self._file_run(data, run_id)
self.get_or_create_entity_node(run_id, triple.subject)
self.get_or_create_entity_node(run_id, triple.object)
data = self._read_file_store()
run = self._file_run(data, run_id)
triple_data = triple.model_dump()
triple_data["uuid"] = self._triple_uuid(run_id, triple_data)
run["triples"][triple_data["uuid"]] = triple_data
self._write_file_store(data)
return {"uuid": triple_data["uuid"]}
def _record_to_fact(self, row: Any) -> Dict[str, Any]:
rel = dict(row["r"].items())
return {
"subject": row["s"].get("name"),
"predicate": rel.get("predicate"),
"object": row["o"].get("name"),
"fact": rel.get("fact"),
"valid_at": rel.get("valid_at"),
"invalid_at": rel.get("invalid_at"),
"source": rel.get("source"),
"source_file": rel.get("source_file"),
"evidence": rel.get("evidence"),
"confidence": rel.get("confidence"),
"metadata": json.loads(rel.get("metadata_json") or "{}"),
"uuid": rel.get("uuid"),
}
def _to_jsonable(self, value: Any) -> Any:
if isinstance(value, dict):
return {key: self._to_jsonable(item) for key, item in value.items()}
if isinstance(value, list):
return [self._to_jsonable(item) for item in value]
if hasattr(value, "iso_format"):
return value.iso_format()
if hasattr(value, "isoformat"):
return value.isoformat()
return value
def _query_terms(self, query: str) -> List[str]:
terms = {query}
terms.update(token for token in re.split(r"\s+", query) if len(token) >= 2)
for chunk in re.findall(r"[\w\u4e00-\u9fff]+", query):
if len(chunk) >= 2:
terms.add(chunk)
if len(chunk) >= 5:
max_size = min(12, len(chunk))
for size in range(max_size, 3, -1):
for start in range(0, len(chunk) - size + 1):
terms.add(chunk[start : start + size])
return sorted(terms, key=len, reverse=True)
def _entity_uuid(self, run_id: str, normalized: str) -> str:
return hashlib.sha256(f"{run_id}:entity:{normalized}".encode("utf-8")).hexdigest()
def _episode_uuid(self, run_id: str, content: str, metadata_json: str) -> str:
return hashlib.sha256(f"{run_id}:episode:{content}:{metadata_json}".encode("utf-8")).hexdigest()
def _memory_uuid(self, run_id: str, agent_id: str) -> str:
return hashlib.sha256(f"{run_id}:memory:{agent_id}".encode("utf-8")).hexdigest()
def _clean_triple_payload(self, triple: Dict[str, Any]) -> Dict[str, Any]:
return {key: triple.get(key) for key in GraphTriple.model_fields}
def _triple_uuid(self, run_id: str, triple: Dict[str, Any] | GraphTriple) -> str:
payload = triple.model_dump() if isinstance(triple, GraphTriple) else triple
stable = json.dumps(
{
"subject": self.normalize_entity(payload["subject"]),
"predicate": payload["predicate"],
"object": self.normalize_entity(payload["object"]),
"fact": payload["fact"],
"valid_at": payload.get("valid_at"),
"invalid_at": payload.get("invalid_at"),
},
ensure_ascii=False,
sort_keys=True,
)
return hashlib.sha256(f"{run_id}:triple:{stable}".encode("utf-8")).hexdigest()
def _file_run(self, data: Dict[str, Any], run_id: str) -> Dict[str, Any]:
return data.setdefault("runs", {}).setdefault(
run_id,
{"entities": {}, "triples": {}, "episodes": [], "memory": {}},
)
def _read_file_store(self) -> Dict[str, Any]:
if not self.store_path.exists():
return {"runs": {}}
return json.loads(self.store_path.read_text(encoding="utf-8"))
def _write_file_store(self, data: Dict[str, Any]) -> None:
self.store_path.parent.mkdir(parents=True, exist_ok=True)
self.store_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
class GraphitiGraphProvider(GraphProvider):
name = "graphiti"
def __init__(self, store: Optional[GraphitiCompatibilityStore] = None, *, require_graphiti_package: bool = False):
if require_graphiti_package and importlib.util.find_spec("graphiti_core") is None:
raise GraphitiDependencyError(
"graphiti_core is not installed. Install Graphiti or use the no-LLM compatibility store."
)
self.store = store or GraphitiCompatibilityStore()
def add_episode(self, run_id: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.store.add_episode(run_id, content, metadata)
def add_triples(self, run_id: str, triples: List[Dict[str, Any] | GraphTriple]) -> Dict[str, Any]:
parsed = [triple if isinstance(triple, GraphTriple) else GraphTriple.model_validate(triple) for triple in triples]
return self.store.add_triples(run_id, parsed)
def search(self, run_id: str, query: str, limit: int = 20) -> List[Dict[str, Any]]:
facts = self.store.search_facts(run_id, query, limit)
if len(facts) < limit:
facts.extend({"node": node} for node in self.store.search_nodes(run_id, query, limit - len(facts)))
return facts[:limit]
def neighbors(self, run_id: str, entity: str, depth: int = 2) -> List[Dict[str, Any]]:
return self.store.neighbors(run_id, entity, depth)
def list_entities(self, run_id: str) -> List[Dict[str, Any]]:
return self.store.list_entities(run_id)
def get_entity(self, run_id: str, entity: str) -> Optional[Dict[str, Any]]:
return self.store.get_entity(run_id, entity)
def update_memory(self, run_id: str, agent_id: str, memory: Dict[str, Any]) -> Dict[str, Any]:
return self.store.update_memory(run_id, agent_id, memory)
def get_agent_memory(self, run_id: str, agent_id: str) -> Dict[str, Any]:
return self.store.get_agent_memory(run_id, agent_id)
def write_agent_memory(self, run_id: str, agent_id: str, memory: Dict[str, Any]) -> Dict[str, Any]:
return self.store.write_agent_memory(run_id, agent_id, memory)
def export_snapshot(self, run_id: str, output_path: str) -> Dict[str, Any]:
return self.store.export_snapshot(run_id, output_path)
def import_snapshot(self, run_id: str, input_path: str) -> Dict[str, Any]:
return self.store.import_snapshot(run_id, input_path)
def clear_run_graph(self, run_id: str) -> Dict[str, Any]:
return self.store.clear_run_graph(run_id)
def export_timeline(self, run_id: str, output_path: str) -> Dict[str, Any]:
timeline = self.store.timeline(run_id)
path = Path(output_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(timeline, ensure_ascii=False, indent=2), encoding="utf-8")
return {"output_path": str(path), "events": len(timeline)}

View File

@ -0,0 +1,117 @@
"""Legacy Zep Cloud graph provider.
This is the only backend/app path allowed to import the Zep SDK.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, List, Optional
from .base import GraphProvider, GraphTriple
from ...config import Config
class ZepGraphProvider(GraphProvider):
name = "zep"
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or Config.ZEP_API_KEY
if not self.api_key:
raise RuntimeError("ZEP_API_KEY is required for legacy zep graph provider")
try:
from zep_cloud.client import Zep
except ImportError as exc:
raise RuntimeError(
"zep-cloud package is required for legacy zep graph provider; "
"install with `uv sync --extra legacy`"
) from exc
self.client = Zep(api_key=self.api_key)
def add_episode(self, run_id: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
try:
from zep_cloud import EpisodeData
except ImportError as exc:
raise RuntimeError(
"zep-cloud package is required for legacy zep graph provider; "
"install with `uv sync --extra legacy`"
) from exc
result = self.client.graph.add(graph_id=run_id, data=EpisodeData(data=content, type="text", metadata=metadata or {}))
return {"run_id": run_id, "result": str(result)}
def add_triples(self, run_id: str, triples: List[Dict[str, Any] | GraphTriple]) -> Dict[str, Any]:
parsed = [triple if isinstance(triple, GraphTriple) else GraphTriple.model_validate(triple) for triple in triples]
content = "\n".join(triple.fact for triple in parsed)
self.add_episode(run_id, content, {"source": "agent_triples"})
return {"run_id": run_id, "triples_added": len(parsed), "mode": "zep_episode_compat"}
def search(self, run_id: str, query: str, limit: int = 20) -> List[Dict[str, Any]]:
result = self.client.graph.search(graph_id=run_id, query=query, limit=limit, scope="edges", reranker="rrf")
rows = []
for edge in getattr(result, "edges", []) or []:
rows.append(
{
"uuid": getattr(edge, "uuid_", None) or getattr(edge, "uuid", ""),
"predicate": getattr(edge, "name", ""),
"fact": getattr(edge, "fact", ""),
"source_node_uuid": getattr(edge, "source_node_uuid", ""),
"target_node_uuid": getattr(edge, "target_node_uuid", ""),
}
)
return rows
def neighbors(self, run_id: str, entity: str, depth: int = 2) -> List[Dict[str, Any]]:
matches = self.search(run_id, entity, limit=50)
return matches[: max(depth, 1) * 20]
def list_entities(self, run_id: str) -> List[Dict[str, Any]]:
nodes = self.client.graph.node.get_by_graph_id(graph_id=run_id)
return [self._node_to_dict(node) for node in nodes]
def get_entity(self, run_id: str, entity: str) -> Optional[Dict[str, Any]]:
entity_lower = entity.casefold()
for node in self.list_entities(run_id):
if node.get("name", "").casefold() == entity_lower:
return node
return None
def update_memory(self, run_id: str, agent_id: str, memory: Dict[str, Any]) -> Dict[str, Any]:
return self.write_agent_memory(run_id, agent_id, memory)
def get_agent_memory(self, run_id: str, agent_id: str) -> Dict[str, Any]:
result = self.search(run_id, f"agent memory {agent_id}", limit=10)
return {"agent_id": agent_id, "facts": result}
def write_agent_memory(self, run_id: str, agent_id: str, memory: Dict[str, Any]) -> Dict[str, Any]:
self.add_episode(run_id, json.dumps({"agent_id": agent_id, "memory": memory}, ensure_ascii=False), {"type": "agent_memory"})
return {"run_id": run_id, "agent_id": agent_id, "memory": memory}
def export_snapshot(self, run_id: str, output_path: str) -> Dict[str, Any]:
nodes = self.list_entities(run_id)
edges = self.search(run_id, "", limit=1000)
snapshot = {"run_id": run_id, "provider": "zep", "entities": nodes, "triples": edges}
path = Path(output_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2), encoding="utf-8")
return {"output_path": str(path), "nodes": len(nodes), "triples": len(edges)}
def import_snapshot(self, run_id: str, input_path: str) -> Dict[str, Any]:
data = json.loads(Path(input_path).read_text(encoding="utf-8"))
self.add_episode(run_id, json.dumps(data, ensure_ascii=False), {"type": "snapshot_import"})
return {"run_id": run_id, "imported": True, "mode": "zep_episode_compat"}
def clear_run_graph(self, run_id: str) -> Dict[str, Any]:
self.client.graph.delete(graph_id=run_id)
return {"run_id": run_id, "cleared": True}
def _node_to_dict(self, node: Any) -> Dict[str, Any]:
return {
"uuid": getattr(node, "uuid_", None) or getattr(node, "uuid", ""),
"name": getattr(node, "name", ""),
"labels": getattr(node, "labels", []) or [],
"summary": getattr(node, "summary", "") or "",
"attributes": getattr(node, "attributes", {}) or {},
}

View File

@ -0,0 +1,6 @@
"""LLM provider adapters."""
from .agent_runtime import AgentRuntime, NeedAgentResponse
from .factory import create_llm_provider
__all__ = ["AgentRuntime", "NeedAgentResponse", "create_llm_provider"]

View File

@ -0,0 +1,42 @@
"""LLM provider that delegates model work to external desktop agents."""
from __future__ import annotations
from pathlib import Path
from .base import LLMProvider, LLMProviderResult, LLMTask
from ...agent_engine.queue import AgentQueue
class AgentQueueLLMProvider(LLMProvider):
name = "agent_queue"
def __init__(self, run_dir: str | Path | None = None):
self.run_dir = Path(run_dir) if run_dir else None
def run_task(self, task: LLMTask) -> LLMProviderResult:
if not self.run_dir:
raise RuntimeError("agent_queue provider requires run_dir")
queue = AgentQueue(self.run_dir)
need = queue.create_request(
run_id=task.run_id,
task_type=task.task_type,
stage=task.stage,
expected_schema=task.expected_schema,
input_text=task.input_text,
input_files=task.input_files,
structured_input=task.structured_input,
system_prompt=task.system_prompt,
user_prompt=task.user_prompt,
validation_rules=task.validation_rules,
retry_policy=task.retry_policy,
context_refs=task.context_refs,
output_contract=task.output_contract,
)
return LLMProviderResult(
status="need_agent_response",
request_id=need.request_id,
request_file=need.request_file,
expected_response_file=need.expected_response_file,
)

View File

@ -0,0 +1,62 @@
"""Business-facing model runtime facade."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from .base import LLMProvider, LLMProviderResult, LLMTask
from .factory import create_llm_provider
class NeedAgentResponse(RuntimeError):
def __init__(self, result: LLMProviderResult):
self.result = result
super().__init__(f"Agent response required: {result.request_file}")
class AgentRuntime:
def __init__(self, provider: Optional[LLMProvider] = None, *, run_dir: str | None = None):
self.provider = provider or create_llm_provider(run_dir=run_dir)
self.run_dir = run_dir
def run_task(
self,
*,
run_id: str,
task_type: str,
stage: str,
expected_schema: Dict[str, Any],
input_text: Optional[str] = None,
input_files: Optional[List[str]] = None,
structured_input: Optional[Dict[str, Any]] = None,
system_prompt: str = "",
user_prompt: str = "",
validation_rules: Optional[Dict[str, Any]] = None,
retry_policy: Optional[Dict[str, Any]] = None,
context_refs: Optional[List[str]] = None,
output_contract: Optional[Dict[str, Any]] = None,
) -> LLMProviderResult:
task = LLMTask(
run_id=run_id,
task_type=task_type,
stage=stage,
expected_schema=expected_schema,
input_text=input_text,
input_files=input_files,
structured_input=structured_input,
system_prompt=system_prompt,
user_prompt=user_prompt,
validation_rules=validation_rules,
retry_policy=retry_policy,
context_refs=context_refs,
output_contract=output_contract,
)
return self.provider.run_task(task)
def require_output(self, **kwargs: Any) -> Dict[str, Any]:
result = self.run_task(**kwargs)
if result.status == "need_agent_response":
raise NeedAgentResponse(result)
if result.status != "ok":
raise RuntimeError(result.error or f"LLM task failed with status {result.status}")
return result.output or {}

View File

@ -0,0 +1,57 @@
"""Unified business-facing LLM provider interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
@dataclass
class LLMTask:
run_id: str
task_type: str
stage: str
expected_schema: Dict[str, Any]
input_text: Optional[str] = None
input_files: Optional[List[str]] = None
structured_input: Optional[Dict[str, Any]] = None
system_prompt: str = ""
user_prompt: str = ""
validation_rules: Optional[Dict[str, Any]] = None
retry_policy: Optional[Dict[str, Any]] = None
context_refs: Optional[List[str]] = None
output_contract: Optional[Dict[str, Any]] = None
@dataclass
class LLMProviderResult:
status: str
output: Optional[Dict[str, Any]] = None
request_id: Optional[str] = None
request_file: Optional[str] = None
expected_response_file: Optional[str] = None
error: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
data = {
"status": self.status,
"output": self.output,
"request_id": self.request_id,
"request_file": self.request_file,
"expected_response_file": self.expected_response_file,
"error": self.error,
}
return {key: value for key, value in data.items() if value is not None}
class LLMProvider(ABC):
name: str
@abstractmethod
def run_task(self, task: LLMTask) -> LLMProviderResult:
raise NotImplementedError
class ProviderConfigurationError(RuntimeError):
pass

View File

@ -0,0 +1,236 @@
"""Bridge CAMEL/OASIS model calls into AgentRuntime."""
from __future__ import annotations
import json
import time
from typing import Any, Dict, Iterable, List, Optional, Type
from ...agent_engine.json_schema import object_schema
from .base import LLMProviderResult
from .agent_runtime import AgentRuntime
from camel.messages import OpenAIMessage
from camel.models import BaseModelBackend
from camel.types import (
ChatCompletion,
ChatCompletionChunk,
ChatCompletionMessage,
Choice,
CompletionUsage,
ModelType,
)
from camel.utils import BaseTokenCounter
from pydantic import BaseModel
SIMULATE_ACTION_SCHEMA = object_schema(
{
"actions": {
"type": "array",
"items": object_schema(
{
"agent_id": {"type": "string"},
"action_id": {"type": "string"},
"action_type": {"type": "string"},
"content": {"type": "string"},
}
),
}
}
)
class AgentRuntimeTokenCounter(BaseTokenCounter):
def count_tokens_from_messages(self, messages: List[OpenAIMessage]) -> int:
return sum(len(str(message.get("content", ""))) // 4 + 1 for message in messages)
def encode(self, text: str) -> List[int]:
return [0] * (len(text) // 4 + 1)
def decode(self, token_ids: List[int]) -> str:
return ""
class AgentModelBackendAdapter(BaseModelBackend):
"""CAMEL-compatible model backend backed by AgentRuntime.
CAMEL/OASIS scripts can use this adapter instead of directly constructing
model SDK clients. Batch calls are preferred for same-round actions.
"""
def __init__(self, run_id: str, run_dir: str, runtime: Optional[AgentRuntime] = None):
super().__init__(model_type=ModelType.STUB, model_config_dict={})
self.run_id = run_id
self.run_dir = run_dir
self.runtime = runtime or AgentRuntime(run_dir=run_dir)
self.last_need_agent_response: Optional[Dict[str, Any]] = None
@property
def token_counter(self) -> BaseTokenCounter:
if not self._token_counter:
self._token_counter = AgentRuntimeTokenCounter()
return self._token_counter
def run_batch_actions(self, round_id: str, actions: Iterable[Dict[str, Any]]) -> Dict[str, Any]:
action_list = list(actions)
result = self.runtime.run_task(
run_id=self.run_id,
task_type="simulate_agent_action",
stage="simulation",
expected_schema=SIMULATE_ACTION_SCHEMA,
structured_input={"round_id": round_id, "actions": action_list},
system_prompt="Generate simulation actions for the requested agents.",
user_prompt="Return JSON with actions keyed by agent_id and action_id.",
output_contract={"batch_key": ["agent_id", "action_id"]},
)
if result.status == "need_agent_response":
self.last_need_agent_response = result.to_dict()
return result.to_dict()
def run_single_action(self, agent_id: str, action_id: str, prompt: str) -> Dict[str, Any]:
return self.run_batch_actions(
round_id="single",
actions=[{"agent_id": agent_id, "action_id": action_id, "prompt": prompt}],
)
def _run(
self,
messages: List[OpenAIMessage],
response_format: Optional[Type[BaseModel]] = None,
tools: Optional[List[Dict[str, Any]]] = None,
) -> ChatCompletion:
result = self._run_model_task(messages, tools)
return self._to_chat_completion(result, tools)
async def _arun(
self,
messages: List[OpenAIMessage],
response_format: Optional[Type[BaseModel]] = None,
tools: Optional[List[Dict[str, Any]]] = None,
) -> ChatCompletion:
result = self._run_model_task(messages, tools)
return self._to_chat_completion(result, tools)
def _run_model_task(self, messages: List[OpenAIMessage], tools: Optional[List[Dict[str, Any]]]) -> LLMProviderResult:
return self.runtime.run_task(
run_id=self.run_id,
task_type="simulate_agent_action",
stage="simulation_runtime",
expected_schema=SIMULATE_ACTION_SCHEMA,
structured_input={
"messages": messages,
"tools": tools or [],
"actions": [
{
"agent_id": self._extract_agent_id(messages),
"action_id": f"camel_{int(time.time() * 1000)}",
"prompt": messages[-1].get("content", "") if messages else "",
}
],
},
system_prompt="You are driving one OASIS/CAMEL social simulation agent action.",
user_prompt=json.dumps(messages, ensure_ascii=False),
output_contract={"camel_model_backend": True},
)
def _to_chat_completion(self, result: LLMProviderResult, tools: Optional[List[Dict[str, Any]]]) -> ChatCompletion:
if result.status == "need_agent_response":
self.last_need_agent_response = result.to_dict()
content = json.dumps(self.last_need_agent_response, ensure_ascii=False)
tool_calls = self._tool_calls_for_actions([{"action_type": "DO_NOTHING", "action_args": {}}], tools)
return self._completion(content=content, tool_calls=tool_calls)
if result.status != "ok":
return self._completion(content=result.error or "AgentRuntime model task failed")
output = result.output or {}
actions = output.get("actions") or []
tool_calls = self._tool_calls_for_actions(actions, tools)
content = output.get("content") or output.get("text") or json.dumps(output, ensure_ascii=False)
return self._completion(content=content if not tool_calls else "", tool_calls=tool_calls)
def _tool_calls_for_actions(
self,
actions: List[Dict[str, Any]],
tools: Optional[List[Dict[str, Any]]],
) -> Optional[List[Dict[str, Any]]]:
available = self._available_tool_names(tools)
if not available:
return None
calls = []
for index, action in enumerate(actions[:1]):
tool_name = self._action_to_tool_name(action.get("action_type", "DO_NOTHING"))
if tool_name not in available:
tool_name = "do_nothing" if "do_nothing" in available else sorted(available)[0]
args = action.get("action_args") or self._action_args(action)
calls.append(
{
"id": f"call_agent_runtime_{index}",
"type": "function",
"function": {
"name": tool_name,
"arguments": json.dumps(args, ensure_ascii=False),
},
}
)
return calls or None
def _available_tool_names(self, tools: Optional[List[Dict[str, Any]]]) -> set[str]:
names = set()
for tool in tools or []:
function = tool.get("function", {}) if isinstance(tool, dict) else {}
name = function.get("name")
if name:
names.add(name)
return names
def _action_to_tool_name(self, action_type: str) -> str:
return str(action_type or "DO_NOTHING").lower()
def _action_args(self, action: Dict[str, Any]) -> Dict[str, Any]:
if action.get("content"):
return {"content": action["content"]}
return {}
def _extract_agent_id(self, messages: List[OpenAIMessage]) -> str:
for message in messages:
content = str(message.get("content", ""))
if "Agent" in content:
return "camel_agent"
return "camel_agent"
def _completion(
self,
*,
content: str,
tool_calls: Optional[List[Dict[str, Any]]] = None,
) -> ChatCompletion:
message = ChatCompletionMessage(
content=None if tool_calls else content,
role="assistant",
tool_calls=tool_calls,
)
return ChatCompletion(
id=f"agent-runtime-{int(time.time() * 1000)}",
model="mirofish-agent-runtime",
object="chat.completion",
created=int(time.time()),
choices=[
Choice(
finish_reason="tool_calls" if tool_calls else "stop",
index=0,
message=message,
logprobs=None,
)
],
usage=CompletionUsage(
completion_tokens=max(1, len(content) // 4),
prompt_tokens=1,
total_tokens=max(2, len(content) // 4 + 1),
),
)
def create_model_backend(run_id: str, run_dir: str) -> AgentModelBackendAdapter:
return AgentModelBackendAdapter(run_id=run_id, run_dir=run_dir)

View File

@ -0,0 +1,28 @@
"""LLM provider factory."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Optional
from .agent_queue import AgentQueueLLMProvider
from .base import LLMProvider
from .mock import MockLLMProvider
from .openai_compatible import OpenAICompatibleProvider
def create_llm_provider(provider: Optional[str] = None, *, run_dir: str | Path | None = None) -> LLMProvider:
provider_name = provider or os.environ.get("MIROFISH_LLM_PROVIDER")
if not provider_name:
mode = os.environ.get("MIROFISH_MODE", "agent")
provider_name = "agent_queue" if mode == "agent" else "openai_compatible"
provider_name = provider_name.lower()
if provider_name == "agent_queue":
return AgentQueueLLMProvider(run_dir=run_dir)
if provider_name == "mock":
return MockLLMProvider()
if provider_name == "openai_compatible":
return OpenAICompatibleProvider()
raise ValueError(f"Unsupported MIROFISH_LLM_PROVIDER: {provider_name}")

View File

@ -0,0 +1,102 @@
"""Deterministic provider for tests and offline smoke runs."""
from __future__ import annotations
from typing import Any, Dict
from .base import LLMProvider, LLMProviderResult, LLMTask
class MockLLMProvider(LLMProvider):
name = "mock"
def run_task(self, task: LLMTask) -> LLMProviderResult:
output = self._output_for(task)
return LLMProviderResult(status="ok", output=output)
def _output_for(self, task: LLMTask) -> Dict[str, Any]:
required = task.expected_schema.get("required", []) if task.expected_schema else []
if "text" in required:
return {"text": "Mock text response generated without model APIs."}
if task.task_type == "generate_ontology":
return {
"ontology": {
"entity_types": [{"name": "Organization", "description": "An organization"}],
"edge_types": [{"name": "AFFECTS", "description": "Affects another entity"}],
}
}
if task.task_type == "extract_triples":
return {
"triples": [
{
"subject": "Seed Entity",
"predicate": "relates_to",
"object": "Prediction Topic",
"fact": "Seed Entity relates to Prediction Topic.",
"valid_at": None,
"invalid_at": None,
"source": "mock",
"source_file": None,
"evidence": "mock evidence",
"confidence": 0.5,
"metadata": {},
}
]
}
if task.task_type == "generate_oasis_profiles":
return {
"profiles": [
{
"agent_id": "agent_1",
"name": "Seed Analyst",
"persona": "Tracks seed facts and reacts conservatively.",
}
]
}
if task.task_type == "generate_simulation_config":
return {"config": {"rounds": 1, "agents": ["agent_1"], "platforms": ["agent_queue"]}}
if task.task_type == "simulate_agent_action":
actions = []
for item in (task.structured_input or {}).get("actions", []):
actions.append(
{
"agent_id": item.get("agent_id"),
"action_id": item.get("action_id"),
"action_type": "CREATE_POST",
"content": "Mock simulated action.",
}
)
return {"actions": actions or [{"agent_id": "agent_1", "action_id": "act_1", "action_type": "CREATE_POST", "content": "Mock simulated action."}]}
if task.task_type == "summarize_round":
return {
"summary_markdown": "Mock round summary generated without model APIs.",
"key_events": [],
"memory_updates": [],
}
if task.task_type == "update_memory":
return {
"memory": (task.structured_input or {}).get("memory", {}),
"events": (task.structured_input or {}).get("events", []),
}
if task.task_type == "generate_report":
return {
"report_markdown": "# MiroFish Agent Report\n\nMock report generated without model APIs.",
"verdict": {"status": "mock", "confidence": 0.5},
"timeline": [{"step": "mock", "summary": "Mock simulation completed."}],
}
if task.task_type == "answer_followup_question":
return {
"answer_markdown": "Mock follow-up answer generated without model APIs.",
"used_graph_results": (task.structured_input or {}).get("graph_results", []),
"confidence": 0.5,
}
if task.task_type == "validate_json_output":
return {
"valid": True,
"errors": [],
"output": (task.structured_input or {}).get("candidate", {}),
}
if task.task_type == "repair_invalid_json":
invalid_response = (task.structured_input or {}).get("invalid_response", {})
return invalid_response.get("output", {})
return {"result": task.structured_input or {}}

View File

@ -0,0 +1,54 @@
"""Legacy OpenAI-compatible provider.
This is the only backend/app path allowed to import the OpenAI SDK.
"""
from __future__ import annotations
import json
import re
from typing import Any, Dict
from .base import LLMProvider, LLMProviderResult, LLMTask, ProviderConfigurationError
from ...config import Config
class OpenAICompatibleProvider(LLMProvider):
name = "openai_compatible"
def __init__(self, api_key: str | None = None, base_url: str | None = None, model: str | None = None):
self.api_key = api_key or Config.LLM_API_KEY
self.base_url = base_url or Config.LLM_BASE_URL
self.model = model or Config.LLM_MODEL_NAME
if not self.api_key:
raise ProviderConfigurationError("LLM_API_KEY is required for openai_compatible provider")
try:
from openai import OpenAI
except ImportError as exc:
raise ProviderConfigurationError(
"openai package is required for openai_compatible legacy provider; "
"install with `uv sync --extra legacy`"
) from exc
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
def run_task(self, task: LLMTask) -> LLMProviderResult:
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": task.system_prompt or "Return valid JSON."},
{"role": "user", "content": task.user_prompt or task.input_text or json.dumps(task.structured_input or {}, ensure_ascii=False)},
],
response_format={"type": "json_object"},
temperature=0.3,
)
content = response.choices[0].message.content or "{}"
content = re.sub(r"<think>[\s\S]*?</think>", "", content).strip()
content = re.sub(r"^```(?:json)?\s*\n?", "", content, flags=re.IGNORECASE)
content = re.sub(r"\n?```\s*$", "", content).strip()
try:
output: Dict[str, Any] = json.loads(content)
except json.JSONDecodeError as exc:
return LLMProviderResult(status="error", error=f"invalid JSON from openai_compatible provider: {exc}")
return LLMProviderResult(status="ok", output=output)

View File

@ -0,0 +1,283 @@
"""Agent-friendly MiroFish CLI."""
from __future__ import annotations
import argparse
import json
import sys
from typing import Any, Dict
from .runner import PredictionRunService
def emit(result: Dict[str, Any], as_json: bool) -> None:
if as_json:
print(json.dumps(result, ensure_ascii=False, indent=2))
return
status = result.get("status")
if status == "need_agent_response":
print(f"need_agent_response: {result['request_id']}")
print(f"request_file: {result['request_file']}")
print(f"expected_response_file: {result['expected_response_file']}")
elif status == "created":
print(f"created run: {result['run_id']}")
print(f"run_dir: {result['run_dir']}")
elif status == "awaiting_user_confirmation":
print(f"awaiting_user_confirmation: {result['stage']}")
elif status == "ok":
print(json.dumps(result, ensure_ascii=False, indent=2))
elif status == "completed":
print("completed")
print(json.dumps(result.get("artifacts", []), ensure_ascii=False, indent=2))
else:
print(json.dumps(result, ensure_ascii=False, indent=2))
def add_json(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--json", action="store_true", help="Emit stable JSON output")
def add_create_run_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--seed", required=True)
parser.add_argument("--requirement", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--mode", choices=["auto", "staged"], default="auto")
parser.add_argument("--rounds", type=int, default=10)
parser.add_argument("--round-unit", choices=["year", "month", "day", "step"], default="year")
parser.add_argument("--minutes-per-round", type=int, default=None)
parser.add_argument("--pause-each-round", action=argparse.BooleanOptionalAction, default=False)
parser.add_argument("--agent-count", type=int, default=None)
parser.add_argument("--simulation-name", default=None)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="mirofish-agent")
add_json(parser)
sub = parser.add_subparsers(dest="command", required=True)
init = sub.add_parser("init")
add_json(init)
add_create_run_args(init)
create_run = sub.add_parser("create-run")
add_json(create_run)
add_create_run_args(create_run)
run = sub.add_parser("run")
add_json(run)
run.add_argument("--run", required=True)
resume = sub.add_parser("resume")
add_json(resume)
resume.add_argument("--run", required=True)
status = sub.add_parser("status")
add_json(status)
status.add_argument("--run", required=True)
stage = sub.add_parser("stage")
stage_sub = stage.add_subparsers(dest="stage_command", required=True)
stage_status = stage_sub.add_parser("status")
add_json(stage_status)
stage_status.add_argument("--run", required=True)
stage_next = stage_sub.add_parser("next")
add_json(stage_next)
stage_next.add_argument("--run", required=True)
stage_approve = stage_sub.add_parser("approve")
add_json(stage_approve)
stage_approve.add_argument("--run", required=True)
stage_reject = stage_sub.add_parser("reject")
add_json(stage_reject)
stage_reject.add_argument("--run", required=True)
stage_reject.add_argument("--reason", default="")
stage_update = stage_sub.add_parser("update-settings")
add_json(stage_update)
stage_update.add_argument("--run", required=True)
stage_update.add_argument("--rounds", type=int, default=None)
stage_update.add_argument("--round-unit", choices=["year", "month", "day", "step"], default=None)
stage_update.add_argument("--minutes-per-round", type=int, default=None)
stage_update.add_argument("--pause-each-round", action=argparse.BooleanOptionalAction, default=None)
stage_update.add_argument("--agent-count", type=int, default=None)
stage_update.add_argument("--simulation-name", default=None)
stage_rerun = stage_sub.add_parser("rerun")
add_json(stage_rerun)
stage_rerun.add_argument("--run", required=True)
stage_rerun.add_argument("--stage", required=True)
requests = sub.add_parser("requests")
req_sub = requests.add_subparsers(dest="requests_command", required=True)
req_list = req_sub.add_parser("list")
add_json(req_list)
req_list.add_argument("--run", required=True)
req_show = req_sub.add_parser("show")
add_json(req_show)
req_show.add_argument("--run", required=True)
req_show.add_argument("--request-id", required=True)
responses = sub.add_parser("responses")
resp_sub = responses.add_subparsers(dest="responses_command", required=True)
resp_validate = resp_sub.add_parser("validate")
add_json(resp_validate)
resp_validate.add_argument("--run", required=True)
resp_validate.add_argument("--response", required=True)
resp_submit = resp_sub.add_parser("submit")
add_json(resp_submit)
resp_submit.add_argument("--run", required=True)
resp_submit.add_argument("--response", required=True)
graph = sub.add_parser("graph")
graph_sub = graph.add_subparsers(dest="graph_command", required=True)
graph_build = graph_sub.add_parser("build")
add_json(graph_build)
graph_build.add_argument("--run", required=True)
graph_build.add_argument("--provider", default=None)
graph_build.add_argument("--mode", default="agent-triples")
graph_search = graph_sub.add_parser("search")
add_json(graph_search)
graph_search.add_argument("--run", required=True)
graph_search.add_argument("--query", required=True)
graph_search.add_argument("--limit", type=int, default=20)
graph_export = graph_sub.add_parser("export")
add_json(graph_export)
graph_export.add_argument("--run", required=True)
graph_export.add_argument("--output", default=None)
simulate = sub.add_parser("simulate")
sim_sub = simulate.add_subparsers(dest="simulate_command", required=True)
sim_start = sim_sub.add_parser("start")
add_json(sim_start)
sim_start.add_argument("--run", required=True)
sim_resume = sim_sub.add_parser("resume")
add_json(sim_resume)
sim_resume.add_argument("--run", required=True)
sim_status = sim_sub.add_parser("status")
add_json(sim_status)
sim_status.add_argument("--run", required=True)
report = sub.add_parser("report")
report_sub = report.add_subparsers(dest="report_command", required=True)
report_generate = report_sub.add_parser("generate")
add_json(report_generate)
report_generate.add_argument("--run", required=True)
report_show = report_sub.add_parser("show")
add_json(report_show)
report_show.add_argument("--run", required=True)
followup = sub.add_parser("followup")
followup_sub = followup.add_subparsers(dest="followup_command", required=True)
followup_ask = followup_sub.add_parser("ask")
add_json(followup_ask)
followup_ask.add_argument("--run", required=True)
followup_ask.add_argument("--question", required=True)
followup_ask.add_argument("--limit", type=int, default=20)
followup_show = followup_sub.add_parser("show")
add_json(followup_show)
followup_show.add_argument("--run", required=True)
followup_show.add_argument("--request-id", required=True)
artifacts = sub.add_parser("artifacts")
artifacts_sub = artifacts.add_subparsers(dest="artifacts_command", required=True)
artifacts_list = artifacts_sub.add_parser("list")
add_json(artifacts_list)
artifacts_list.add_argument("--run", required=True)
doctor = sub.add_parser("doctor")
add_json(doctor)
doctor.add_argument("--runs-dir", default=None)
return parser
def dispatch(args: argparse.Namespace) -> Dict[str, Any]:
service = PredictionRunService()
if args.command in {"init", "create-run"}:
return service.create_run(
args.seed,
args.requirement,
args.output,
mode=args.mode,
rounds=args.rounds,
round_unit=args.round_unit,
minutes_per_round=args.minutes_per_round,
pause_each_round=args.pause_each_round,
agent_count=args.agent_count,
simulation_name=args.simulation_name,
)
if args.command == "run":
return service.run(args.run)
if args.command == "resume":
return service.resume(args.run)
if args.command == "status":
return service.status(args.run)
if args.command == "stage":
if args.stage_command == "status":
return service.get_current_stage(args.run)
if args.stage_command == "next":
return service.resume(args.run)
if args.stage_command == "approve":
return service.approve_stage(args.run)
if args.stage_command == "reject":
return service.reject_stage(args.run, args.reason)
if args.stage_command == "update-settings":
return service.update_simulation_settings(
args.run,
rounds=args.rounds,
round_unit=args.round_unit,
minutes_per_round=args.minutes_per_round,
pause_each_round=args.pause_each_round,
agent_count=args.agent_count,
simulation_name=args.simulation_name,
)
return service.rerun_stage(args.run, args.stage)
if args.command == "requests":
if args.requests_command == "list":
return service.list_requests(args.run)
return service.get_request(args.run, args.request_id)
if args.command == "responses":
if args.responses_command == "validate":
return service.validate_response(args.run, args.response)
return service.submit_response(args.run, args.response)
if args.command == "graph":
if args.graph_command == "build":
return service.build_graph(args.run, provider=args.provider, mode=args.mode)
if args.graph_command == "search":
return service.search_graph(args.run, args.query, args.limit)
return service.export_graph(args.run, args.output)
if args.command == "simulate":
if args.simulate_command == "start":
return service.start_simulation(args.run)
if args.simulate_command == "resume":
return service.resume(args.run)
return service.simulation_status(args.run)
if args.command == "report":
if args.report_command == "generate":
return service.generate_report(args.run)
return service.get_report(args.run)
if args.command == "followup":
if args.followup_command == "ask":
return service.ask_followup_question(args.run, args.question, args.limit)
return service.get_followup_answer(args.run, args.request_id)
if args.command == "artifacts":
return service.list_artifacts(args.run)
if args.command == "doctor":
return service.doctor(args.runs_dir)
raise ValueError(f"unsupported command: {args.command}")
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
as_json = bool(getattr(args, "json", False))
try:
result = dispatch(args)
except Exception as exc:
result = {"status": "error", "error": str(exc), "error_type": exc.__class__.__name__}
emit(result, as_json)
return 1
emit(result, as_json)
return 0 if result.get("status") not in {"failed", "error"} else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,148 @@
"""Expected output contracts for all agent task types."""
from __future__ import annotations
from typing import Any, Dict
from .json_schema import TRIPLE_SCHEMA, object_schema
ONTOLOGY_OUTPUT_SCHEMA = object_schema({"ontology": {"type": "object"}}, ["ontology"])
TRIPLES_OUTPUT_SCHEMA = object_schema(
{"triples": {"type": "array", "items": TRIPLE_SCHEMA}},
["triples"],
)
PROFILES_OUTPUT_SCHEMA = object_schema(
{"profiles": {"type": "array", "items": {"type": "object"}}},
["profiles"],
)
SIMULATION_CONFIG_OUTPUT_SCHEMA = object_schema({"config": {"type": "object"}}, ["config"])
SIMULATE_ACTION_OUTPUT_SCHEMA = object_schema(
{
"actions": {
"type": "array",
"items": object_schema(
{
"agent_id": {"type": "string"},
"action_id": {"type": "string"},
"action_type": {"type": "string"},
"content": {"type": "string"},
},
["agent_id", "action_id", "action_type", "content"],
),
}
},
["actions"],
)
REPORT_OUTPUT_SCHEMA = object_schema(
{
"report_markdown": {"type": "string", "minLength": 1},
"verdict": {"type": "object"},
"timeline": {"type": "array", "items": {"type": "object"}},
},
["report_markdown", "verdict", "timeline"],
)
FOLLOWUP_OUTPUT_SCHEMA = object_schema(
{
"answer_markdown": {"type": "string", "minLength": 1},
"used_graph_results": {"type": "array", "items": {"type": "object"}},
"confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
},
["answer_markdown", "used_graph_results", "confidence"],
)
ROUND_SUMMARY_OUTPUT_SCHEMA = object_schema(
{
"summary_markdown": {"type": "string", "minLength": 1},
"key_events": {"type": "array", "items": {"type": "object"}},
"memory_updates": {"type": "array", "items": {"type": "object"}},
},
["summary_markdown", "key_events", "memory_updates"],
)
MEMORY_UPDATE_OUTPUT_SCHEMA = object_schema(
{
"memory": {"type": "object"},
"events": {"type": "array", "items": {"type": "object"}},
},
["memory", "events"],
)
VALIDATE_JSON_OUTPUT_SCHEMA = object_schema(
{
"valid": {"type": "boolean"},
"errors": {"type": "array", "items": {"type": "string"}},
"output": {"type": "object"},
},
["valid", "errors", "output"],
)
GENERIC_REPAIR_OUTPUT_SCHEMA: Dict[str, Any] = {
"type": "object",
"additionalProperties": True,
}
TASK_OUTPUT_SCHEMAS: Dict[str, Dict[str, Any]] = {
"extract_triples": TRIPLES_OUTPUT_SCHEMA,
"generate_ontology": ONTOLOGY_OUTPUT_SCHEMA,
"generate_oasis_profiles": PROFILES_OUTPUT_SCHEMA,
"generate_simulation_config": SIMULATION_CONFIG_OUTPUT_SCHEMA,
"simulate_agent_action": SIMULATE_ACTION_OUTPUT_SCHEMA,
"summarize_round": ROUND_SUMMARY_OUTPUT_SCHEMA,
"update_memory": MEMORY_UPDATE_OUTPUT_SCHEMA,
"generate_report": REPORT_OUTPUT_SCHEMA,
"answer_followup_question": FOLLOWUP_OUTPUT_SCHEMA,
"validate_json_output": VALIDATE_JSON_OUTPUT_SCHEMA,
"repair_invalid_json": GENERIC_REPAIR_OUTPUT_SCHEMA,
}
STAGE_CONTRACTS: Dict[str, Dict[str, Any]] = {
"ontology": {
"task_type": "generate_ontology",
"schema": ONTOLOGY_OUTPUT_SCHEMA,
"system_prompt": "Generate a compact ontology for prediction graph construction.",
"user_prompt": "Return JSON with an ontology object. Do not include explanations.",
},
"graph": {
"task_type": "extract_triples",
"schema": TRIPLES_OUTPUT_SCHEMA,
"system_prompt": "Extract factual entity-relationship-entity triples from the seed only.",
"user_prompt": (
"Read the seed and return triples. Each triple must include subject, predicate, "
"object, fact, evidence, confidence, time fields, source fields, and metadata. "
"Do not invent facts not supported by evidence."
),
},
"profiles": {
"task_type": "generate_oasis_profiles",
"schema": PROFILES_OUTPUT_SCHEMA,
"system_prompt": "Generate OASIS/CAMEL-compatible agent profiles from the seed and graph facts.",
"user_prompt": "Return JSON with profiles array.",
},
"config": {
"task_type": "generate_simulation_config",
"schema": SIMULATION_CONFIG_OUTPUT_SCHEMA,
"system_prompt": "Generate a simulation config that can run without direct model API keys.",
"user_prompt": "Return JSON with config object.",
},
"simulation": {
"task_type": "simulate_agent_action",
"schema": SIMULATE_ACTION_OUTPUT_SCHEMA,
"system_prompt": "Generate batched simulation actions keyed by agent_id and action_id.",
"user_prompt": "Return JSON with actions array.",
},
"report": {
"task_type": "generate_report",
"schema": REPORT_OUTPUT_SCHEMA,
"system_prompt": "Generate final prediction report artifacts from seed, graph, profiles, config, and simulation actions.",
"user_prompt": "Return report_markdown, verdict, and timeline.",
},
}

View File

@ -0,0 +1,127 @@
"""Small JSON Schema validator for agent response contracts.
The project already depends on Pydantic, but not jsonschema. This validator
implements the subset we use in queue contracts so smoke tests stay offline.
"""
from __future__ import annotations
from typing import Any, Dict, List
def validate_json_schema(value: Any, schema: Dict[str, Any], path: str = "$") -> List[str]:
errors: List[str] = []
schema_type = schema.get("type")
if schema_type == "null":
return [] if value is None else [f"{path}: expected null"]
if schema_type == "object":
if not isinstance(value, dict):
return [f"{path}: expected object"]
required = schema.get("required", [])
for key in required:
if key not in value:
errors.append(f"{path}.{key}: missing required field")
properties = schema.get("properties", {})
additional = schema.get("additionalProperties", True)
for key, item in value.items():
if key in properties:
errors.extend(validate_json_schema(item, properties[key], f"{path}.{key}"))
elif additional is False:
errors.append(f"{path}.{key}: extra field is not allowed")
return errors
if schema_type == "array":
if not isinstance(value, list):
return [f"{path}: expected array"]
item_schema = schema.get("items", {})
for index, item in enumerate(value):
errors.extend(validate_json_schema(item, item_schema, f"{path}[{index}]"))
return errors
if schema_type == "string":
if not isinstance(value, str):
return [f"{path}: expected string"]
if schema.get("minLength") is not None and len(value) < int(schema["minLength"]):
errors.append(f"{path}: shorter than minLength")
return errors
if schema_type == "number":
if not isinstance(value, (int, float)) or isinstance(value, bool):
return [f"{path}: expected number"]
if schema.get("minimum") is not None and value < schema["minimum"]:
errors.append(f"{path}: below minimum {schema['minimum']}")
if schema.get("maximum") is not None and value > schema["maximum"]:
errors.append(f"{path}: above maximum {schema['maximum']}")
return errors
if schema_type == "integer":
if not isinstance(value, int) or isinstance(value, bool):
return [f"{path}: expected integer"]
return errors
if schema_type == "boolean":
if not isinstance(value, bool):
return [f"{path}: expected boolean"]
return errors
if isinstance(schema_type, list):
matched = False
nested_errors: List[str] = []
for candidate in schema_type:
candidate_schema = {**schema, "type": candidate}
candidate_errors = validate_json_schema(value, candidate_schema, path)
if not candidate_errors:
matched = True
break
nested_errors.extend(candidate_errors)
if not matched:
errors.append(f"{path}: did not match any allowed type {schema_type}; {nested_errors[:2]}")
return errors
enum = schema.get("enum")
if enum is not None and value not in enum:
errors.append(f"{path}: value {value!r} not in enum")
return errors
TRIPLE_SCHEMA: Dict[str, Any] = {
"type": "object",
"additionalProperties": False,
"required": [
"subject",
"predicate",
"object",
"fact",
"valid_at",
"invalid_at",
"source",
"source_file",
"evidence",
"confidence",
"metadata",
],
"properties": {
"subject": {"type": "string", "minLength": 1},
"predicate": {"type": "string", "minLength": 1},
"object": {"type": "string", "minLength": 1},
"fact": {"type": "string", "minLength": 1},
"valid_at": {"type": ["string", "null"]},
"invalid_at": {"type": ["string", "null"]},
"source": {"type": ["string", "null"]},
"source_file": {"type": ["string", "null"]},
"evidence": {"type": "string", "minLength": 1},
"confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
"metadata": {"type": "object"},
},
}
def object_schema(properties: Dict[str, Any], required: List[str] | None = None) -> Dict[str, Any]:
return {
"type": "object",
"additionalProperties": False,
"required": required or list(properties.keys()),
"properties": properties,
}

View File

@ -0,0 +1,183 @@
"""Filesystem queue for external desktop agents."""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from typing import Any, Dict, List, Optional
from pydantic import ValidationError
from .json_schema import validate_json_schema
from .schemas import AgentNeedResponse, AgentRequest, AgentResponse, ValidationResult
from .state import RunStore
class AgentQueueError(ValueError):
pass
class AgentQueue:
def __init__(self, run_dir: str | Path):
self.store = RunStore(run_dir)
self.store.ensure_layout()
def create_request(
self,
*,
run_id: str,
task_type: str,
stage: str,
expected_schema: Dict[str, Any],
input_text: Optional[str] = None,
input_files: Optional[List[str]] = None,
structured_input: Optional[Dict[str, Any]] = None,
system_prompt: str = "",
user_prompt: str = "",
validation_rules: Optional[Dict[str, Any]] = None,
retry_policy: Optional[Dict[str, Any]] = None,
context_refs: Optional[List[str]] = None,
output_contract: Optional[Dict[str, Any]] = None,
) -> AgentNeedResponse:
request_id = self.store.next_request_id()
request = AgentRequest(
request_id=request_id,
run_id=run_id,
type=task_type,
stage=stage,
input_text=input_text,
input_files=input_files or [],
structured_input=structured_input or {},
system_prompt=system_prompt,
user_prompt=user_prompt,
expected_schema=expected_schema,
validation_rules=validation_rules or {},
retry_policy=retry_policy or {},
context_refs=context_refs or [],
output_contract=output_contract or {},
)
request_file = self.store.requests_dir / f"{request_id}.json"
request_file.write_text(request.model_dump_json(indent=2), encoding="utf-8")
return AgentNeedResponse(
request_id=request_id,
request_file=str(request_file),
expected_response_file=str(self.store.responses_dir / f"{request_id}.json"),
stage=stage,
type=task_type,
)
def list_requests(self) -> List[Dict[str, Any]]:
requests = []
for path in sorted(self.store.requests_dir.glob("req_*.json")):
request = self.load_request(path.stem)
response_path = self.store.responses_dir / path.name
requests.append(
{
"request_id": request.request_id,
"type": request.type,
"stage": request.stage,
"request_file": str(path),
"expected_response_file": str(response_path),
"has_response": response_path.exists(),
}
)
return requests
def load_request(self, request_id: str) -> AgentRequest:
path = self.store.requests_dir / f"{request_id}.json"
if not path.exists():
raise AgentQueueError(f"request not found: {request_id}")
return AgentRequest.model_validate_json(path.read_text(encoding="utf-8"))
def save_request(self, request: AgentRequest) -> None:
path = self.store.requests_dir / f"{request.request_id}.json"
path.write_text(request.model_dump_json(indent=2), encoding="utf-8")
def load_response(self, request_id: str) -> AgentResponse:
path = self.store.responses_dir / f"{request_id}.json"
if not path.exists():
raise AgentQueueError(f"response not found: {request_id}")
return AgentResponse.model_validate_json(path.read_text(encoding="utf-8"))
def validate_response_file(
self,
response_path: str | Path,
*,
request_id: Optional[str] = None,
) -> ValidationResult:
errors: List[str] = []
path = Path(response_path)
try:
response = AgentResponse.model_validate_json(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return ValidationResult(ok=False, errors=[f"response file not found: {path}"])
except (ValidationError, json.JSONDecodeError, ValueError) as exc:
return ValidationResult(ok=False, errors=[f"invalid response schema: {exc}"])
if request_id and response.request_id != request_id:
errors.append(f"response request_id {response.request_id} does not match {request_id}")
if path.stem.startswith("req_") and response.request_id != path.stem:
errors.append(f"response request_id {response.request_id} does not match response file name {path.name}")
try:
request = self.load_request(response.request_id)
except AgentQueueError as exc:
errors.append(str(exc))
return ValidationResult(ok=False, errors=errors)
if response.status in {"ok", "skipped"}:
errors.extend(validate_json_schema(response.output, request.expected_schema))
elif response.status == "error" and not response.error:
errors.append("error response must include error")
return ValidationResult(ok=not errors, errors=errors)
def submit_response(self, response_path: str | Path) -> ValidationResult:
result = self.validate_response_file(response_path)
if not result.ok:
repair = self._maybe_create_repair_request(response_path, result.errors)
result.repair_request = repair
return result
source = Path(response_path)
response = AgentResponse.model_validate_json(source.read_text(encoding="utf-8"))
destination = self.store.responses_dir / f"{response.request_id}.json"
if source.resolve() != destination.resolve():
shutil.copyfile(source, destination)
return result
def _maybe_create_repair_request(
self,
response_path: str | Path,
errors: List[str],
) -> Optional[AgentNeedResponse]:
try:
raw = json.loads(Path(response_path).read_text(encoding="utf-8"))
request_id = raw.get("request_id")
request = self.load_request(request_id)
except Exception:
return None
policy = request.retry_policy
if policy.repair_attempts_used >= policy.max_repair_attempts:
return None
policy.repair_attempts_used += 1
request.retry_policy = policy
self.save_request(request)
return self.create_request(
run_id=request.run_id,
task_type="repair_invalid_json",
stage=request.stage,
expected_schema=request.expected_schema,
structured_input={
"original_request": request.model_dump(),
"invalid_response": raw,
"validation_errors": errors,
},
system_prompt="Repair the invalid agent output so it strictly matches expected_schema.",
user_prompt="Return only the repaired output object. Do not wrap it in an AgentResponse envelope.",
retry_policy=policy.model_dump(),
output_contract={"repairs_request_id": request.request_id, "return_shape": "output_only"},
)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,161 @@
"""Strict request/response schemas for desktop-agent driven runs."""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
AgentTaskType = Literal[
"extract_triples",
"generate_ontology",
"generate_oasis_profiles",
"generate_simulation_config",
"simulate_agent_action",
"summarize_round",
"update_memory",
"generate_report",
"answer_followup_question",
"validate_json_output",
"repair_invalid_json",
]
AGENT_TASK_TYPES = {
"extract_triples",
"generate_ontology",
"generate_oasis_profiles",
"generate_simulation_config",
"simulate_agent_action",
"summarize_round",
"update_memory",
"generate_report",
"answer_followup_question",
"validate_json_output",
"repair_invalid_json",
}
class StageStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
WAITING_AGENT = "waiting_agent"
AWAITING_USER_CONFIRMATION = "awaiting_user_confirmation"
COMPLETED = "completed"
FAILED = "failed"
class RetryPolicy(BaseModel):
model_config = ConfigDict(extra="forbid")
max_repair_attempts: int = Field(default=1, ge=0)
repair_attempts_used: int = Field(default=0, ge=0)
class AgentRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
request_id: str
run_id: str
type: AgentTaskType
stage: str
input_text: Optional[str] = None
input_files: List[str] = Field(default_factory=list)
structured_input: Dict[str, Any] = Field(default_factory=dict)
system_prompt: str = ""
user_prompt: str = ""
expected_schema: Dict[str, Any]
validation_rules: Dict[str, Any] = Field(default_factory=dict)
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
context_refs: List[str] = Field(default_factory=list)
output_contract: Dict[str, Any] = Field(default_factory=dict)
created_at: str = Field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
@field_validator("request_id")
@classmethod
def validate_request_id(cls, value: str) -> str:
if not value.startswith("req_"):
raise ValueError("request_id must start with req_")
return value
@field_validator("expected_schema")
@classmethod
def validate_expected_schema(cls, value: Dict[str, Any]) -> Dict[str, Any]:
if not value or value.get("type") != "object":
raise ValueError("expected_schema must be a non-empty JSON object schema")
return value
class AgentResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
request_id: str
status: Literal["ok", "error", "skipped"]
output: Dict[str, Any] = Field(default_factory=dict)
error: Optional[str] = None
validation: Optional[Dict[str, Any]] = None
@field_validator("request_id")
@classmethod
def validate_request_id(cls, value: str) -> str:
if not value.startswith("req_"):
raise ValueError("request_id must start with req_")
return value
class AgentNeedResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
status: Literal["need_agent_response"] = "need_agent_response"
request_id: str
request_file: str
expected_response_file: str
stage: str
type: str
class StageCheckpoint(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
status: StageStatus = StageStatus.PENDING
request_ids: List[str] = Field(default_factory=list)
artifact_paths: List[str] = Field(default_factory=list)
error: Optional[str] = None
stale: bool = False
stale_reason: Optional[str] = None
metadata: Dict[str, Any] = Field(default_factory=dict)
updated_at: str = Field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
class RunState(BaseModel):
model_config = ConfigDict(extra="forbid")
run_id: str
run_dir: str
requirement: str
seed_files: List[str] = Field(default_factory=list)
mode: str = "agent"
workflow_mode: Literal["auto", "staged"] = "auto"
current_stage: str = "ontology"
stages: Dict[str, StageCheckpoint]
seed_path: Optional[str] = None
simulation_settings: Dict[str, Any] = Field(default_factory=dict)
graph_summary: Dict[str, Any] = Field(default_factory=dict)
profiles_summary: Dict[str, Any] = Field(default_factory=dict)
config_summary: Dict[str, Any] = Field(default_factory=dict)
simulation_progress: Dict[str, Any] = Field(default_factory=dict)
report_artifacts: Dict[str, Any] = Field(default_factory=dict)
metadata: Dict[str, Any] = Field(default_factory=dict)
created_at: str = Field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
updated_at: str = Field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
class ValidationResult(BaseModel):
model_config = ConfigDict(extra="forbid")
ok: bool
errors: List[str] = Field(default_factory=list)
repair_request: Optional[AgentNeedResponse] = None

View File

@ -0,0 +1,151 @@
"""Persistent run state and directory layout."""
from __future__ import annotations
import json
import os
from datetime import datetime
from pathlib import Path
from typing import Dict, Iterable, Optional
from .schemas import RunState, StageCheckpoint, StageStatus
RUN_STAGES = [
"ontology",
"graph",
"profiles",
"config",
"simulation",
"report",
"artifacts",
]
STAGED_RUN_STAGES = [
"seed_input",
"prediction_requirement",
"simulation_settings",
"graph_build",
"profile_and_config",
"simulation_run",
"report_generation",
"followup_question",
]
def utc_now() -> str:
return datetime.utcnow().isoformat() + "Z"
class RunStore:
def __init__(self, run_dir: str | Path):
self.run_dir = Path(run_dir)
self.requests_dir = self.run_dir / "requests"
self.responses_dir = self.run_dir / "responses"
self.artifacts_dir = self.run_dir / "artifacts"
self.state_path = self.run_dir / "state.json"
def ensure_layout(self) -> None:
self.requests_dir.mkdir(parents=True, exist_ok=True)
self.responses_dir.mkdir(parents=True, exist_ok=True)
self.artifacts_dir.mkdir(parents=True, exist_ok=True)
def init_state(
self,
run_id: str,
requirement: str,
seed_files: Iterable[str],
mode: str = "agent",
workflow_mode: str = "auto",
simulation_settings: Optional[Dict] = None,
seed_path: Optional[str] = None,
metadata: Optional[Dict] = None,
) -> RunState:
self.ensure_layout()
stage_names = STAGED_RUN_STAGES if workflow_mode == "staged" else RUN_STAGES
stages = {name: StageCheckpoint(name=name) for name in stage_names}
state = RunState(
run_id=run_id,
run_dir=str(self.run_dir),
requirement=requirement,
seed_files=list(seed_files),
mode=mode,
workflow_mode=workflow_mode,
current_stage=stage_names[0],
seed_path=seed_path,
simulation_settings=simulation_settings or {},
stages=stages,
metadata=metadata or {},
)
self.save(state)
return state
def load(self) -> RunState:
with self.state_path.open("r", encoding="utf-8") as handle:
return RunState.model_validate_json(handle.read())
def save(self, state: RunState) -> None:
self.ensure_layout()
state.updated_at = utc_now()
with self.state_path.open("w", encoding="utf-8") as handle:
handle.write(state.model_dump_json(indent=2))
def set_stage(
self,
state: RunState,
stage: str,
status: StageStatus,
error: Optional[str] = None,
) -> None:
checkpoint = state.stages[stage]
checkpoint.status = status
checkpoint.error = error
checkpoint.updated_at = utc_now()
state.current_stage = stage
self.save(state)
def add_stage_request(self, state: RunState, stage: str, request_id: str) -> None:
checkpoint = state.stages[stage]
if request_id not in checkpoint.request_ids:
checkpoint.request_ids.append(request_id)
checkpoint.status = StageStatus.WAITING_AGENT
checkpoint.updated_at = utc_now()
state.current_stage = stage
self.save(state)
def add_stage_artifact(self, state: RunState, stage: str, artifact_path: str) -> None:
checkpoint = state.stages.get(stage) or state.stages[state.current_stage]
if artifact_path not in checkpoint.artifact_paths:
checkpoint.artifact_paths.append(artifact_path)
checkpoint.updated_at = utc_now()
self.save(state)
def next_request_id(self) -> str:
self.ensure_layout()
max_seen = 0
for folder in (self.requests_dir, self.responses_dir):
for path in folder.glob("req_*.json"):
try:
max_seen = max(max_seen, int(path.stem.split("_", 1)[1]))
except (IndexError, ValueError):
continue
return f"req_{max_seen + 1:06d}"
def read_seed_text(self, max_chars: int = 200_000) -> str:
state = self.load()
parts = []
for seed_file in state.seed_files:
path = Path(seed_file)
if not path.is_absolute():
path = self.run_dir / path
if path.exists():
parts.append(path.read_text(encoding="utf-8")[:max_chars])
return "\n\n".join(parts)
def as_status(self) -> Dict:
state = self.load()
return json.loads(state.model_dump_json())
def default_runs_dir() -> str:
return os.environ.get("MIROFISH_RUNS_DIR", "./runs")

View File

@ -23,6 +23,14 @@ from ..models.project import ProjectManager, ProjectStatus
logger = get_logger('mirofish.api')
def _legacy_zep_config_errors() -> list[str]:
"""Only legacy zep provider requires ZEP_API_KEY."""
graph_provider = os.environ.get('MIROFISH_GRAPH_PROVIDER', Config.MIROFISH_GRAPH_PROVIDER)
if graph_provider == 'zep' and not Config.ZEP_API_KEY:
return [t('api.zepApiKeyMissing')]
return []
def allowed_file(filename: str) -> bool:
"""检查文件扩展名是否允许"""
if not filename or '.' not in filename:
@ -248,6 +256,7 @@ def generate_ontology():
})
except Exception as e:
logger.exception("生成本体定义失败")
return jsonify({
"success": False,
"error": str(e),
@ -284,9 +293,7 @@ def build_graph():
logger.info("=== 开始构建图谱 ===")
# 检查配置
errors = []
if not Config.ZEP_API_KEY:
errors.append(t('api.zepApiKeyMissing'))
errors = _legacy_zep_config_errors()
if errors:
logger.error(f"配置错误: {errors}")
return jsonify({
@ -387,7 +394,7 @@ def build_graph():
)
# 创建图谱构建服务
builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)
builder = GraphBuilderService()
# 分块
task_manager.update_task(
@ -522,6 +529,7 @@ def build_graph():
})
except Exception as e:
logger.exception(f"获取图谱数据失败: graph_id={graph_id}")
return jsonify({
"success": False,
"error": str(e),
@ -572,13 +580,13 @@ def get_graph_data(graph_id: str):
获取图谱数据节点和边
"""
try:
if not Config.ZEP_API_KEY:
if _legacy_zep_config_errors():
return jsonify({
"success": False,
"error": t('api.zepApiKeyMissing')
}), 500
builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)
builder = GraphBuilderService()
graph_data = builder.get_graph_data(graph_id)
return jsonify({
@ -600,13 +608,13 @@ def delete_graph(graph_id: str):
删除Zep图谱
"""
try:
if not Config.ZEP_API_KEY:
if _legacy_zep_config_errors():
return jsonify({
"success": False,
"error": t('api.zepApiKeyMissing')
}), 500
builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)
builder = GraphBuilderService()
builder.delete_graph(graph_id)
return jsonify({

View File

@ -20,6 +20,11 @@ from ..models.project import ProjectManager
logger = get_logger('mirofish.api.simulation')
def _legacy_zep_required() -> bool:
graph_provider = os.environ.get('MIROFISH_GRAPH_PROVIDER', Config.MIROFISH_GRAPH_PROVIDER)
return graph_provider == 'zep' and not Config.ZEP_API_KEY
# Interview prompt 优化前缀
# 添加此前缀可以避免Agent调用工具直接用文本回复
INTERVIEW_PROMPT_PREFIX = "结合你的人设、所有的过往记忆与行动,不调用任何工具直接用文本回复我:"
@ -57,7 +62,7 @@ def get_graph_entities(graph_id: str):
enrich: 是否获取相关边信息默认true
"""
try:
if not Config.ZEP_API_KEY:
if _legacy_zep_required():
return jsonify({
"success": False,
"error": t('api.zepApiKeyMissing')
@ -94,7 +99,7 @@ def get_graph_entities(graph_id: str):
def get_entity_detail(graph_id: str, entity_uuid: str):
"""获取单个实体的详细信息"""
try:
if not Config.ZEP_API_KEY:
if _legacy_zep_required():
return jsonify({
"success": False,
"error": t('api.zepApiKeyMissing')
@ -127,7 +132,7 @@ def get_entity_detail(graph_id: str, entity_uuid: str):
def get_entities_by_type(graph_id: str, entity_type: str):
"""获取指定类型的所有实体"""
try:
if not Config.ZEP_API_KEY:
if _legacy_zep_required():
return jsonify({
"success": False,
"error": t('api.zepApiKeyMissing')
@ -1547,7 +1552,11 @@ def start_simulation():
if state.status == SimulationStatus.RUNNING:
# 检查模拟进程是否真的在运行
run_state = SimulationRunner.get_run_state(simulation_id)
if run_state and run_state.runner_status.value == "running":
if (
run_state
and run_state.runner_status.value == "running"
and SimulationRunner.is_process_alive(simulation_id)
):
# 进程确实在运行
if force:
# 强制模式:停止运行中的模拟

View File

@ -23,6 +23,22 @@ class Config:
# Flask配置
SECRET_KEY = os.environ.get('SECRET_KEY', 'mirofish-secret-key')
DEBUG = os.environ.get('FLASK_DEBUG', 'True').lower() == 'true'
PRESERVE_SIMULATIONS_ON_RELOAD = os.environ.get(
'MIROFISH_PRESERVE_SIMULATIONS_ON_RELOAD',
'true' if DEBUG else 'false'
).lower() == 'true'
# Agent engine配置
MIROFISH_MODE = os.environ.get('MIROFISH_MODE', 'agent')
MIROFISH_LLM_PROVIDER = os.environ.get(
'MIROFISH_LLM_PROVIDER',
'agent_queue' if MIROFISH_MODE == 'agent' else 'openai_compatible'
)
MIROFISH_GRAPH_PROVIDER = os.environ.get(
'MIROFISH_GRAPH_PROVIDER',
'graphiti' if MIROFISH_MODE == 'agent' else 'zep'
)
MIROFISH_RUNS_DIR = os.environ.get('MIROFISH_RUNS_DIR', './runs')
# JSON配置 - 禁用ASCII转义让中文直接显示而不是 \uXXXX 格式)
JSON_AS_ASCII = False
@ -34,6 +50,14 @@ class Config:
# Zep配置
ZEP_API_KEY = os.environ.get('ZEP_API_KEY')
# Graphiti / Neo4j配置
NEO4J_URI = os.environ.get('NEO4J_URI', 'bolt://localhost:7687')
NEO4J_USER = os.environ.get('NEO4J_USER', 'neo4j')
NEO4J_PASSWORD = os.environ.get('NEO4J_PASSWORD', 'password')
NEO4J_DATABASE = os.environ.get('NEO4J_DATABASE', 'neo4j')
OLLAMA_BASE_URL = os.environ.get('OLLAMA_BASE_URL', 'http://localhost:11434')
OLLAMA_EMBEDDING_MODEL = os.environ.get('OLLAMA_EMBEDDING_MODEL', 'nomic-embed-text')
# 文件上传配置
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB
@ -67,9 +91,18 @@ class Config:
def validate(cls) -> list[str]:
"""验证必要配置"""
errors: list[str] = []
if not cls.LLM_API_KEY:
errors.append("LLM_API_KEY 未配置")
if not cls.ZEP_API_KEY:
errors.append("ZEP_API_KEY 未配置")
return errors
mode = os.environ.get('MIROFISH_MODE', cls.MIROFISH_MODE)
llm_provider = os.environ.get(
'MIROFISH_LLM_PROVIDER',
'agent_queue' if mode == 'agent' else cls.MIROFISH_LLM_PROVIDER
)
graph_provider = os.environ.get(
'MIROFISH_GRAPH_PROVIDER',
'graphiti' if mode == 'agent' else cls.MIROFISH_GRAPH_PROVIDER
)
if llm_provider == 'openai_compatible' and not os.environ.get('LLM_API_KEY', cls.LLM_API_KEY or ''):
errors.append("LLM_API_KEY 未配置openai_compatible provider 需要)")
if graph_provider == 'zep' and not os.environ.get('ZEP_API_KEY', cls.ZEP_API_KEY or ''):
errors.append("ZEP_API_KEY 未配置legacy zep provider 需要)")
return errors

View File

@ -0,0 +1 @@
"""MiroFish MCP server package."""

View File

@ -0,0 +1,163 @@
"""FastMCP server exposing MiroFish run lifecycle tools."""
from __future__ import annotations
from typing import Any, Dict, Optional
from ..agent_engine.runner import PredictionRunService
def create_server():
try:
from mcp.server.fastmcp import FastMCP
except ImportError as exc:
raise RuntimeError(
"MCP Python SDK is not installed. Install package 'mcp' to run the MiroFish MCP server."
) from exc
mcp = FastMCP("mirofish-agent-engine")
service = PredictionRunService()
@mcp.tool()
def mirofish_create_run(
seed: str,
requirement: str,
output: str,
mode: str = "auto",
rounds: int = 10,
round_unit: str = "year",
minutes_per_round: Optional[int] = None,
pause_each_round: bool = False,
agent_count: Optional[int] = None,
simulation_name: Optional[str] = None,
) -> Dict[str, Any]:
return service.create_run(
seed,
requirement,
output,
mode=mode,
rounds=rounds,
round_unit=round_unit,
minutes_per_round=minutes_per_round,
pause_each_round=pause_each_round,
agent_count=agent_count,
simulation_name=simulation_name,
)
@mcp.tool()
def mirofish_run(run: str) -> Dict[str, Any]:
return service.run(run)
@mcp.tool()
def mirofish_resume_run(run: str) -> Dict[str, Any]:
return service.resume(run)
@mcp.tool()
def mirofish_get_status(run: str) -> Dict[str, Any]:
return service.status(run)
@mcp.tool()
def mirofish_get_current_stage(run: str) -> Dict[str, Any]:
return service.get_current_stage(run)
@mcp.tool()
def mirofish_update_simulation_settings(
run: str,
rounds: Optional[int] = None,
round_unit: Optional[str] = None,
minutes_per_round: Optional[int] = None,
pause_each_round: Optional[bool] = None,
agent_count: Optional[int] = None,
simulation_name: Optional[str] = None,
) -> Dict[str, Any]:
return service.update_simulation_settings(
run,
rounds=rounds,
round_unit=round_unit,
minutes_per_round=minutes_per_round,
pause_each_round=pause_each_round,
agent_count=agent_count,
simulation_name=simulation_name,
)
@mcp.tool()
def mirofish_approve_stage(run: str) -> Dict[str, Any]:
return service.approve_stage(run)
@mcp.tool()
def mirofish_reject_stage(run: str, reason: str = "") -> Dict[str, Any]:
return service.reject_stage(run, reason)
@mcp.tool()
def mirofish_rerun_stage(run: str, stage: str) -> Dict[str, Any]:
return service.rerun_stage(run, stage)
@mcp.tool()
def mirofish_list_requests(run: str) -> Dict[str, Any]:
return service.list_requests(run)
@mcp.tool()
def mirofish_get_request(run: str, request_id: str) -> Dict[str, Any]:
return service.get_request(run, request_id)
@mcp.tool()
def mirofish_submit_response(run: str, response: str) -> Dict[str, Any]:
return service.submit_response(run, response)
@mcp.tool()
def mirofish_validate_response(run: str, response: str) -> Dict[str, Any]:
return service.validate_response(run, response)
@mcp.tool()
def mirofish_build_graph(run: str, provider: Optional[str] = None, mode: str = "agent-triples") -> Dict[str, Any]:
return service.build_graph(run, provider=provider, mode=mode)
@mcp.tool()
def mirofish_search_graph(run: str, query: str, limit: int = 20) -> Dict[str, Any]:
return service.search_graph(run, query, limit)
@mcp.tool()
def mirofish_export_graph(run: str, output: Optional[str] = None) -> Dict[str, Any]:
return service.export_graph(run, output)
@mcp.tool()
def mirofish_start_simulation(run: str) -> Dict[str, Any]:
return service.start_simulation(run)
@mcp.tool()
def mirofish_resume_simulation(run: str) -> Dict[str, Any]:
return service.resume(run)
@mcp.tool()
def mirofish_generate_report(run: str) -> Dict[str, Any]:
return service.generate_report(run)
@mcp.tool()
def mirofish_get_report(run: str) -> Dict[str, Any]:
return service.get_report(run)
@mcp.tool()
def mirofish_ask_followup_question(run: str, question: str, limit: int = 20) -> Dict[str, Any]:
return service.ask_followup_question(run, question, limit)
@mcp.tool()
def mirofish_get_followup_answer(run: str, request_id: str) -> Dict[str, Any]:
return service.get_followup_answer(run, request_id)
@mcp.tool()
def mirofish_list_artifacts(run: str) -> Dict[str, Any]:
return service.list_artifacts(run)
@mcp.tool()
def mirofish_doctor(runs_dir: Optional[str] = None) -> Dict[str, Any]:
return service.doctor(runs_dir)
return mcp
def main() -> None:
create_server().run()
if __name__ == "__main__":
main()

View File

@ -10,16 +10,17 @@ import threading
from typing import Dict, Any, List, Optional, Callable
from dataclasses import dataclass
from zep_cloud.client import Zep
from zep_cloud import EpisodeData, EntityEdgeSourceTarget
from ..config import Config
from ..adapters.graph.factory import create_graph_provider
from ..models.task import TaskManager, TaskStatus
from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges
from ..utils.logger import get_logger
from .text_processor import TextProcessor
from ..utils.locale import t, get_locale, set_locale
logger = get_logger('mirofish.graph_builder')
@dataclass
class GraphInfo:
"""图谱信息"""
@ -44,11 +45,8 @@ class GraphBuilderService:
"""
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or Config.ZEP_API_KEY
if not self.api_key:
raise ValueError("ZEP_API_KEY 未配置")
self.client = Zep(api_key=self.api_key)
self.api_key = api_key
self.provider = create_graph_provider()
self.task_manager = TaskManager()
def build_graph_async(
@ -193,103 +191,17 @@ class GraphBuilderService:
def create_graph(self, name: str) -> str:
"""创建Zep图谱公开方法"""
graph_id = f"mirofish_{uuid.uuid4().hex[:16]}"
self.client.graph.create(
graph_id=graph_id,
name=name,
description="MiroFish Social Simulation Graph"
)
self.provider.add_episode(graph_id, f"Graph created: {name}", {"type": "graph_created", "name": name})
return graph_id
def set_ontology(self, graph_id: str, ontology: Dict[str, Any]):
"""设置图谱本体(公开方法)"""
import warnings
from typing import Optional
from pydantic import Field
from zep_cloud.external_clients.ontology import EntityModel, EntityText, EdgeModel
# 抑制 Pydantic v2 关于 Field(default=None) 的警告
# 这是 Zep SDK 要求的用法,警告来自动态类创建,可以安全忽略
warnings.filterwarnings('ignore', category=UserWarning, module='pydantic')
# Zep 保留名称,不能作为属性名
RESERVED_NAMES = {'uuid', 'name', 'group_id', 'name_embedding', 'summary', 'created_at'}
def safe_attr_name(attr_name: str) -> str:
"""将保留名称转换为安全名称"""
if attr_name.lower() in RESERVED_NAMES:
return f"entity_{attr_name}"
return attr_name
# 动态创建实体类型
entity_types = {}
for entity_def in ontology.get("entity_types", []):
name = entity_def["name"]
description = entity_def.get("description", f"A {name} entity.")
# 创建属性字典和类型注解Pydantic v2 需要)
attrs = {"__doc__": description}
annotations = {}
for attr_def in entity_def.get("attributes", []):
attr_name = safe_attr_name(attr_def["name"]) # 使用安全名称
attr_desc = attr_def.get("description", attr_name)
# Zep API 需要 Field 的 description这是必需的
attrs[attr_name] = Field(description=attr_desc, default=None)
annotations[attr_name] = Optional[EntityText] # 类型注解
attrs["__annotations__"] = annotations
# 动态创建类
entity_class = type(name, (EntityModel,), attrs)
entity_class.__doc__ = description
entity_types[name] = entity_class
# 动态创建边类型
edge_definitions = {}
for edge_def in ontology.get("edge_types", []):
name = edge_def["name"]
description = edge_def.get("description", f"A {name} relationship.")
# 创建属性字典和类型注解
attrs = {"__doc__": description}
annotations = {}
for attr_def in edge_def.get("attributes", []):
attr_name = safe_attr_name(attr_def["name"]) # 使用安全名称
attr_desc = attr_def.get("description", attr_name)
# Zep API 需要 Field 的 description这是必需的
attrs[attr_name] = Field(description=attr_desc, default=None)
annotations[attr_name] = Optional[str] # 边属性用str类型
attrs["__annotations__"] = annotations
# 动态创建类
class_name = ''.join(word.capitalize() for word in name.split('_'))
edge_class = type(class_name, (EdgeModel,), attrs)
edge_class.__doc__ = description
# 构建source_targets
source_targets = []
for st in edge_def.get("source_targets", []):
source_targets.append(
EntityEdgeSourceTarget(
source=st.get("source", "Entity"),
target=st.get("target", "Entity")
)
)
if source_targets:
edge_definitions[name] = (edge_class, source_targets)
# 调用Zep API设置本体
if entity_types or edge_definitions:
self.client.graph.set_ontology(
graph_ids=[graph_id],
entities=entity_types if entity_types else None,
edges=edge_definitions if edge_definitions else None,
)
self.provider.add_episode(
graph_id,
os.linesep.join([f"Ontology: {ontology.get('entity_types', [])}", f"Edges: {ontology.get('edge_types', [])}"]),
{"type": "ontology"},
)
def add_text_batches(
self,
@ -314,25 +226,12 @@ class GraphBuilderService:
progress
)
# 构建episode数据
episodes = [
EpisodeData(data=chunk, type="text")
for chunk in batch_chunks
]
# 发送到Zep
# 发送到统一图谱 provider。agent 模式下这只是 episode 存储;
# 三元组抽取由 CLI/MCP runner 生成 extract_triples request。
try:
batch_result = self.client.graph.add_batch(
graph_id=graph_id,
episodes=episodes
)
# 收集返回的 episode uuid
if batch_result and isinstance(batch_result, list):
for ep in batch_result:
ep_uuid = getattr(ep, 'uuid_', None) or getattr(ep, 'uuid', None)
if ep_uuid:
episode_uuids.append(ep_uuid)
for chunk in batch_chunks:
result = self.provider.add_episode(graph_id, chunk, {"type": "seed_chunk", "batch": batch_num})
episode_uuids.append(str(result.get("episode_index", uuid.uuid4().hex)))
# 避免请求过快
time.sleep(1)
@ -356,63 +255,21 @@ class GraphBuilderService:
progress_callback(t('progress.noEpisodesWait'), 1.0)
return
start_time = time.time()
pending_episodes = set(episode_uuids)
completed_count = 0
total_episodes = len(episode_uuids)
if progress_callback:
progress_callback(t('progress.waitingEpisodes', count=total_episodes), 0)
while pending_episodes:
if time.time() - start_time > timeout:
if progress_callback:
progress_callback(
t('progress.episodesTimeout', completed=completed_count, total=total_episodes),
completed_count / total_episodes
)
break
# 检查每个 episode 的处理状态
for ep_uuid in list(pending_episodes):
try:
episode = self.client.graph.episode.get(uuid_=ep_uuid)
is_processed = getattr(episode, 'processed', False)
if is_processed:
pending_episodes.remove(ep_uuid)
completed_count += 1
except Exception as e:
# 忽略单个查询错误,继续
pass
elapsed = int(time.time() - start_time)
if progress_callback:
progress_callback(
t('progress.zepProcessing', completed=completed_count, total=total_episodes, pending=len(pending_episodes), elapsed=elapsed),
completed_count / total_episodes if total_episodes > 0 else 0
)
if pending_episodes:
time.sleep(3) # 每3秒检查一次
if progress_callback:
progress_callback(t('progress.processingComplete', completed=completed_count, total=total_episodes), 1.0)
progress_callback(t('progress.waitingEpisodes', count=total_episodes), 1.0)
def _get_graph_info(self, graph_id: str) -> GraphInfo:
"""获取图谱信息"""
# 获取节点(分页)
nodes = fetch_all_nodes(self.client, graph_id)
# 获取边(分页)
edges = fetch_all_edges(self.client, graph_id)
nodes = self.provider.list_entities(graph_id)
edges = self.provider.search(graph_id, "", limit=10000)
# 统计实体类型
entity_types = set()
for node in nodes:
if node.labels:
for label in node.labels:
if node.get("labels"):
for label in node["labels"]:
if label not in ["Entity", "Node"]:
entity_types.add(label)
@ -433,58 +290,58 @@ class GraphBuilderService:
Returns:
包含nodes和edges的字典包括时间信息属性等详细数据
"""
nodes = fetch_all_nodes(self.client, graph_id)
edges = fetch_all_edges(self.client, graph_id)
nodes = self.provider.list_entities(graph_id)
edges = self.provider.search(graph_id, "", limit=10000)
# 创建节点映射用于获取节点名称
node_map = {}
for node in nodes:
node_map[node.uuid_] = node.name or ""
node_map[node.get("uuid", "")] = node.get("name", "") or ""
nodes_data = []
for node in nodes:
# 获取创建时间
created_at = getattr(node, 'created_at', None)
created_at = node.get("created_at")
if created_at:
created_at = str(created_at)
nodes_data.append({
"uuid": node.uuid_,
"name": node.name,
"labels": node.labels or [],
"summary": node.summary or "",
"attributes": node.attributes or {},
"uuid": node.get("uuid", ""),
"name": node.get("name", ""),
"labels": node.get("labels", []) or [],
"summary": node.get("summary", "") or "",
"attributes": node.get("attributes", {}) or {},
"created_at": created_at,
})
edges_data = []
for edge in edges:
# 获取时间信息
created_at = getattr(edge, 'created_at', None)
valid_at = getattr(edge, 'valid_at', None)
invalid_at = getattr(edge, 'invalid_at', None)
expired_at = getattr(edge, 'expired_at', None)
created_at = edge.get("created_at")
valid_at = edge.get("valid_at")
invalid_at = edge.get("invalid_at")
expired_at = edge.get("expired_at")
# 获取 episodes
episodes = getattr(edge, 'episodes', None) or getattr(edge, 'episode_ids', None)
episodes = edge.get("episodes") or edge.get("episode_ids")
if episodes and not isinstance(episodes, list):
episodes = [str(episodes)]
elif episodes:
episodes = [str(e) for e in episodes]
# 获取 fact_type
fact_type = getattr(edge, 'fact_type', None) or edge.name or ""
fact_type = edge.get("fact_type") or edge.get("predicate") or edge.get("name", "")
edges_data.append({
"uuid": edge.uuid_,
"name": edge.name or "",
"fact": edge.fact or "",
"uuid": edge.get("uuid", ""),
"name": edge.get("predicate") or edge.get("name", ""),
"fact": edge.get("fact", ""),
"fact_type": fact_type,
"source_node_uuid": edge.source_node_uuid,
"target_node_uuid": edge.target_node_uuid,
"source_node_name": node_map.get(edge.source_node_uuid, ""),
"target_node_name": node_map.get(edge.target_node_uuid, ""),
"attributes": edge.attributes or {},
"source_node_uuid": edge.get("source_node_uuid", ""),
"target_node_uuid": edge.get("target_node_uuid", ""),
"source_node_name": node_map.get(edge.get("source_node_uuid", ""), ""),
"target_node_name": node_map.get(edge.get("target_node_uuid", ""), ""),
"attributes": edge.get("attributes", {}) or edge.get("metadata", {}),
"created_at": str(created_at) if created_at else None,
"valid_at": str(valid_at) if valid_at else None,
"invalid_at": str(invalid_at) if invalid_at else None,
@ -502,5 +359,4 @@ class GraphBuilderService:
def delete_graph(self, graph_id: str):
"""删除图谱"""
self.client.graph.delete(graph_id=graph_id)
self.provider.clear_run_graph(graph_id)

View File

@ -15,10 +15,9 @@ from typing import Dict, Any, List, Optional
from dataclasses import dataclass, field
from datetime import datetime
from openai import OpenAI
from zep_cloud.client import Zep
from ..config import Config
from ..adapters.graph.factory import create_graph_provider
from ..adapters.llm.agent_runtime import AgentRuntime, NeedAgentResponse
from ..utils.logger import get_logger
from ..utils.locale import get_language_instruction, get_locale, set_locale, t
from .zep_entity_reader import EntityNode, ZepEntityReader
@ -186,28 +185,12 @@ class OasisProfileGenerator:
zep_api_key: Optional[str] = None,
graph_id: Optional[str] = None
):
self.api_key = api_key or Config.LLM_API_KEY
self.api_key = api_key
self.base_url = base_url or Config.LLM_BASE_URL
self.model_name = model_name or Config.LLM_MODEL_NAME
if not self.api_key:
raise ValueError("LLM_API_KEY 未配置")
self.client = OpenAI(
api_key=self.api_key,
base_url=self.base_url
)
# Zep客户端用于检索丰富上下文
self.zep_api_key = zep_api_key or Config.ZEP_API_KEY
self.zep_client = None
self.runtime = AgentRuntime()
self.graph_provider = create_graph_provider()
self.graph_id = graph_id
if self.zep_api_key:
try:
self.zep_client = Zep(api_key=self.zep_api_key)
except Exception as e:
logger.warning(f"Zep客户端初始化失败: {e}")
def generate_profile_from_entity(
self,
@ -296,11 +279,6 @@ class OasisProfileGenerator:
Returns:
包含facts, node_summaries, context的字典
"""
import concurrent.futures
if not self.zep_client:
return {"facts": [], "node_summaries": [], "context": ""}
entity_name = entity.name
results = {
@ -314,84 +292,20 @@ class OasisProfileGenerator:
logger.debug(f"跳过Zep检索未设置graph_id")
return results
comprehensive_query = t('progress.zepSearchQuery', name=entity_name)
def search_edges():
"""搜索边(事实/关系)- 带重试机制"""
max_retries = 3
last_exception = None
delay = 2.0
for attempt in range(max_retries):
try:
return self.zep_client.graph.search(
query=comprehensive_query,
graph_id=self.graph_id,
limit=30,
scope="edges",
reranker="rrf"
)
except Exception as e:
last_exception = e
if attempt < max_retries - 1:
logger.debug(f"Zep边搜索第 {attempt + 1} 次失败: {str(e)[:80]}, 重试中...")
time.sleep(delay)
delay *= 2
else:
logger.debug(f"Zep边搜索在 {max_retries} 次尝试后仍失败: {e}")
return None
def search_nodes():
"""搜索节点(实体摘要)- 带重试机制"""
max_retries = 3
last_exception = None
delay = 2.0
for attempt in range(max_retries):
try:
return self.zep_client.graph.search(
query=comprehensive_query,
graph_id=self.graph_id,
limit=20,
scope="nodes",
reranker="rrf"
)
except Exception as e:
last_exception = e
if attempt < max_retries - 1:
logger.debug(f"Zep节点搜索第 {attempt + 1} 次失败: {str(e)[:80]}, 重试中...")
time.sleep(delay)
delay *= 2
else:
logger.debug(f"Zep节点搜索在 {max_retries} 次尝试后仍失败: {e}")
return None
try:
# 并行执行edges和nodes搜索
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
edge_future = executor.submit(search_edges)
node_future = executor.submit(search_nodes)
# 获取结果
edge_result = edge_future.result(timeout=30)
node_result = node_future.result(timeout=30)
# 处理边搜索结果
comprehensive_query = t('progress.zepSearchQuery', name=entity_name)
graph_results = self.graph_provider.search(self.graph_id, comprehensive_query, limit=30)
all_facts = set()
if edge_result and hasattr(edge_result, 'edges') and edge_result.edges:
for edge in edge_result.edges:
if hasattr(edge, 'fact') and edge.fact:
all_facts.add(edge.fact)
results["facts"] = list(all_facts)
# 处理节点搜索结果
all_summaries = set()
if node_result and hasattr(node_result, 'nodes') and node_result.nodes:
for node in node_result.nodes:
if hasattr(node, 'summary') and node.summary:
all_summaries.add(node.summary)
if hasattr(node, 'name') and node.name and node.name != entity_name:
all_summaries.add(f"相关实体: {node.name}")
for item in graph_results:
if item.get("fact"):
all_facts.add(item["fact"])
node = item.get("node") if isinstance(item.get("node"), dict) else item
if node.get("summary"):
all_summaries.add(node["summary"])
if node.get("name") and node.get("name") != entity_name:
all_summaries.add(f"相关实体: {node['name']}")
results["facts"] = list(all_facts)
results["node_summaries"] = list(all_summaries)
# 构建综合上下文
@ -404,10 +318,8 @@ class OasisProfileGenerator:
logger.info(f"Zep混合检索完成: {entity_name}, 获取 {len(results['facts'])} 条事实, {len(results['node_summaries'])} 个相关节点")
except concurrent.futures.TimeoutError:
logger.warning(f"Zep检索超时 ({entity_name})")
except Exception as e:
logger.warning(f"Zep检索失败 ({entity_name}): {e}")
logger.warning(f"图谱检索失败 ({entity_name}): {e}")
return results
@ -527,48 +439,38 @@ class OasisProfileGenerator:
for attempt in range(max_attempts):
try:
response = self.client.chat.completions.create(
model=self.model_name,
messages=[
{"role": "system", "content": self._get_system_prompt(is_individual)},
{"role": "user", "content": prompt}
],
response_format={"type": "json_object"},
temperature=0.7 - (attempt * 0.1) # 每次重试降低温度
# 不设置max_tokens让LLM自由发挥
runtime_result = self.runtime.run_task(
run_id=self.graph_id or "legacy-oasis-profile",
task_type="generate_oasis_profiles",
stage="oasis_profile_generator",
expected_schema={"type": "object"},
input_text=prompt,
structured_input={
"entity_name": entity_name,
"entity_type": entity_type,
"entity_summary": entity_summary,
"entity_attributes": entity_attributes,
"is_individual": is_individual,
"temperature": 0.7 - (attempt * 0.1),
},
system_prompt=self._get_system_prompt(is_individual),
user_prompt=prompt,
validation_rules={"json_object": True},
)
content = response.choices[0].message.content
# 检查是否被截断finish_reason不是'stop'
finish_reason = response.choices[0].finish_reason
if finish_reason == 'length':
logger.warning(f"LLM输出被截断 (attempt {attempt+1}), 尝试修复...")
content = self._fix_truncated_json(content)
# 尝试解析JSON
try:
result = json.loads(content)
# 验证必需字段
if "bio" not in result or not result["bio"]:
result["bio"] = entity_summary[:200] if entity_summary else f"{entity_type}: {entity_name}"
if "persona" not in result or not result["persona"]:
result["persona"] = entity_summary or f"{entity_name}是一个{entity_type}"
return result
except json.JSONDecodeError as je:
logger.warning(f"JSON解析失败 (attempt {attempt+1}): {str(je)[:80]}")
# 尝试修复JSON
result = self._try_fix_json(content, entity_name, entity_type, entity_summary)
if result.get("_fixed"):
del result["_fixed"]
return result
last_error = je
if runtime_result.status == "need_agent_response":
raise NeedAgentResponse(runtime_result)
if runtime_result.status != "ok":
raise RuntimeError(runtime_result.error or "LLM provider failed")
result = runtime_result.output or {}
if "profiles" in result and result["profiles"]:
result = result["profiles"][0]
if "bio" not in result or not result["bio"]:
result["bio"] = entity_summary[:200] if entity_summary else f"{entity_type}: {entity_name}"
if "persona" not in result or not result["persona"]:
result["persona"] = entity_summary or f"{entity_name}是一个{entity_type}"
return result
except NeedAgentResponse:
raise
except Exception as e:
logger.warning(f"LLM调用失败 (attempt {attempt+1}): {str(e)[:80]}")
last_error = e
@ -1202,4 +1104,3 @@ class OasisProfileGenerator:
"""[已废弃] 请使用 save_profiles() 方法"""
logger.warning("save_profiles_to_json已废弃请使用save_profiles方法")
self.save_profiles(profiles, file_path, platform)

View File

@ -410,11 +410,23 @@ class OntologyGenerator:
code_lines = [
'"""',
'自定义实体类型定义',
'由MiroFish自动生成用于社会舆论模拟',
'由MiroFish自动生成用于社会舆论模拟。',
'该代码是 provider-neutral 的本体描述,不直接依赖具体图谱 SDK。',
'"""',
'',
'from pydantic import Field',
'from zep_cloud.external_clients.ontology import EntityModel, EntityText, EdgeModel',
'from typing import Optional',
'from pydantic import BaseModel, Field',
'',
'',
'EntityText = Optional[str]',
'',
'',
'class EntityModel(BaseModel):',
' pass',
'',
'',
'class EdgeModel(BaseModel):',
' pass',
'',
'',
'# ============== 实体类型定义 ==============',
@ -503,4 +515,3 @@ class OntologyGenerator:
code_lines.append('}')
return '\n'.join(code_lines)

View File

@ -16,9 +16,8 @@ from typing import Dict, Any, List, Optional, Callable
from dataclasses import dataclass, field, asdict
from datetime import datetime
from openai import OpenAI
from ..config import Config
from ..adapters.llm.agent_runtime import AgentRuntime, NeedAgentResponse
from ..utils.logger import get_logger
from ..utils.locale import get_language_instruction, t
from .zep_entity_reader import EntityNode, ZepEntityReader
@ -228,17 +227,10 @@ class SimulationConfigGenerator:
base_url: Optional[str] = None,
model_name: Optional[str] = None
):
self.api_key = api_key or Config.LLM_API_KEY
self.api_key = api_key
self.base_url = base_url or Config.LLM_BASE_URL
self.model_name = model_name or Config.LLM_MODEL_NAME
if not self.api_key:
raise ValueError("LLM_API_KEY 未配置")
self.client = OpenAI(
api_key=self.api_key,
base_url=self.base_url
)
self.runtime = AgentRuntime()
def generate_config(
self,
@ -440,38 +432,28 @@ class SimulationConfigGenerator:
for attempt in range(max_attempts):
try:
response = self.client.chat.completions.create(
model=self.model_name,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
],
response_format={"type": "json_object"},
temperature=0.7 - (attempt * 0.1) # 每次重试降低温度
# 不设置max_tokens让LLM自由发挥
result = self.runtime.run_task(
run_id="legacy-simulation-config",
task_type="generate_simulation_config",
stage="simulation_config_generator",
expected_schema={"type": "object"},
input_text=prompt,
structured_input={
"temperature": 0.7 - (attempt * 0.1),
"model_name": self.model_name,
},
system_prompt=system_prompt,
user_prompt=prompt,
validation_rules={"json_object": True},
)
content = response.choices[0].message.content
finish_reason = response.choices[0].finish_reason
# 检查是否被截断
if finish_reason == 'length':
logger.warning(f"LLM输出被截断 (attempt {attempt+1})")
content = self._fix_truncated_json(content)
# 尝试解析JSON
try:
return json.loads(content)
except json.JSONDecodeError as e:
logger.warning(f"JSON解析失败 (attempt {attempt+1}): {str(e)[:80]}")
# 尝试修复JSON
fixed = self._try_fix_config_json(content)
if fixed:
return fixed
last_error = e
if result.status == "need_agent_response":
raise NeedAgentResponse(result)
if result.status != "ok":
raise RuntimeError(result.error or "LLM provider failed")
return result.output or {}
except NeedAgentResponse:
raise
except Exception as e:
logger.warning(f"LLM调用失败 (attempt {attempt+1}): {str(e)[:80]}")
last_error = e
@ -988,4 +970,3 @@ class SimulationConfigGenerator:
"influence_weight": 1.0
}

View File

@ -333,7 +333,11 @@ class SimulationRunner:
"""
# 检查是否已在运行
existing = cls.get_run_state(simulation_id)
if existing and existing.runner_status in [RunnerStatus.RUNNING, RunnerStatus.STARTING]:
if (
existing
and existing.runner_status in [RunnerStatus.RUNNING, RunnerStatus.STARTING]
and cls.is_process_alive(simulation_id)
):
raise ValueError(f"模拟已在运行中: {simulation_id}")
# 加载模拟配置
@ -1182,6 +1186,7 @@ class SimulationRunner:
# 防止重复清理的标志
_cleanup_done = False
_skip_exit_cleanup = False
@classmethod
def cleanup_all_simulations(cls):
@ -1190,6 +1195,10 @@ class SimulationRunner:
在服务器关闭时调用确保所有子进程被终止
"""
if cls._skip_exit_cleanup:
logger.info("跳过模拟进程清理:开发模式下保留等待命令模式的模拟进程")
return
# 防止重复清理
if cls._cleanup_done:
return
@ -1318,6 +1327,27 @@ class SimulationRunner:
def cleanup_handler(signum=None, frame=None):
"""信号处理器:先清理模拟进程,再调用原处理器"""
preserve_on_reload = (
Config.DEBUG
and Config.PRESERVE_SIMULATIONS_ON_RELOAD
and signum in {signal.SIGTERM, getattr(signal, 'SIGHUP', None)}
)
if preserve_on_reload:
cls._skip_exit_cleanup = True
if cls._processes or cls._graph_memory_enabled:
logger.info(
f"收到信号 {signum},开发模式下保留模拟进程;"
"请使用 /api/simulation/stop 或 /api/simulation/close-env 显式停止"
)
if signum == signal.SIGTERM and callable(original_sigterm):
original_sigterm(signum, frame)
return
if has_sighup and signum == signal.SIGHUP and callable(original_sighup):
original_sighup(signum, frame)
return
sys.exit(0)
# 只有在有进程需要清理时才打印日志
if cls._processes or cls._graph_memory_enabled:
logger.info(f"收到信号 {signum},开始清理...")
@ -1367,6 +1397,24 @@ class SimulationRunner:
if process.poll() is None:
running.append(sim_id)
return running
@classmethod
def is_process_alive(cls, simulation_id: str) -> bool:
"""检查模拟子进程是否仍然存在。"""
process = cls._processes.get(simulation_id)
if process:
return process.poll() is None
state = cls.get_run_state(simulation_id)
pid = state.process_pid if state else None
if not pid:
return False
try:
os.kill(pid, 0)
return True
except OSError:
return False
# ============== Interview 功能 ==============
@ -1385,6 +1433,9 @@ class SimulationRunner:
if not os.path.exists(sim_dir):
return False
if not cls.is_process_alive(simulation_id):
return False
ipc_client = SimulationIPCClient(sim_dir)
return ipc_client.check_env_alive()
@ -1765,4 +1816,3 @@ class SimulationRunner:
results = results[:limit]
return results

View File

@ -7,11 +7,9 @@ import time
from typing import Dict, Any, List, Optional, Set, Callable, TypeVar
from dataclasses import dataclass, field
from zep_cloud.client import Zep
from ..config import Config
from ..adapters.graph.factory import create_graph_provider
from ..utils.logger import get_logger
from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges
logger = get_logger('mirofish.zep_entity_reader')
@ -79,11 +77,8 @@ class ZepEntityReader:
"""
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or Config.ZEP_API_KEY
if not self.api_key:
raise ValueError("ZEP_API_KEY 未配置")
self.client = Zep(api_key=self.api_key)
self.api_key = api_key
self.provider = create_graph_provider()
def _call_with_retry(
self,
@ -136,17 +131,7 @@ class ZepEntityReader:
"""
logger.info(f"获取图谱 {graph_id} 的所有节点...")
nodes = fetch_all_nodes(self.client, graph_id)
nodes_data = []
for node in nodes:
nodes_data.append({
"uuid": getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''),
"name": node.name or "",
"labels": node.labels or [],
"summary": node.summary or "",
"attributes": node.attributes or {},
})
nodes_data = self.provider.list_entities(graph_id)
logger.info(f"共获取 {len(nodes_data)} 个节点")
return nodes_data
@ -163,18 +148,7 @@ class ZepEntityReader:
"""
logger.info(f"获取图谱 {graph_id} 的所有边...")
edges = fetch_all_edges(self.client, graph_id)
edges_data = []
for edge in edges:
edges_data.append({
"uuid": getattr(edge, 'uuid_', None) or getattr(edge, 'uuid', ''),
"name": edge.name or "",
"fact": edge.fact or "",
"source_node_uuid": edge.source_node_uuid,
"target_node_uuid": edge.target_node_uuid,
"attributes": edge.attributes or {},
})
edges_data = self.provider.search(graph_id, "", limit=10000)
logger.info(f"共获取 {len(edges_data)} 条边")
return edges_data
@ -190,24 +164,7 @@ class ZepEntityReader:
边列表
"""
try:
# 使用重试机制调用Zep API
edges = self._call_with_retry(
func=lambda: self.client.graph.node.get_entity_edges(node_uuid=node_uuid),
operation_name=f"获取节点边(node={node_uuid[:8]}...)"
)
edges_data = []
for edge in edges:
edges_data.append({
"uuid": getattr(edge, 'uuid_', None) or getattr(edge, 'uuid', ''),
"name": edge.name or "",
"fact": edge.fact or "",
"source_node_uuid": edge.source_node_uuid,
"target_node_uuid": edge.target_node_uuid,
"attributes": edge.attributes or {},
})
return edges_data
return self.provider.neighbors("", node_uuid, depth=1)
except Exception as e:
logger.warning(f"获取节点 {node_uuid} 的边失败: {str(e)}")
return []
@ -346,20 +303,19 @@ class ZepEntityReader:
EntityNode或None
"""
try:
# 使用重试机制获取节点
node = self._call_with_retry(
func=lambda: self.client.graph.node.get(uuid_=entity_uuid),
operation_name=f"获取节点详情(uuid={entity_uuid[:8]}...)"
)
all_nodes = self.get_all_nodes(graph_id)
node = next((item for item in all_nodes if item.get("uuid") == entity_uuid), None)
if not node:
return None
# 获取节点的边
edges = self.get_node_edges(entity_uuid)
edges = [
edge for edge in self.get_all_edges(graph_id)
if edge.get("source_node_uuid") == entity_uuid or edge.get("target_node_uuid") == entity_uuid
]
# 获取所有节点用于关联查找
all_nodes = self.get_all_nodes(graph_id)
node_map = {n["uuid"]: n for n in all_nodes}
# 处理相关边和节点
@ -397,11 +353,11 @@ class ZepEntityReader:
})
return EntityNode(
uuid=getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''),
name=node.name or "",
labels=node.labels or [],
summary=node.summary or "",
attributes=node.attributes or {},
uuid=node.get("uuid", ""),
name=node.get("name", "") or "",
labels=node.get("labels", []) or [],
summary=node.get("summary", "") or "",
attributes=node.get("attributes", {}) or {},
related_edges=related_edges,
related_nodes=related_nodes,
)
@ -433,5 +389,3 @@ class ZepEntityReader:
enrich_with_edges=enrich_with_edges
)
return result.entities

View File

@ -12,9 +12,8 @@ from dataclasses import dataclass
from datetime import datetime
from queue import Queue, Empty
from zep_cloud.client import Zep
from ..config import Config
from ..adapters.graph.factory import create_graph_provider
from ..utils.logger import get_logger
from ..utils.locale import get_locale, set_locale
@ -235,15 +234,11 @@ class ZepGraphMemoryUpdater:
Args:
graph_id: Zep图谱ID
api_key: Zep API Key可选默认从配置读取
api_key: legacy Zep API Key仅由 legacy provider 使用
"""
self.graph_id = graph_id
self.api_key = api_key or Config.ZEP_API_KEY
if not self.api_key:
raise ValueError("ZEP_API_KEY未配置")
self.client = Zep(api_key=self.api_key)
self.api_key = api_key
self.provider = create_graph_provider()
# 活动队列
self._activity_queue: Queue = Queue()
@ -411,10 +406,10 @@ class ZepGraphMemoryUpdater:
# 带重试的发送
for attempt in range(self.MAX_RETRIES):
try:
self.client.graph.add(
graph_id=self.graph_id,
type="text",
data=combined_text
self.provider.add_episode(
self.graph_id,
combined_text,
{"platform": platform, "source": "simulation_activity"},
)
self._total_sent += 1

View File

@ -13,13 +13,11 @@ import json
from typing import Dict, Any, List, Optional
from dataclasses import dataclass, field
from zep_cloud.client import Zep
from ..config import Config
from ..adapters.graph.factory import create_graph_provider
from ..utils.logger import get_logger
from ..utils.llm_client import LLMClient
from ..utils.locale import get_locale, t
from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges
logger = get_logger('mirofish.zep_tools')
@ -423,11 +421,8 @@ class ZepToolsService:
RETRY_DELAY = 2.0
def __init__(self, api_key: Optional[str] = None, llm_client: Optional[LLMClient] = None):
self.api_key = api_key or Config.ZEP_API_KEY
if not self.api_key:
raise ValueError("ZEP_API_KEY 未配置")
self.client = Zep(api_key=self.api_key)
self.api_key = api_key
self.provider = create_graph_provider()
# LLM客户端用于InsightForge生成子问题
self._llm_client = llm_client
logger.info(t("console.zepToolsInitialized"))
@ -485,48 +480,28 @@ class ZepToolsService:
"""
logger.info(t("console.graphSearch", graphId=graph_id, query=query[:50]))
# 尝试使用Zep Cloud Search API
# 使用统一 GraphProvider 搜索legacy Zep 和 agent Graphiti 都在 provider 内部处理。
try:
search_results = self._call_with_retry(
func=lambda: self.client.graph.search(
graph_id=graph_id,
query=query,
limit=limit,
scope=scope,
reranker="cross_encoder"
),
operation_name=t("console.graphSearchOp", graphId=graph_id)
)
search_results = self.provider.search(graph_id, query, limit=limit)
facts = []
edges = []
nodes = []
# 解析边搜索结果
if hasattr(search_results, 'edges') and search_results.edges:
for edge in search_results.edges:
if hasattr(edge, 'fact') and edge.fact:
facts.append(edge.fact)
edges.append({
"uuid": getattr(edge, 'uuid_', None) or getattr(edge, 'uuid', ''),
"name": getattr(edge, 'name', ''),
"fact": getattr(edge, 'fact', ''),
"source_node_uuid": getattr(edge, 'source_node_uuid', ''),
"target_node_uuid": getattr(edge, 'target_node_uuid', ''),
})
# 解析节点搜索结果
if hasattr(search_results, 'nodes') and search_results.nodes:
for node in search_results.nodes:
nodes.append({
"uuid": getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''),
"name": getattr(node, 'name', ''),
"labels": getattr(node, 'labels', []),
"summary": getattr(node, 'summary', ''),
})
# 节点摘要也算作事实
if hasattr(node, 'summary') and node.summary:
facts.append(f"[{node.name}]: {node.summary}")
for item in search_results:
if item.get("node"):
node = item["node"]
nodes.append(node)
if node.get("summary"):
facts.append(f"[{node.get('name', '')}]: {node['summary']}")
continue
if item.get("fact"):
facts.append(item["fact"])
edges.append({
"uuid": item.get("uuid", ""),
"name": item.get("predicate") or item.get("name", ""),
"fact": item.get("fact", ""),
"source_node_uuid": item.get("source_node_uuid", ""),
"target_node_uuid": item.get("target_node_uuid", ""),
})
logger.info(t("console.searchComplete", count=len(facts)))
@ -659,17 +634,15 @@ class ZepToolsService:
"""
logger.info(t("console.fetchingAllNodes", graphId=graph_id))
nodes = fetch_all_nodes(self.client, graph_id)
result = []
for node in nodes:
node_uuid = getattr(node, 'uuid_', None) or getattr(node, 'uuid', None) or ""
for node in self.provider.list_entities(graph_id):
node_uuid = node.get("uuid", "")
result.append(NodeInfo(
uuid=str(node_uuid) if node_uuid else "",
name=node.name or "",
labels=node.labels or [],
summary=node.summary or "",
attributes=node.attributes or {}
name=node.get("name", "") or "",
labels=node.get("labels", []) or [],
summary=node.get("summary", "") or "",
attributes=node.get("attributes", {}) or {}
))
logger.info(t("console.fetchedNodes", count=len(result)))
@ -688,25 +661,23 @@ class ZepToolsService:
"""
logger.info(t("console.fetchingAllEdges", graphId=graph_id))
edges = fetch_all_edges(self.client, graph_id)
result = []
for edge in edges:
edge_uuid = getattr(edge, 'uuid_', None) or getattr(edge, 'uuid', None) or ""
for edge in self.provider.search(graph_id, "", limit=10000):
edge_uuid = edge.get("uuid", "")
edge_info = EdgeInfo(
uuid=str(edge_uuid) if edge_uuid else "",
name=edge.name or "",
fact=edge.fact or "",
source_node_uuid=edge.source_node_uuid or "",
target_node_uuid=edge.target_node_uuid or ""
name=edge.get("predicate") or edge.get("name", ""),
fact=edge.get("fact", ""),
source_node_uuid=edge.get("source_node_uuid", ""),
target_node_uuid=edge.get("target_node_uuid", "")
)
# 添加时间信息
if include_temporal:
edge_info.created_at = getattr(edge, 'created_at', None)
edge_info.valid_at = getattr(edge, 'valid_at', None)
edge_info.invalid_at = getattr(edge, 'invalid_at', None)
edge_info.expired_at = getattr(edge, 'expired_at', None)
edge_info.created_at = edge.get("created_at")
edge_info.valid_at = edge.get("valid_at")
edge_info.invalid_at = edge.get("invalid_at")
edge_info.expired_at = edge.get("expired_at")
result.append(edge_info)
@ -726,20 +697,20 @@ class ZepToolsService:
logger.info(t("console.fetchingNodeDetail", uuid=node_uuid[:8]))
try:
node = self._call_with_retry(
func=lambda: self.client.graph.node.get(uuid_=node_uuid),
operation_name=t("console.fetchNodeDetailOp", uuid=node_uuid[:8])
)
node = None
for candidate in self.provider.list_entities(""):
if candidate.get("uuid") == node_uuid:
node = candidate
break
if not node:
return None
return NodeInfo(
uuid=getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''),
name=node.name or "",
labels=node.labels or [],
summary=node.summary or "",
attributes=node.attributes or {}
uuid=node.get("uuid", ""),
name=node.get("name", "") or "",
labels=node.get("labels", []) or [],
summary=node.get("summary", "") or "",
attributes=node.get("attributes", {}) or {}
)
except Exception as e:
logger.error(t("console.fetchNodeDetailFailed", error=str(e)))

View File

@ -1,18 +1,21 @@
"""
LLM客户端封装
统一使用OpenAI格式调用
"""
"""Legacy LLMClient facade backed by AgentRuntime."""
import json
import re
from pathlib import Path
from typing import Optional, Dict, Any, List
from openai import OpenAI
from ..config import Config
from ..adapters.llm.agent_runtime import AgentRuntime, NeedAgentResponse
from ..agent_engine.json_schema import object_schema
class LLMClient:
"""LLM客户端"""
"""Compatibility wrapper for older services.
New business code should call AgentRuntime directly. This class remains so
legacy UI/report code can be migrated incrementally without direct SDK use.
"""
def __init__(
self,
@ -20,17 +23,12 @@ class LLMClient:
base_url: Optional[str] = None,
model: Optional[str] = None
):
self.api_key = api_key or Config.LLM_API_KEY
self.api_key = api_key
self.base_url = base_url or Config.LLM_BASE_URL
self.model = model or Config.LLM_MODEL_NAME
if not self.api_key:
raise ValueError("LLM_API_KEY 未配置")
self.client = OpenAI(
api_key=self.api_key,
base_url=self.base_url
)
legacy_run_dir = Path(Config.MIROFISH_RUNS_DIR) / "legacy-ui"
legacy_run_dir.mkdir(parents=True, exist_ok=True)
self.runtime = AgentRuntime(run_dir=str(legacy_run_dir))
def chat(
self,
@ -51,18 +49,35 @@ class LLMClient:
Returns:
模型响应文本
"""
kwargs = {
"model": self.model,
if response_format and response_format.get("type") == "json_object":
max_tokens = max(max_tokens, 8192)
structured_input = {
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"response_format": response_format,
"legacy_client": True,
}
if response_format:
kwargs["response_format"] = response_format
response = self.client.chat.completions.create(**kwargs)
content = response.choices[0].message.content
user_prompt = "\n".join(message.get("content", "") for message in messages if message.get("role") == "user")
system_prompt = "\n".join(message.get("content", "") for message in messages if message.get("role") == "system")
result = self.runtime.run_task(
run_id="legacy-ui",
task_type="validate_json_output" if response_format else "answer_followup_question",
stage="legacy_llm_client",
expected_schema=object_schema({"text": {"type": "string"}}, ["text"]),
structured_input=structured_input,
system_prompt=system_prompt,
user_prompt=user_prompt,
)
if result.status == "need_agent_response":
raise NeedAgentResponse(result)
if result.status != "ok":
raise RuntimeError(result.error or "LLM provider failed")
content = (result.output or {}).get("text")
if content is None and result.output:
content = json.dumps(result.output, ensure_ascii=False)
content = content or ""
# 部分模型如MiniMax M2.5会在content中包含<think>思考内容,需要移除
content = re.sub(r'<think>[\s\S]*?</think>', '', content).strip()
return content
@ -84,12 +99,31 @@ class LLMClient:
Returns:
解析后的JSON对象
"""
response = self.chat(
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
response_format={"type": "json_object"}
structured_input = {
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"response_format": {"type": "json_object"},
"legacy_client": True,
}
user_prompt = "\n".join(message.get("content", "") for message in messages if message.get("role") == "user")
system_prompt = "\n".join(message.get("content", "") for message in messages if message.get("role") == "system")
result = self.runtime.run_task(
run_id="legacy-ui",
task_type="validate_json_output",
stage="legacy_llm_client",
expected_schema={"type": "object"},
structured_input=structured_input,
system_prompt=system_prompt,
user_prompt=user_prompt,
)
if result.status == "need_agent_response":
raise NeedAgentResponse(result)
if result.status != "ok":
raise RuntimeError(result.error or "LLM provider failed")
if isinstance(result.output, dict):
return result.output
response = json.dumps(result.output, ensure_ascii=False)
# 清理markdown代码块标记
cleaned_response = response.strip()
cleaned_response = re.sub(r'^```(?:json)?\s*\n?', '', cleaned_response, flags=re.IGNORECASE)
@ -100,4 +134,3 @@ class LLMClient:
return json.loads(cleaned_response)
except json.JSONDecodeError:
raise ValueError(f"LLM返回的JSON格式无效: {cleaned_response}")

View File

@ -10,8 +10,7 @@ import time
from collections.abc import Callable
from typing import Any
from zep_cloud import InternalServerError
from zep_cloud.client import Zep
import httpx
from .logger import get_logger
@ -41,7 +40,7 @@ def _fetch_page_with_retry(
for attempt in range(max_retries):
try:
return api_call(*args, **kwargs)
except (ConnectionError, TimeoutError, OSError, InternalServerError) as e:
except (ConnectionError, TimeoutError, OSError, httpx.TransportError) as e:
last_exception = e
if attempt < max_retries - 1:
logger.warning(
@ -57,7 +56,7 @@ def _fetch_page_with_retry(
def fetch_all_nodes(
client: Zep,
client: Any,
graph_id: str,
page_size: int = _DEFAULT_PAGE_SIZE,
max_items: int = _MAX_NODES,
@ -103,7 +102,7 @@ def fetch_all_nodes(
def fetch_all_edges(
client: Zep,
client: Any,
graph_id: str,
page_size: int = _DEFAULT_PAGE_SIZE,
max_retries: int = _DEFAULT_MAX_RETRIES,

View File

@ -13,12 +13,6 @@ dependencies = [
"flask>=3.0.0",
"flask-cors>=6.0.0",
# LLM 相关
"openai>=1.0.0",
# Zep Cloud
"zep-cloud==3.13.0",
# OASIS 社交媒体模拟
"camel-oasis==0.2.5",
"camel-ai==0.2.78",
@ -34,7 +28,20 @@ dependencies = [
"pydantic>=2.0.0",
]
[project.scripts]
mirofish-agent = "app.agent_engine.cli:main"
mirofish-mcp = "app.mcp_server.server:main"
[project.optional-dependencies]
agent = [
"graphiti-core",
"neo4j",
"mcp",
]
legacy = [
"openai>=1.0.0",
"zep-cloud==3.13.0",
]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",

View File

@ -89,7 +89,7 @@ _project_root = os.path.abspath(os.path.join(_backend_dir, '..'))
sys.path.insert(0, _scripts_dir)
sys.path.insert(0, _backend_dir)
# 加载项目根目录的 .env 文件(包含 LLM_API_KEY 等配置)
# 加载项目根目录的 .env 文件(包含 MiroFish provider 配置)
from dotenv import load_dotenv
_env_file = os.path.join(_project_root, '.env')
if os.path.exists(_env_file):
@ -158,8 +158,7 @@ def init_logging_for_simulation(simulation_dir: str):
from action_logger import SimulationLogManager, PlatformActionLogger
try:
from camel.models import ModelFactory
from camel.types import ModelPlatformType
from app.adapters.llm.camel_adapter import create_model_backend
import oasis
from oasis import (
ActionType,
@ -985,56 +984,18 @@ def create_model(config: Dict[str, Any], use_boost: bool = False):
"""
创建LLM模型
支持双 LLM 配置用于并行模拟时提速
- 通用配置LLM_API_KEY, LLM_BASE_URL, LLM_MODEL_NAME
- 加速配置可选LLM_BOOST_API_KEY, LLM_BOOST_BASE_URL, LLM_BOOST_MODEL_NAME
如果配置了加速 LLM并行模拟时可以让不同平台使用不同的 API 服务商提高并发能力
使用 AgentRuntime / LLMProvider 创建模型后端
agent_queue 模式不需要模型 API keyopenai_compatible legacy 模式才使用 LLM_* 配置
Args:
config: 模拟配置字典
use_boost: 是否使用加速 LLM 配置如果可用
"""
# 检查是否有加速配置
boost_api_key = os.environ.get("LLM_BOOST_API_KEY", "")
boost_base_url = os.environ.get("LLM_BOOST_BASE_URL", "")
boost_model = os.environ.get("LLM_BOOST_MODEL_NAME", "")
has_boost_config = bool(boost_api_key)
# 根据参数和配置情况选择使用哪个 LLM
if use_boost and has_boost_config:
# 使用加速配置
llm_api_key = boost_api_key
llm_base_url = boost_base_url
llm_model = boost_model or os.environ.get("LLM_MODEL_NAME", "")
config_label = "[加速LLM]"
else:
# 使用通用配置
llm_api_key = os.environ.get("LLM_API_KEY", "")
llm_base_url = os.environ.get("LLM_BASE_URL", "")
llm_model = os.environ.get("LLM_MODEL_NAME", "")
config_label = "[通用LLM]"
# 如果 .env 中没有模型名,则使用 config 作为备用
if not llm_model:
llm_model = config.get("llm_model", "gpt-4o-mini")
# 设置 camel-ai 所需的环境变量
if llm_api_key:
os.environ["OPENAI_API_KEY"] = llm_api_key
if not os.environ.get("OPENAI_API_KEY"):
raise ValueError("缺少 API Key 配置,请在项目根目录 .env 文件中设置 LLM_API_KEY")
if llm_base_url:
os.environ["OPENAI_API_BASE_URL"] = llm_base_url
print(f"{config_label} model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else '默认'}...")
return ModelFactory.create(
model_platform=ModelPlatformType.OPENAI,
model_type=llm_model,
)
run_id = config.get("simulation_id") or config.get("project_id") or "parallel-simulation"
run_dir = config.get("simulation_dir") or os.getcwd()
config_label = "[AgentRuntime]"
print(f"{config_label} model backend adapter, run_id={run_id}")
return create_model_backend(run_id=run_id, run_dir=run_dir)
def get_active_agents_for_round(

View File

@ -36,7 +36,7 @@ _project_root = os.path.abspath(os.path.join(_backend_dir, '..'))
sys.path.insert(0, _scripts_dir)
sys.path.insert(0, _backend_dir)
# 加载项目根目录的 .env 文件(包含 LLM_API_KEY 等配置)
# 加载项目根目录的 .env 文件(包含 MiroFish provider 配置)
from dotenv import load_dotenv
_env_file = os.path.join(_project_root, '.env')
if os.path.exists(_env_file):
@ -116,8 +116,7 @@ def setup_oasis_logging(log_dir: str):
try:
from camel.models import ModelFactory
from camel.types import ModelPlatformType
from app.adapters.llm.camel_adapter import create_model_backend
import oasis
from oasis import (
ActionType,
@ -435,36 +434,13 @@ class RedditSimulationRunner:
"""
创建LLM模型
统一使用项目根目录 .env 文件中的配置优先级最高
- LLM_API_KEY: API密钥
- LLM_BASE_URL: API基础URL
- LLM_MODEL_NAME: 模型名称
统一使用 AgentRuntime / LLMProvider 配置优先级最高
- MIROFISH_LLM_PROVIDER=agent_queue 时不需要模型 API key
- MIROFISH_LLM_PROVIDER=openai_compatible 时使用 legacy LLM_* 配置
"""
# 优先从 .env 读取配置
llm_api_key = os.environ.get("LLM_API_KEY", "")
llm_base_url = os.environ.get("LLM_BASE_URL", "")
llm_model = os.environ.get("LLM_MODEL_NAME", "")
# 如果 .env 中没有,则使用 config 作为备用
if not llm_model:
llm_model = self.config.get("llm_model", "gpt-4o-mini")
# 设置 camel-ai 所需的环境变量
if llm_api_key:
os.environ["OPENAI_API_KEY"] = llm_api_key
if not os.environ.get("OPENAI_API_KEY"):
raise ValueError("缺少 API Key 配置,请在项目根目录 .env 文件中设置 LLM_API_KEY")
if llm_base_url:
os.environ["OPENAI_API_BASE_URL"] = llm_base_url
print(f"LLM配置: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else '默认'}...")
return ModelFactory.create(
model_platform=ModelPlatformType.OPENAI,
model_type=llm_model,
)
run_id = self.config.get("simulation_id") or os.path.basename(self.simulation_dir)
print(f"LLM配置: AgentRuntime adapter, run_id={run_id}")
return create_model_backend(run_id=run_id, run_dir=self.simulation_dir)
def _get_active_agents_for_round(
self,
@ -766,4 +742,3 @@ if __name__ == "__main__":
pass
finally:
print("模拟进程已退出")

View File

@ -36,7 +36,7 @@ _project_root = os.path.abspath(os.path.join(_backend_dir, '..'))
sys.path.insert(0, _scripts_dir)
sys.path.insert(0, _backend_dir)
# 加载项目根目录的 .env 文件(包含 LLM_API_KEY 等配置)
# 加载项目根目录的 .env 文件(包含 MiroFish provider 配置)
from dotenv import load_dotenv
_env_file = os.path.join(_project_root, '.env')
if os.path.exists(_env_file):
@ -116,8 +116,7 @@ def setup_oasis_logging(log_dir: str):
try:
from camel.models import ModelFactory
from camel.types import ModelPlatformType
from app.adapters.llm.camel_adapter import create_model_backend
import oasis
from oasis import (
ActionType,
@ -428,36 +427,13 @@ class TwitterSimulationRunner:
"""
创建LLM模型
统一使用项目根目录 .env 文件中的配置优先级最高
- LLM_API_KEY: API密钥
- LLM_BASE_URL: API基础URL
- LLM_MODEL_NAME: 模型名称
统一使用 AgentRuntime / LLMProvider 配置优先级最高
- MIROFISH_LLM_PROVIDER=agent_queue 时不需要模型 API key
- MIROFISH_LLM_PROVIDER=openai_compatible 时使用 legacy LLM_* 配置
"""
# 优先从 .env 读取配置
llm_api_key = os.environ.get("LLM_API_KEY", "")
llm_base_url = os.environ.get("LLM_BASE_URL", "")
llm_model = os.environ.get("LLM_MODEL_NAME", "")
# 如果 .env 中没有,则使用 config 作为备用
if not llm_model:
llm_model = self.config.get("llm_model", "gpt-4o-mini")
# 设置 camel-ai 所需的环境变量
if llm_api_key:
os.environ["OPENAI_API_KEY"] = llm_api_key
if not os.environ.get("OPENAI_API_KEY"):
raise ValueError("缺少 API Key 配置,请在项目根目录 .env 文件中设置 LLM_API_KEY")
if llm_base_url:
os.environ["OPENAI_API_BASE_URL"] = llm_base_url
print(f"LLM配置: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else '默认'}...")
return ModelFactory.create(
model_platform=ModelPlatformType.OPENAI,
model_type=llm_model,
)
run_id = self.config.get("simulation_id") or os.path.basename(self.simulation_dir)
print(f"LLM配置: AgentRuntime adapter, run_id={run_id}")
return create_model_backend(run_id=run_id, run_dir=self.simulation_dir)
def _get_active_agents_for_round(
self,

View File

@ -0,0 +1,194 @@
import importlib.util
import json
import sys
import types
from pathlib import Path
import tomllib
from app.agent_engine.runner import PredictionRunService
def test_agent_optional_dependencies_declared():
pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
agent_deps = set(pyproject["project"]["optional-dependencies"]["agent"])
assert {"graphiti-core", "neo4j", "mcp"}.issubset(agent_deps)
class _FakeResult:
def __init__(self, version="5.26.0"):
self.version = version
def single(self):
return {"versions": [self.version]}
class _FakeSession:
def __init__(self, version="5.26.0"):
self.version = version
def __enter__(self):
return self
def __exit__(self, exc_type, exc, traceback):
return False
def run(self, query):
assert "CALL dbms.components()" in query
return _FakeResult(self.version)
class _FakeDriver:
def __init__(self, version="5.26.0"):
self.version = version
def session(self, database):
assert database == "neo4j"
return _FakeSession(self.version)
def close(self):
return None
def _fake_graph_database(version="5.26.0"):
class FakeGraphDatabase:
@staticmethod
def driver(uri, auth):
assert uri == "bolt://localhost:7687"
assert auth == ("neo4j", "password")
return _FakeDriver(version)
return FakeGraphDatabase
class _FakeUrlResponse:
def __init__(self, payload):
self.payload = payload
def __enter__(self):
return self
def __exit__(self, exc_type, exc, traceback):
return False
def read(self):
return json.dumps(self.payload).encode("utf-8")
def _set_agent_env(monkeypatch):
monkeypatch.setenv("MIROFISH_MODE", "agent")
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
monkeypatch.delenv("MIROFISH_GRAPHITI_STORE", raising=False)
monkeypatch.setenv("NEO4J_URI", "bolt://localhost:7687")
monkeypatch.setenv("NEO4J_USER", "neo4j")
monkeypatch.setenv("NEO4J_PASSWORD", "password")
monkeypatch.setenv("NEO4J_DATABASE", "neo4j")
monkeypatch.setenv("MIROFISH_GRAPH_SEARCH_MODE", "semantic")
monkeypatch.setenv("MIROFISH_EMBEDDING_PROVIDER", "ollama")
monkeypatch.setenv("OLLAMA_BASE_URL", "http://localhost:11434")
monkeypatch.setenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text")
def _mock_agent_deps(monkeypatch, ollama_payload, *, neo4j_version="5.26.0"):
original_find_spec = importlib.util.find_spec
def fake_find_spec(name):
if name in {"graphiti_core", "neo4j", "mcp"}:
return object()
return original_find_spec(name)
monkeypatch.setattr(importlib.util, "find_spec", fake_find_spec)
monkeypatch.setitem(sys.modules, "neo4j", types.SimpleNamespace(GraphDatabase=_fake_graph_database(neo4j_version)))
monkeypatch.setattr("shutil.which", lambda name: None)
monkeypatch.setattr(
"urllib.request.urlopen",
lambda url, timeout: _FakeUrlResponse(ollama_payload),
)
def test_doctor_agent_graphiti_checks_neo4j_and_ollama(monkeypatch, tmp_path):
_set_agent_env(monkeypatch)
_mock_agent_deps(monkeypatch, {"models": [{"name": "nomic-embed-text:latest"}]})
result = PredictionRunService().doctor(str(tmp_path))
assert result["status"] == "ok"
checks = {check["name"]: check for check in result["checks"]}
assert checks["graphiti_package"]["ok"] is True
assert checks["neo4j_connectable"]["ok"] is True
assert checks["neo4j_version_supported"]["ok"] is True
assert checks["ollama_connectable"]["ok"] is True
assert checks["ollama_embedding_model"]["ok"] is True
assert checks["docker"]["required"] is False
assert checks["docker_compose"]["required"] is False
def test_doctor_agent_graphiti_fails_when_ollama_model_missing(monkeypatch, tmp_path):
_set_agent_env(monkeypatch)
_mock_agent_deps(monkeypatch, {"models": []})
result = PredictionRunService().doctor(str(tmp_path))
assert result["status"] == "failed"
hard_failures = {check["name"] for check in result["hard_failures"]}
assert "ollama_embedding_model" in hard_failures
def test_doctor_does_not_require_ollama_when_embedding_provider_is_not_ollama(monkeypatch, tmp_path):
_set_agent_env(monkeypatch)
monkeypatch.setenv("MIROFISH_EMBEDDING_PROVIDER", "none")
_mock_agent_deps(monkeypatch, {"models": []})
result = PredictionRunService().doctor(str(tmp_path))
assert result["status"] == "ok"
checks = {check["name"]: check for check in result["checks"]}
assert checks["ollama_connectable"]["required"] is False
assert checks["ollama_embedding_model"]["required"] is False
def test_doctor_does_not_require_ollama_for_fulltext_search(monkeypatch, tmp_path):
_set_agent_env(monkeypatch)
monkeypatch.setenv("MIROFISH_GRAPH_SEARCH_MODE", "fulltext")
monkeypatch.setenv("MIROFISH_EMBEDDING_PROVIDER", "ollama")
_mock_agent_deps(monkeypatch, {"models": []})
result = PredictionRunService().doctor(str(tmp_path))
assert result["status"] == "ok"
checks = {check["name"]: check for check in result["checks"]}
assert checks["graph_search_mode"]["value"] == "fulltext"
assert checks["ollama_connectable"]["required"] is False
assert "fulltext search does not require Ollama" in checks["ollama_connectable"]["value"]
def test_doctor_requires_neo4j_526_or_newer(monkeypatch, tmp_path):
_set_agent_env(monkeypatch)
monkeypatch.setenv("MIROFISH_EMBEDDING_PROVIDER", "none")
_mock_agent_deps(monkeypatch, {"models": []}, neo4j_version="5.25.0")
result = PredictionRunService().doctor(str(tmp_path))
assert result["status"] == "failed"
hard_failures = {check["name"] for check in result["hard_failures"]}
assert "neo4j_version_supported" in hard_failures
def test_doctor_hard_failures_are_limited_to_dependency_and_service_gates(monkeypatch, tmp_path):
_set_agent_env(monkeypatch)
monkeypatch.setenv("MIROFISH_GRAPH_SEARCH_MODE", "not-a-mode")
monkeypatch.setenv("MIROFISH_EMBEDDING_PROVIDER", "not-a-provider")
_mock_agent_deps(monkeypatch, {"models": []})
result = PredictionRunService().doctor(str(tmp_path))
allowed = {
"graphiti_package",
"neo4j_package",
"neo4j_connectable",
"neo4j_version_supported",
"ollama_connectable",
"ollama_embedding_model",
}
assert {check["name"] for check in result["hard_failures"]}.issubset(allowed)

View File

@ -0,0 +1,112 @@
import importlib.util
import tomllib
from pathlib import Path
from app.config import Config
def test_agent_mode_config_does_not_require_model_or_zep_keys(monkeypatch):
monkeypatch.setenv("MIROFISH_MODE", "agent")
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
monkeypatch.delenv("LLM_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ZEP_API_KEY", raising=False)
assert Config.validate() == []
def test_legacy_llm_client_mock_mode_does_not_require_api_key(tmp_path, monkeypatch):
from app.utils.llm_client import LLMClient
monkeypatch.setenv("MIROFISH_MODE", "agent")
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "mock")
monkeypatch.setattr(Config, "MIROFISH_RUNS_DIR", str(tmp_path / "runs"))
monkeypatch.delenv("LLM_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
client = LLMClient()
result = client.chat([{"role": "user", "content": "hello"}])
assert result == "Mock text response generated without model APIs."
def test_flask_api_returns_need_agent_response_as_structured_json():
from app import create_app
from app.adapters.llm.agent_runtime import NeedAgentResponse
from app.adapters.llm.base import LLMProviderResult
app = create_app()
@app.route("/_test_need_agent_response")
def _test_need_agent_response():
raise NeedAgentResponse(
LLMProviderResult(
status="need_agent_response",
request_id="req_000001",
request_file="/tmp/req_000001.json",
expected_response_file="/tmp/resp_000001.json",
)
)
response = app.test_client().get("/_test_need_agent_response")
assert response.status_code == 202
assert response.get_json() == {
"status": "need_agent_response",
"request_id": "req_000001",
"request_file": "/tmp/req_000001.json",
"expected_response_file": "/tmp/resp_000001.json",
}
def test_flask_api_zep_guard_is_provider_aware(monkeypatch):
from app.api.graph import _legacy_zep_config_errors
from app.api.simulation import _legacy_zep_required
monkeypatch.setattr(Config, "ZEP_API_KEY", None)
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
assert _legacy_zep_config_errors() == []
assert _legacy_zep_required() is False
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "zep")
assert _legacy_zep_config_errors()
assert _legacy_zep_required() is True
def test_provider_boundary_checker_catches_legacy_adapter_and_schema_leaks(tmp_path):
checker_path = Path(__file__).resolve().parents[2] / "scripts" / "check_provider_boundaries.py"
spec = importlib.util.spec_from_file_location("check_provider_boundaries", checker_path)
checker = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(checker)
business = tmp_path / "backend" / "app" / "services" / "bad_business.py"
business.parent.mkdir(parents=True)
business.write_text(
"from app.adapters.graph.zep import ZepGraphProvider\n"
"QUERY = 'MATCH (n:MiroFishEntity) RETURN n'\n",
encoding="utf-8",
)
violations = checker.collect_violations(
[tmp_path / "backend" / "app"],
root=tmp_path,
allowed=set(),
allowed_legacy_adapter_imports=set(),
allowed_graphiti_schema=set(),
)
assert any("imports legacy provider adapter directly" in violation for violation in violations)
assert any("Graphiti/Neo4j schema assumption" in violation for violation in violations)
def test_legacy_sdks_are_optional_dependencies_only():
pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
default_dependencies = set(data["project"]["dependencies"])
legacy_dependencies = set(data["project"]["optional-dependencies"]["legacy"])
assert not any(dependency.startswith("openai") for dependency in default_dependencies)
assert not any(dependency.startswith("zep-cloud") for dependency in default_dependencies)
assert any(dependency.startswith("openai") for dependency in legacy_dependencies)
assert any(dependency.startswith("zep-cloud") for dependency in legacy_dependencies)

View File

@ -0,0 +1,240 @@
import json
from pathlib import Path
from app.adapters.llm.base import LLMTask
from app.adapters.llm.mock import MockLLMProvider
from app.agent_engine.contracts import TASK_OUTPUT_SCHEMAS
from app.agent_engine.schemas import AGENT_TASK_TYPES
from app.agent_engine.json_schema import TRIPLE_SCHEMA, object_schema, validate_json_schema
from app.agent_engine.queue import AgentQueue
from app.agent_engine.state import RunStore
def _init_run(tmp_path: Path) -> Path:
run_dir = tmp_path / "run"
store = RunStore(run_dir)
store.init_state("run", "test requirement", [])
return run_dir
def test_agent_queue_strict_response_lifecycle(tmp_path):
run_dir = _init_run(tmp_path)
queue = AgentQueue(run_dir)
need = queue.create_request(
run_id="run",
task_type="extract_triples",
stage="graph",
expected_schema=object_schema({"triples": {"type": "array", "items": TRIPLE_SCHEMA}}, ["triples"]),
)
response_path = Path(need.expected_response_file)
response_path.write_text(
json.dumps(
{
"request_id": need.request_id,
"status": "ok",
"output": {
"triples": [
{
"subject": "A",
"predicate": "affects",
"object": "B",
"fact": "A affects B.",
"valid_at": None,
"invalid_at": None,
"source": "seed",
"source_file": "seed.md",
"evidence": "A affects B.",
"confidence": 0.8,
"metadata": {},
}
]
},
}
),
encoding="utf-8",
)
result = queue.validate_response_file(response_path)
assert result.ok, result.errors
def test_agent_queue_rejects_missing_fields_and_bad_confidence(tmp_path):
run_dir = _init_run(tmp_path)
queue = AgentQueue(run_dir)
need = queue.create_request(
run_id="run",
task_type="extract_triples",
stage="graph",
expected_schema=object_schema({"triples": {"type": "array", "items": TRIPLE_SCHEMA}}, ["triples"]),
)
bad = Path(need.expected_response_file)
bad.write_text(
json.dumps(
{
"request_id": need.request_id,
"status": "ok",
"output": {
"triples": [
{
"subject": "A",
"predicate": "affects",
"object": "B",
"fact": "A affects B.",
"valid_at": None,
"invalid_at": None,
"source": "seed",
"source_file": "seed.md",
"evidence": "A affects B.",
"confidence": 1.2,
"metadata": {},
"extra": "not allowed",
}
]
},
}
),
encoding="utf-8",
)
result = queue.submit_response(bad)
assert not result.ok
assert any("confidence" in error for error in result.errors)
assert any("extra field" in error for error in result.errors)
assert result.repair_request is not None
def test_agent_queue_persists_repair_attempts(tmp_path):
run_dir = _init_run(tmp_path)
queue = AgentQueue(run_dir)
need = queue.create_request(
run_id="run",
task_type="generate_report",
stage="report",
expected_schema=object_schema({"report_markdown": {"type": "string"}}, ["report_markdown"]),
retry_policy={"max_repair_attempts": 1},
)
bad = Path(need.expected_response_file)
bad.write_text(
json.dumps(
{
"request_id": need.request_id,
"status": "ok",
"output": {},
}
),
encoding="utf-8",
)
first = queue.submit_response(bad)
second = queue.submit_response(bad)
assert not first.ok
assert first.repair_request is not None
assert not second.ok
assert second.repair_request is None
assert queue.load_request(need.request_id).retry_policy.repair_attempts_used == 1
def test_agent_queue_requires_filename_request_id_match(tmp_path):
run_dir = _init_run(tmp_path)
queue = AgentQueue(run_dir)
first = queue.create_request(
run_id="run",
task_type="generate_report",
stage="report",
expected_schema=object_schema({"report_markdown": {"type": "string"}}, ["report_markdown"]),
)
second = queue.create_request(
run_id="run",
task_type="generate_report",
stage="report",
expected_schema=object_schema({"report_markdown": {"type": "string"}}, ["report_markdown"]),
)
response_path = run_dir / "responses" / f"{second.request_id}.json"
response_path.write_text(
json.dumps(
{
"request_id": first.request_id,
"status": "ok",
"output": {"report_markdown": "ok"},
}
),
encoding="utf-8",
)
result = queue.validate_response_file(response_path)
assert not result.ok
assert any("does not match response file name" in error for error in result.errors)
def test_agent_queue_validates_skipped_output_against_expected_schema(tmp_path):
run_dir = _init_run(tmp_path)
queue = AgentQueue(run_dir)
need = queue.create_request(
run_id="run",
task_type="generate_report",
stage="report",
expected_schema=object_schema({"report_markdown": {"type": "string"}}, ["report_markdown"]),
)
response_path = Path(need.expected_response_file)
response_path.write_text(
json.dumps(
{
"request_id": need.request_id,
"status": "skipped",
"output": {},
}
),
encoding="utf-8",
)
result = queue.validate_response_file(response_path)
assert not result.ok
assert any("missing required field" in error for error in result.errors)
def test_agent_queue_supports_all_declared_task_types(tmp_path):
run_dir = _init_run(tmp_path)
queue = AgentQueue(run_dir)
schema = object_schema({"result": {"type": "object"}}, ["result"])
created = []
for task_type in sorted(AGENT_TASK_TYPES):
need = queue.create_request(
run_id="run",
task_type=task_type,
stage=task_type,
expected_schema=schema,
structured_input={"task_type": task_type},
)
created.append(need.request_id)
assert len(created) == len(AGENT_TASK_TYPES)
assert len(queue.list_requests()) == len(AGENT_TASK_TYPES)
def test_all_task_types_have_strict_schema_and_mock_output():
assert set(TASK_OUTPUT_SCHEMAS) == AGENT_TASK_TYPES
provider = MockLLMProvider()
for task_type in sorted(AGENT_TASK_TYPES):
schema = TASK_OUTPUT_SCHEMAS[task_type]
result = provider.run_task(
LLMTask(
run_id="run",
task_type=task_type,
stage=task_type,
expected_schema=schema,
structured_input={
"actions": [{"agent_id": "agent_1", "action_id": "action_1"}],
"candidate": {},
"invalid_response": {"output": {}},
},
)
)
assert result.status == "ok"
assert not validate_json_schema(result.output, schema), task_type

View File

@ -0,0 +1,77 @@
from app.adapters.llm.camel_adapter import AgentModelBackendAdapter
from app.adapters.llm.agent_queue import AgentQueueLLMProvider
from app.adapters.llm.mock import MockLLMProvider
from app.adapters.llm.agent_runtime import AgentRuntime
from app.agent_engine.queue import AgentQueue
from camel.models import BaseModelBackend
def test_agent_model_backend_adapter_batches_actions(tmp_path):
runtime = AgentRuntime(provider=MockLLMProvider(), run_dir=str(tmp_path))
adapter = AgentModelBackendAdapter("run", str(tmp_path), runtime=runtime)
result = adapter.run_batch_actions(
"round_1",
[
{"agent_id": "a1", "action_id": "x1"},
{"agent_id": "a2", "action_id": "x2"},
],
)
assert result["status"] == "ok"
actions = result["output"]["actions"]
assert {item["action_id"] for item in actions} == {"x1", "x2"}
def test_agent_model_backend_adapter_is_camel_backend_and_returns_tool_call(tmp_path):
runtime = AgentRuntime(provider=MockLLMProvider(), run_dir=str(tmp_path))
adapter = AgentModelBackendAdapter("run", str(tmp_path), runtime=runtime)
assert isinstance(adapter, BaseModelBackend)
response = adapter.run(
[{"role": "user", "content": "Act now"}],
tools=[{"type": "function", "function": {"name": "create_post", "parameters": {"type": "object"}}}],
)
tool_calls = response.choices[0].message.tool_calls
assert tool_calls
assert tool_calls[0].function.name == "create_post"
def test_agent_model_backend_adapter_agent_queue_generates_request_without_keys(tmp_path, monkeypatch):
monkeypatch.delenv("LLM_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ZEP_API_KEY", raising=False)
runtime = AgentRuntime(provider=AgentQueueLLMProvider(run_dir=tmp_path), run_dir=str(tmp_path))
adapter = AgentModelBackendAdapter("run", str(tmp_path), runtime=runtime)
response = adapter.run(
[{"role": "user", "content": "Act in the simulation round"}],
tools=[{"type": "function", "function": {"name": "do_nothing", "parameters": {"type": "object"}}}],
)
assert response.choices[0].message.tool_calls[0].function.name == "do_nothing"
requests = AgentQueue(tmp_path).list_requests()
assert requests
assert requests[0]["type"] == "simulate_agent_action"
assert adapter.last_need_agent_response is not None
assert adapter.last_need_agent_response["status"] == "need_agent_response"
assert adapter.last_need_agent_response["request_id"] == requests[0]["request_id"]
def test_agent_model_backend_adapter_records_batch_need_agent_response(tmp_path):
runtime = AgentRuntime(provider=AgentQueueLLMProvider(run_dir=tmp_path), run_dir=str(tmp_path))
adapter = AgentModelBackendAdapter("run", str(tmp_path), runtime=runtime)
result = adapter.run_batch_actions(
"round_1",
[
{"agent_id": "a1", "action_id": "x1"},
{"agent_id": "a2", "action_id": "x2"},
],
)
requests = AgentQueue(tmp_path).list_requests()
assert result["status"] == "need_agent_response"
assert adapter.last_need_agent_response is not None
assert adapter.last_need_agent_response["request_id"] == result["request_id"]
request = AgentQueue(tmp_path).load_request(result["request_id"])
assert len(request.structured_input["actions"]) == 2
assert {item["action_id"] for item in request.structured_input["actions"]} == {"x1", "x2"}
assert requests[0]["type"] == "simulate_agent_action"

View File

@ -0,0 +1,215 @@
import json
from pathlib import Path
import pytest
from app.adapters.graph.graphiti import (
GraphitiCompatibilityStore,
GraphitiDependencyError,
GraphitiGraphProvider,
)
from app.adapters.graph.base import GraphTriple
def test_graphiti_provider_add_search_export_without_openai_key(tmp_path, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("LLM_API_KEY", raising=False)
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "store.json"))
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
provider = GraphitiGraphProvider()
provider.add_triples(
"run",
[
{
"subject": "美国商务部",
"predicate": "限制",
"object": "先进AI芯片出口",
"fact": "美国商务部限制先进AI芯片出口。",
"valid_at": "2024-01-01",
"invalid_at": None,
"source": "现实种子",
"source_file": "seed.md",
"evidence": "限制先进AI芯片出口",
"confidence": 0.82,
"metadata": {},
}
],
)
results = provider.search("run", "AI芯片", limit=5)
assert results
assert results[0]["fact"] == "美国商务部限制先进AI芯片出口。"
out = tmp_path / "snapshot.json"
provider.export_snapshot("run", str(out))
snapshot = json.loads(out.read_text(encoding="utf-8"))
assert snapshot["run_id"] == "run"
assert len(snapshot["triples"]) == 1
def test_graphiti_missing_dependency_error_is_clear(monkeypatch):
monkeypatch.setattr("importlib.util.find_spec", lambda name: None if name == "graphiti_core" else object())
with pytest.raises(GraphitiDependencyError, match="graphiti_core is not installed"):
GraphitiGraphProvider(require_graphiti_package=True)
def test_graphiti_auto_store_requires_neo4j_driver(monkeypatch):
monkeypatch.delenv("NEO4J_URI", raising=False)
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "auto")
monkeypatch.setattr("importlib.util.find_spec", lambda name: None if name == "neo4j" else object())
with pytest.raises(GraphitiDependencyError, match="neo4j Python package is required"):
GraphitiCompatibilityStore()
def test_graphiti_file_store_explicitly_skips_neo4j_dependency(tmp_path, monkeypatch):
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "store.json"))
monkeypatch.setattr("importlib.util.find_spec", lambda name: None if name == "neo4j" else object())
store = GraphitiCompatibilityStore()
assert store.driver is None
assert (tmp_path / "store.json").exists()
def test_graphiti_compatibility_store_encapsulates_schema_assumptions():
assert hasattr(GraphitiCompatibilityStore, "_add_triplet_neo4j")
assert not hasattr(GraphitiGraphProvider, "_add_triplet_neo4j")
def test_graphiti_snapshot_uses_neo4j_branch_when_driver_present():
store = GraphitiCompatibilityStore.__new__(GraphitiCompatibilityStore)
store.driver = object()
store.list_entities = lambda run_id: [{"name": "A"}]
store._list_facts_neo4j = lambda run_id: [{"subject": "A", "object": "B", "uuid": "internal"}]
store._list_episodes_neo4j = lambda run_id: [{"content": "episode"}]
store._list_memory_neo4j = lambda run_id: {"agent_1": {"belief": "x"}}
snapshot = store.snapshot("run")
assert snapshot["store"] == "neo4j"
assert snapshot["entities"] == [{"name": "A"}]
assert snapshot["triples"][0]["uuid"] == "internal"
assert snapshot["memory"]["agent_1"]["belief"] == "x"
def test_graphiti_neo4j_import_snapshot_cleans_internal_uuid(tmp_path):
snapshot_path = tmp_path / "snapshot.json"
snapshot_path.write_text(
json.dumps(
{
"entities": [{"name": "A", "labels": ["Entity"]}],
"triples": [
{
"uuid": "internal",
"subject": "A",
"predicate": "affects",
"object": "B",
"fact": "A affects B.",
"valid_at": None,
"invalid_at": None,
"source": "seed",
"source_file": "seed.md",
"evidence": "A affects B.",
"confidence": 0.9,
"metadata": {},
}
],
"episodes": [{"content": "episode", "metadata": {"source": "seed"}}],
"memory": {"agent_1": {"belief": "x"}},
}
),
encoding="utf-8",
)
imported = {}
store = GraphitiCompatibilityStore.__new__(GraphitiCompatibilityStore)
store.driver = object()
store.clear_run_graph = lambda run_id: imported.setdefault("cleared", run_id)
store.get_or_create_entity_node = lambda run_id, name, labels=None: imported.setdefault("entity", (run_id, name, labels))
store.add_episode = lambda run_id, content, metadata=None: imported.setdefault("episode", (run_id, content, metadata))
store.write_agent_memory = lambda run_id, agent_id, memory: imported.setdefault("memory", (run_id, agent_id, memory))
def add_triples(run_id, triples):
imported["triples"] = triples
return {"triples_added": len(triples)}
store.add_triples = add_triples
result = store.import_snapshot("run", str(snapshot_path))
assert result["imported"] is True
assert isinstance(imported["triples"][0], GraphTriple)
assert imported["triples"][0].subject == "A"
def test_graphiti_neo4j_neighbors_respects_depth():
captured = {}
class FakeSession:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def run(self, query, **params):
captured["query"] = query
captured["params"] = params
return []
class FakeDriver:
def session(self, database=None):
captured["database"] = database
return FakeSession()
store = GraphitiCompatibilityStore.__new__(GraphitiCompatibilityStore)
store.driver = FakeDriver()
store.neo4j_database = "neo4j"
assert store.neighbors("run", "A", depth=4) == []
assert "[*1..4]" in captured["query"]
assert captured["params"] == {"run_id": "run", "normalized": "a"}
def test_graphiti_neo4j_search_does_not_shadow_driver_query_argument():
captured = {}
class FakeSession:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def run(self, cypher, **params):
captured["cypher"] = cypher
captured["params"] = params
return []
class FakeDriver:
def session(self, database=None):
return FakeSession()
store = GraphitiCompatibilityStore.__new__(GraphitiCompatibilityStore)
store.driver = FakeDriver()
store.neo4j_database = "neo4j"
assert store.search_facts("run", "AI芯片", limit=5) == []
assert "$search_query" in captured["cypher"]
assert captured["params"] == {"run_id": "run", "search_query": "AI芯片", "limit": 5}
def test_graphiti_neo4j_values_are_jsonable():
class FakeDateTime:
def iso_format(self):
return "2026-06-06T00:00:00Z"
store = GraphitiCompatibilityStore.__new__(GraphitiCompatibilityStore)
assert store._to_jsonable({"created_at": FakeDateTime(), "items": [FakeDateTime()]}) == {
"created_at": "2026-06-06T00:00:00Z",
"items": ["2026-06-06T00:00:00Z"],
}

View File

@ -0,0 +1,531 @@
import json
from pathlib import Path
from app.adapters.graph.factory import create_graph_provider
from app.agent_engine.cli import build_parser
from app.agent_engine.queue import AgentQueue
from app.agent_engine.runner import PredictionRunService
def _write_response(run_dir: Path, payload: dict) -> None:
requests = AgentQueue(run_dir).list_requests()
request_id = requests[-1]["request_id"]
response_path = run_dir / "responses" / f"{request_id}.json"
response_path.write_text(
json.dumps({"request_id": request_id, "status": "ok", "output": payload}, ensure_ascii=False),
encoding="utf-8",
)
def test_full_agent_queue_runner_flow(tmp_path, monkeypatch):
monkeypatch.setenv("MIROFISH_MODE", "agent")
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
monkeypatch.delenv("LLM_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ZEP_API_KEY", raising=False)
seed = tmp_path / "seed.md"
seed.write_text("美国商务部限制先进AI芯片出口。", encoding="utf-8")
run_dir = tmp_path / "chip-2036"
service = PredictionRunService()
created = service.create_run(str(seed), "预测未来10年全球芯片能力格局变化", str(run_dir))
assert created["status"] == "created"
need = service.run(str(run_dir))
assert need["status"] == "need_agent_response"
_write_response(run_dir, {"ontology": {"entity_types": [], "edge_types": []}})
need = service.resume(str(run_dir))
assert need["type"] == "extract_triples"
_write_response(
run_dir,
{
"triples": [
{
"subject": "美国商务部",
"predicate": "限制",
"object": "先进AI芯片出口",
"fact": "美国商务部限制先进AI芯片出口。",
"valid_at": "2024-01-01",
"invalid_at": None,
"source": "现实种子",
"source_file": "seed.md",
"evidence": "美国商务部限制先进AI芯片出口。",
"confidence": 0.82,
"metadata": {},
}
]
},
)
need = service.resume(str(run_dir))
assert need["type"] == "generate_oasis_profiles"
_write_response(run_dir, {"profiles": [{"agent_id": "agent_1", "name": "Analyst", "persona": "Analyst"}]})
need = service.resume(str(run_dir))
assert need["type"] == "generate_simulation_config"
_write_response(run_dir, {"config": {"rounds": 1}})
need = service.resume(str(run_dir))
assert need["type"] == "simulate_agent_action"
request = AgentQueue(run_dir).load_request(need["request_id"])
assert len(request.structured_input["actions"]) == 1
_write_response(
run_dir,
{"actions": [{"agent_id": "agent_1", "action_id": "round_1_action_1", "action_type": "CREATE_POST", "content": "Chip export controls may shift supply chains."}]},
)
need = service.resume(str(run_dir))
assert need["type"] == "generate_report"
_write_response(
run_dir,
{
"report_markdown": "# Report\n\nChip capacity may bifurcate.",
"verdict": {"status": "ok", "confidence": 0.7},
"timeline": [{"valid_at": "2024-01-01", "fact": "export controls"}],
},
)
result = service.resume(str(run_dir))
assert result["status"] == "completed"
for artifact in ["report.md", "verdict.json", "timeline.json", "graph_snapshot.json"]:
assert (run_dir / "artifacts" / artifact).exists()
def test_graph_build_provider_override_survives_agent_wait(tmp_path, monkeypatch):
monkeypatch.setenv("MIROFISH_MODE", "agent")
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "zep")
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
monkeypatch.delenv("ZEP_API_KEY", raising=False)
seed = tmp_path / "seed.md"
seed.write_text("A affects B.", encoding="utf-8")
run_dir = tmp_path / "override-run"
service = PredictionRunService()
service.create_run(str(seed), "test graph override", str(run_dir))
need = service.build_graph(str(run_dir), provider="graphiti")
assert need["status"] == "need_agent_response"
_write_response(
run_dir,
{
"triples": [
{
"subject": "A",
"predicate": "affects",
"object": "B",
"fact": "A affects B.",
"valid_at": None,
"invalid_at": None,
"source": "seed",
"source_file": "seed.md",
"evidence": "A affects B.",
"confidence": 0.9,
"metadata": {},
}
]
},
)
next_step = service.resume(str(run_dir))
assert next_step["status"] == "need_agent_response"
assert next_step["type"] == "generate_oasis_profiles"
assert (run_dir / "artifacts" / "graph_snapshot.json").exists()
def test_explicit_stage_commands_reuse_existing_waiting_request(tmp_path, monkeypatch):
monkeypatch.setenv("MIROFISH_MODE", "agent")
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
service = PredictionRunService()
for command_name, command in [
("graph", lambda run: service.build_graph(str(run), provider="graphiti")),
("simulation", lambda run: service.start_simulation(str(run))),
("report", lambda run: service.generate_report(str(run))),
]:
seed = tmp_path / f"{command_name}.md"
seed.write_text("A affects B.", encoding="utf-8")
run_dir = tmp_path / f"{command_name}-run"
service.create_run(str(seed), f"test {command_name}", str(run_dir))
first = command(run_dir)
second = command(run_dir)
requests = AgentQueue(run_dir).list_requests()
assert first["status"] == "need_agent_response"
assert second["status"] == "need_agent_response"
assert second["request_id"] == first["request_id"]
assert len(requests) == 1
def test_resume_creates_repair_request_for_invalid_stage_response(tmp_path, monkeypatch):
monkeypatch.setenv("MIROFISH_MODE", "agent")
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
seed = tmp_path / "seed.md"
seed.write_text("A affects B.", encoding="utf-8")
run_dir = tmp_path / "repair-run"
service = PredictionRunService()
service.create_run(str(seed), "test repair", str(run_dir))
need = service.run(str(run_dir))
_write_response(run_dir, {"ontology": {"entity_types": [], "edge_types": []}})
need = service.resume(str(run_dir))
assert need["type"] == "extract_triples"
bad_response_path = run_dir / "responses" / f"{need['request_id']}.json"
bad_response_path.write_text(
json.dumps(
{
"request_id": need["request_id"],
"status": "ok",
"output": {
"triples": [
{
"subject": "A",
"predicate": "affects",
"object": "B",
"fact": "A affects B.",
"valid_at": None,
"invalid_at": None,
"source": "seed",
"source_file": "seed.md",
"evidence": "A affects B.",
"confidence": 2.0,
"metadata": {},
}
]
},
}
),
encoding="utf-8",
)
repair = service.resume(str(run_dir))
assert repair["status"] == "need_agent_response"
assert repair["type"] == "repair_invalid_json"
repair_request = AgentQueue(run_dir).load_request(repair["request_id"])
assert repair_request.structured_input["validation_errors"]
_write_response(
run_dir,
{
"triples": [
{
"subject": "A",
"predicate": "affects",
"object": "B",
"fact": "A affects B.",
"valid_at": None,
"invalid_at": None,
"source": "seed",
"source_file": "seed.md",
"evidence": "A affects B.",
"confidence": 0.9,
"metadata": {},
}
]
},
)
next_step = service.resume(str(run_dir))
assert next_step["status"] == "need_agent_response"
assert next_step["type"] == "generate_oasis_profiles"
def test_response_submit_repair_request_is_attached_to_waiting_stage(tmp_path, monkeypatch):
monkeypatch.setenv("MIROFISH_MODE", "agent")
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
seed = tmp_path / "seed.md"
seed.write_text("A affects B.", encoding="utf-8")
run_dir = tmp_path / "submit-repair-run"
service = PredictionRunService()
service.create_run(str(seed), "test submit repair", str(run_dir))
need = service.run(str(run_dir))
_write_response(run_dir, {"ontology": {"entity_types": [], "edge_types": []}})
need = service.resume(str(run_dir))
assert need["type"] == "extract_triples"
bad_response_path = run_dir / "responses" / f"{need['request_id']}.json"
bad_response_path.write_text(
json.dumps(
{
"request_id": need["request_id"],
"status": "ok",
"output": {
"triples": [
{
"subject": "A",
"predicate": "affects",
"object": "B",
"fact": "A affects B.",
"valid_at": None,
"invalid_at": None,
"source": "seed",
"source_file": "seed.md",
"evidence": "A affects B.",
"confidence": 2.0,
"metadata": {},
}
]
},
}
),
encoding="utf-8",
)
submitted = service.submit_response(str(run_dir), str(bad_response_path))
repair_id = submitted["repair_request"]["request_id"]
resumed = service.resume(str(run_dir))
assert submitted["ok"] is False
assert resumed["request_id"] == repair_id
assert len(AgentQueue(run_dir).list_requests()) == 3
def test_followup_question_uses_agent_queue_and_graph_context(tmp_path, monkeypatch):
monkeypatch.setenv("MIROFISH_MODE", "agent")
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
monkeypatch.delenv("LLM_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ZEP_API_KEY", raising=False)
seed = tmp_path / "seed.md"
seed.write_text("美国商务部限制先进AI芯片出口。", encoding="utf-8")
run_dir = tmp_path / "followup-run"
service = PredictionRunService()
created = service.create_run(str(seed), "预测未来10年全球芯片能力格局变化", str(run_dir))
create_graph_provider("graphiti").add_triples(
created["run_id"],
[
{
"subject": "美国商务部",
"predicate": "限制",
"object": "先进AI芯片出口",
"fact": "美国商务部限制先进AI芯片出口。",
"valid_at": "2024-01-01",
"invalid_at": None,
"source": "现实种子",
"source_file": "seed.md",
"evidence": "美国商务部限制先进AI芯片出口。",
"confidence": 0.82,
"metadata": {},
}
],
)
need = service.ask_followup_question(str(run_dir), "先进AI芯片出口限制有什么影响?", limit=5)
assert need["status"] == "need_agent_response"
assert need["type"] == "answer_followup_question"
request = AgentQueue(run_dir).load_request(need["request_id"])
assert request.structured_input["question"] == "先进AI芯片出口限制有什么影响?"
assert request.structured_input["graph_results"]
response_path = run_dir / "responses" / f"{need['request_id']}.json"
response_path.write_text(
json.dumps(
{
"request_id": need["request_id"],
"status": "ok",
"output": {
"answer_markdown": "出口限制可能推动供应链分化。",
"used_graph_results": request.structured_input["graph_results"],
"confidence": 0.8,
},
},
ensure_ascii=False,
),
encoding="utf-8",
)
answer = service.get_followup_answer(str(run_dir), need["request_id"])
assert answer["status"] == "ok"
assert (run_dir / "artifacts" / "followups" / f"{need['request_id']}.md").exists()
assert (run_dir / "artifacts" / "followups" / f"{need['request_id']}.json").exists()
def test_create_run_persists_hard_round_settings(tmp_path, monkeypatch):
monkeypatch.setenv("MIROFISH_MODE", "agent")
seed = tmp_path / "seed.md"
seed.write_text("A affects B.", encoding="utf-8")
run_dir = tmp_path / "settings-run"
created = PredictionRunService().create_run(
str(seed),
"predict hard settings",
str(run_dir),
mode="staged",
rounds=12,
round_unit="year",
pause_each_round=True,
agent_count=3,
simulation_name="hard-settings",
)
settings = created["state"]["simulation_settings"]
assert settings["rounds"] == 12
assert settings["max_rounds"] == 12
assert settings["simulation_rounds"] == 12
assert settings["round_unit"] == "year"
assert settings["minutes_per_round"] == 525600
assert settings["pause_each_round"] is True
assert settings["agent_count"] == 3
assert created["state"]["workflow_mode"] == "staged"
assert created["state"]["stages"]["seed_input"]["status"] == "awaiting_user_confirmation"
def test_rounds_below_minimum_fails_without_debug_mode(tmp_path, monkeypatch):
monkeypatch.delenv("MIROFISH_ALLOW_DEBUG_ROUNDS", raising=False)
seed = tmp_path / "seed.md"
seed.write_text("A affects B.", encoding="utf-8")
try:
PredictionRunService().create_run(str(seed), "too short", str(tmp_path / "bad"), rounds=3)
except ValueError as exc:
assert "rounds must be at least 10" in str(exc)
else:
raise AssertionError("rounds below 10 should fail")
monkeypatch.setenv("MIROFISH_ALLOW_DEBUG_ROUNDS", "true")
created = PredictionRunService().create_run(str(seed), "debug short", str(tmp_path / "debug"), rounds=3)
assert created["state"]["simulation_settings"]["rounds"] == 3
def test_staged_mode_pauses_and_approve_advances(tmp_path, monkeypatch):
monkeypatch.setenv("MIROFISH_MODE", "agent")
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
seed = tmp_path / "seed.md"
seed.write_text("A affects B.", encoding="utf-8")
run_dir = tmp_path / "staged-run"
service = PredictionRunService()
created = service.create_run(str(seed), "predict staged", str(run_dir), mode="staged", rounds=10)
assert created["state"]["current_stage"] == "seed_input"
assert created["state"]["stages"]["seed_input"]["status"] == "awaiting_user_confirmation"
approved = service.approve_stage(str(run_dir))
assert approved["next_stage"] == "prediction_requirement"
status = service.status(str(run_dir))["state"]
assert status["current_stage"] == "prediction_requirement"
assert status["stages"]["prediction_requirement"]["status"] == "pending"
paused = service.resume(str(run_dir))
assert paused["status"] == "awaiting_user_confirmation"
assert paused["stage"] == "prediction_requirement"
def test_update_settings_stales_downstream_and_config_uses_hard_rounds(tmp_path, monkeypatch):
monkeypatch.setenv("MIROFISH_MODE", "agent")
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
seed = tmp_path / "seed.md"
seed.write_text("A affects B.", encoding="utf-8")
run_dir = tmp_path / "staged-config-run"
service = PredictionRunService()
service.create_run(str(seed), "predict staged config", str(run_dir), mode="staged", rounds=10)
service.approve_stage(str(run_dir))
service.resume(str(run_dir))
service.approve_stage(str(run_dir))
service.resume(str(run_dir))
updated = service.update_simulation_settings(str(run_dir), rounds=12, round_unit="month")
assert updated["simulation_settings"]["rounds"] == 12
assert updated["state"]["stages"]["profile_and_config"]["stale"] is True
service.approve_stage(str(run_dir))
need = service.resume(str(run_dir))
assert need["type"] == "generate_ontology"
_write_response(run_dir, {"ontology": {"entity_types": [], "edge_types": []}})
need = service.resume(str(run_dir))
assert need["type"] == "extract_triples"
_write_response(
run_dir,
{
"triples": [
{
"subject": "A",
"predicate": "affects",
"object": "B",
"fact": "A affects B.",
"valid_at": None,
"invalid_at": None,
"source": "seed",
"source_file": "seed.md",
"evidence": "A affects B.",
"confidence": 0.9,
"metadata": {},
}
]
},
)
paused = service.resume(str(run_dir))
assert paused["status"] == "awaiting_user_confirmation"
assert paused["stage"] == "graph_build"
service.approve_stage(str(run_dir))
need = service.resume(str(run_dir))
assert need["type"] == "generate_oasis_profiles"
_write_response(run_dir, {"profiles": [{"agent_id": "agent_1", "name": "Agent"}]})
need = service.resume(str(run_dir))
assert need["type"] == "generate_simulation_config"
_write_response(run_dir, {"config": {"rounds": 1, "simulation_rounds": 1}})
paused = service.resume(str(run_dir))
assert paused["status"] == "awaiting_user_confirmation"
config = json.loads((run_dir / "artifacts" / "simulation_config.json").read_text(encoding="utf-8"))
assert config["rounds"] == 12
assert config["simulation_rounds"] == 12
assert config["round_unit"] == "month"
def test_cli_parser_exposes_rounds_and_stage_commands():
parser = build_parser()
args = parser.parse_args(
[
"create-run",
"--seed",
"seed.md",
"--requirement",
"predict",
"--output",
"runs/demo",
"--mode",
"staged",
"--rounds",
"10",
"--round-unit",
"year",
]
)
assert args.command == "create-run"
assert args.rounds == 10
assert args.mode == "staged"
stage_args = parser.parse_args(["stage", "update-settings", "--run", "runs/demo", "--rounds", "12"])
assert stage_args.stage_command == "update-settings"
assert stage_args.rounds == 12

File diff suppressed because it is too large Load Diff

42
docker-compose.agent.yml Normal file
View File

@ -0,0 +1,42 @@
services:
neo4j:
image: neo4j:5.26-community
container_name: mirofish-neo4j
restart: unless-stopped
environment:
NEO4J_AUTH: ${NEO4J_USER:-neo4j}/${NEO4J_PASSWORD:-password}
NEO4J_dbms_default__database: ${NEO4J_DATABASE:-neo4j}
NEO4J_server_memory_heap_initial__size: 512m
NEO4J_server_memory_heap_max__size: 1G
NEO4J_server_memory_pagecache_size: 512m
ports:
- "7474:7474"
- "7687:7687"
volumes:
- neo4j_data:/data
- neo4j_logs:/logs
healthcheck:
test:
[
"CMD-SHELL",
"cypher-shell -u ${NEO4J_USER:-neo4j} -p ${NEO4J_PASSWORD:-password} 'RETURN 1' >/dev/null 2>&1",
]
interval: 10s
timeout: 10s
retries: 12
ollama:
image: ollama/ollama:latest
container_name: mirofish-ollama
profiles:
- ollama
restart: unless-stopped
ports:
- "11434:11434"
volumes:
- ollama_data:/root/.ollama
volumes:
neo4j_data:
neo4j_logs:
ollama_data:

View File

@ -0,0 +1,42 @@
# Claude Code Usage
Open the repository:
```bash
cd /Users/leaf/Documents/future/MiroFish
```
Run the CLI from the backend:
```bash
cd backend
uv run mirofish-agent init --seed /path/to/seed.md --requirement "预测未来10年全球芯片能力格局变化" --output ../runs/chip-2036 --json
uv run mirofish-agent run --run ../runs/chip-2036 --json
```
For each `need_agent_response`, inspect the request:
```bash
uv run mirofish-agent requests show --run ../runs/chip-2036 --request-id req_000001 --json
```
Write the response file exactly matching `expected_schema`, validate it, then resume:
```bash
uv run mirofish-agent responses validate --run ../runs/chip-2036 --response ../runs/chip-2036/responses/req_000001.json --json
uv run mirofish-agent resume --run ../runs/chip-2036 --json
```
Claude Code MCP config:
```json
{
"mcpServers": {
"mirofish": {
"command": "uv",
"args": ["run", "mirofish-mcp"],
"cwd": "/Users/leaf/Documents/future/MiroFish/backend"
}
}
}
```

64
docs/agent-usage/codex.md Normal file
View File

@ -0,0 +1,64 @@
# Codex Desktop Usage
Open `/Users/leaf/Documents/future/MiroFish` in Codex Desktop.
Initialize a run:
```bash
cd /Users/leaf/Documents/future/MiroFish/backend
uv run mirofish-agent init --seed /path/to/seed.md --requirement "预测未来10年全球芯片能力格局变化" --output ../runs/chip-2036 --json
uv run mirofish-agent run --run ../runs/chip-2036 --json
```
When the CLI returns `need_agent_response`, read `request_file`, generate the response JSON, and write it to `expected_response_file`.
Codex triple extraction prompt:
```text
读取 request_file严格按照 expected_schema 生成 response_file。只输出 JSON不要添加解释。每个 triple 必须包含 subject、predicate、object、fact、evidence、confidence。不要编造现实种子中没有的事实。无法确认的关系不要写入或将 confidence 降低。
```
Validate and continue:
```bash
uv run mirofish-agent responses validate --run ../runs/chip-2036 --response ../runs/chip-2036/responses/req_000001.json --json
uv run mirofish-agent resume --run ../runs/chip-2036 --json
```
Repeat until status is `completed`. Final artifacts are in:
```text
runs/chip-2036/artifacts/report.md
runs/chip-2036/artifacts/verdict.json
runs/chip-2036/artifacts/timeline.json
runs/chip-2036/artifacts/graph_snapshot.json
```
Ask a follow-up question after a completed run:
```bash
uv run mirofish-agent followup ask --run ../runs/chip-2036 --question "先进AI芯片出口限制有什么影响?" --json
uv run mirofish-agent requests show --run ../runs/chip-2036 --request-id req_000007 --json
uv run mirofish-agent followup show --run ../runs/chip-2036 --request-id req_000007 --json
```
Follow-up answers are written under `runs/chip-2036/artifacts/followups/`.
MCP config example:
```json
{
"mcpServers": {
"mirofish": {
"command": "uv",
"args": ["run", "mirofish-mcp"],
"cwd": "/Users/leaf/Documents/future/MiroFish/backend",
"env": {
"MIROFISH_MODE": "agent",
"MIROFISH_LLM_PROVIDER": "agent_queue",
"MIROFISH_GRAPH_PROVIDER": "graphiti"
}
}
}
}
```

View File

@ -0,0 +1,32 @@
# Cursor Usage
Open `/Users/leaf/Documents/future/MiroFish` as the workspace.
CLI flow:
```bash
cd backend
uv run mirofish-agent init --seed /path/to/seed.md --requirement "预测未来10年全球芯片能力格局变化" --output ../runs/chip-2036 --json
uv run mirofish-agent run --run ../runs/chip-2036 --json
```
Cursor can handle request files by reading `runs/<run_id>/requests/req_*.json` and writing strict responses to `runs/<run_id>/responses/req_*.json`.
MCP config example:
```json
{
"mcpServers": {
"mirofish": {
"command": "uv",
"args": ["run", "mirofish-mcp"],
"cwd": "/Users/leaf/Documents/future/MiroFish/backend",
"env": {
"MIROFISH_MODE": "agent",
"MIROFISH_LLM_PROVIDER": "agent_queue",
"MIROFISH_GRAPH_PROVIDER": "graphiti"
}
}
}
}
```

View File

@ -0,0 +1,189 @@
# Graphiti + Neo4j Setup
Agent mode defaults to:
```bash
MIROFISH_MODE=agent
MIROFISH_LLM_PROVIDER=agent_queue
MIROFISH_GRAPH_PROVIDER=graphiti
```
MiroFish does not vendor Graphiti source code. Install Graphiti through the backend Python dependency group:
```bash
cd /Users/leaf/Documents/future/MiroFish/backend
uv sync --extra agent --group dev
```
You can also run the helper:
```bash
cd /Users/leaf/Documents/future/MiroFish
bash scripts/setup_agent_deps.sh --neo4j desktop
```
The helper loads `/Users/leaf/Documents/future/MiroFish/.env` automatically when it exists.
Docker is optional. `doctor` does not fail just because Docker or Docker Compose is unavailable.
## Required Environment
```bash
export NEO4J_URI=bolt://localhost:7687
export NEO4J_USER=neo4j
export NEO4J_PASSWORD=password
export NEO4J_DATABASE=neo4j
```
Neo4j must be reachable and must be version `5.26` or newer.
Graphiti stores and searches graph facts. It does not extract complex triples from raw seed text in MiroFish agent mode. MiroFish writes `extract_triples` requests, a desktop agent writes validated responses, then `GraphitiGraphProvider` stores triples using `run_id` as the namespace.
## Option 1: Neo4j Desktop
Recommended when you do not want Docker and prefer a GUI-managed local database:
1. Install Neo4j Desktop.
2. Create a local DBMS using Neo4j `5.26` or newer.
3. Set the password to match `NEO4J_PASSWORD`.
4. Start the database.
5. Export the connection values:
```bash
export NEO4J_URI=bolt://localhost:7687
export NEO4J_USER=neo4j
export NEO4J_PASSWORD=your-password
export NEO4J_DATABASE=neo4j
```
Then run:
```bash
cd /Users/leaf/Documents/future/MiroFish/backend
uv run mirofish-agent doctor --json
```
The `docker` and `docker_compose` doctor checks may show warnings, but they are optional and do not fail doctor.
## Option 2: Homebrew / Native Install
Recommended when you do not want Docker and prefer a local service managed by macOS. Install and start Neo4j locally:
```bash
brew install neo4j
brew services start neo4j
```
Set the same environment variables:
```bash
export NEO4J_URI=bolt://localhost:7687
export NEO4J_USER=neo4j
export NEO4J_PASSWORD=your-password
export NEO4J_DATABASE=neo4j
```
If your native installation uses another port, update `NEO4J_URI`.
Run:
```bash
cd /Users/leaf/Documents/future/MiroFish
bash scripts/setup_agent_deps.sh --neo4j native
cd backend
uv run mirofish-agent doctor --json
```
## Option 3: Existing Neo4j Instance
Point MiroFish at any reachable Neo4j `5.26+` instance:
```bash
export NEO4J_URI=bolt://your-host:7687
export NEO4J_USER=neo4j
export NEO4J_PASSWORD=your-password
export NEO4J_DATABASE=neo4j
```
Then run:
```bash
bash scripts/setup_agent_deps.sh --neo4j existing
```
## Option 4: Docker Compose
Docker Compose remains available for users who prefer it:
```bash
cd /Users/leaf/Documents/future/MiroFish
docker compose -f docker-compose.agent.yml up -d neo4j
```
Open the browser console at `http://localhost:7474` and log in with:
```text
user: neo4j
password: password
```
Run:
```bash
cd /Users/leaf/Documents/future/MiroFish
bash scripts/setup_agent_deps.sh --neo4j docker --start-docker
```
If Docker is not installed, the setup script prints `Docker optional, skipped`; it does not fail for that reason. The required check is still whether Neo4j is reachable through `NEO4J_URI`.
## Ollama Embedding
The no-LLM triplet write path and `fulltext` graph search do not require Ollama. Doctor only hard-fails Ollama checks when both conditions are true:
```bash
export MIROFISH_GRAPH_SEARCH_MODE=semantic # or hybrid
export MIROFISH_EMBEDDING_PROVIDER=ollama
```
If `MIROFISH_GRAPH_SEARCH_MODE=fulltext` or `MIROFISH_EMBEDDING_PROVIDER=none`, missing Ollama is reported as an optional warning only. Semantic retrieval may be unavailable, but the agent engine and Graphiti/Neo4j fulltext path can still run.
If you opt into Ollama embeddings, install and start Ollama locally, then pull the embedding model:
```bash
ollama serve
ollama pull nomic-embed-text
```
Configure:
```bash
export MIROFISH_EMBEDDING_PROVIDER=ollama
export MIROFISH_GRAPH_SEARCH_MODE=semantic
export OLLAMA_BASE_URL=http://localhost:11434
export OLLAMA_EMBEDDING_MODEL=nomic-embed-text
```
Doctor checks `GET $OLLAMA_BASE_URL/api/tags` and verifies that `OLLAMA_EMBEDDING_MODEL` is installed.
## Offline Compatibility Store
Offline tests can use a file-backed no-LLM triplet store:
```bash
export MIROFISH_GRAPHITI_STORE=file
export MIROFISH_GRAPHITI_COMPAT_PATH=/tmp/mirofish-graphiti-store.json
```
This is for smoke tests and local development without Neo4j. Production agent mode should use Neo4j. The default `MIROFISH_GRAPHITI_STORE=auto` path uses Neo4j; it does not silently downgrade to file storage.
## Compatibility Layer
`GraphitiCompatibilityStore` provides the no-LLM triplet write path. If it writes directly to Neo4j, all Cypher and Graphiti schema assumptions stay inside that class. Business code must use `GraphProvider` and must not depend on Graphiti node or edge internals.
References:
- Graphiti episodes: https://help.getzep.com/graphiti/core-concepts/adding-episodes
- Graphiti fact triples: https://help.getzep.com/graphiti/working-with-data/adding-fact-triples
- Graphiti namespacing: https://help.getzep.com/graphiti/core-concepts/graph-namespacing
- Graphiti Neo4j config: https://help.getzep.com/graphiti/configuration/neo-4-j-configuration
- Graphiti LLM config: https://help.getzep.com/graphiti/configuration/llm-configuration

93
docs/agent-usage/mcp.md Normal file
View File

@ -0,0 +1,93 @@
# MiroFish MCP Server
The MCP server exposes MiroFish lifecycle tools, not a Graphiti proxy.
Start:
```bash
cd /Users/leaf/Documents/future/MiroFish/backend
uv run mirofish-mcp
```
Tools:
- `mirofish_create_run`
- `mirofish_run`
- `mirofish_resume_run`
- `mirofish_get_status`
- `mirofish_get_current_stage`
- `mirofish_update_simulation_settings`
- `mirofish_approve_stage`
- `mirofish_reject_stage`
- `mirofish_rerun_stage`
- `mirofish_list_requests`
- `mirofish_get_request`
- `mirofish_submit_response`
- `mirofish_validate_response`
- `mirofish_build_graph`
- `mirofish_search_graph`
- `mirofish_export_graph`
- `mirofish_start_simulation`
- `mirofish_resume_simulation`
- `mirofish_generate_report`
- `mirofish_get_report`
- `mirofish_ask_followup_question`
- `mirofish_get_followup_answer`
- `mirofish_list_artifacts`
- `mirofish_doctor`
## Staged Mode
Use staged mode when a desktop agent should mirror the original MiroFish step-by-step UI flow. The simulation round count is a hard MCP field, not text hidden in the requirement.
Typical Qoder/Codex/Claude Code sequence:
1. Call `mirofish_doctor`.
2. Call `mirofish_create_run` with `mode="staged"`, `rounds=10`, `round_unit="year"`, and the seed/requirement/output path.
3. Call `mirofish_get_current_stage` and show the user the stage summary.
4. After user confirmation, call `mirofish_approve_stage`.
5. Call `mirofish_resume_run`; staged mode advances only to the next pause point or `need_agent_response`.
6. When `need_agent_response` appears, read `request_file`, write the response JSON, call `mirofish_validate_response`, then `mirofish_submit_response`.
7. Repeat resume/approve until `report.md`, `verdict.json`, `timeline.json`, and `graph_snapshot.json` exist.
8. Use `mirofish_ask_followup_question` for post-report questions.
Example `mirofish_create_run` arguments:
```json
{
"seed": "/Users/leaf/Documents/future/MiroFish/seeds/chip.md",
"requirement": "预测未来10年全球芯片能力格局变化",
"output": "/Users/leaf/Documents/future/MiroFish/runs/chip-2036",
"mode": "staged",
"rounds": 10,
"round_unit": "year",
"minutes_per_round": 525600,
"pause_each_round": false,
"agent_count": 5,
"simulation_name": "chip-2036"
}
```
If the user changes hard parameters before approval, call `mirofish_update_simulation_settings`. The engine marks dependent stages stale/pending so old profile/config/simulation/report outputs are not silently reused.
Example MCP server config:
```json
{
"mcpServers": {
"mirofish": {
"command": "uv",
"args": ["run", "mirofish-mcp"],
"cwd": "/Users/leaf/Documents/future/MiroFish/backend",
"env": {
"MIROFISH_MODE": "agent",
"MIROFISH_LLM_PROVIDER": "agent_queue",
"MIROFISH_GRAPH_PROVIDER": "graphiti",
"MIROFISH_RUNS_DIR": "./runs"
}
}
}
}
```
Reference: https://modelcontextprotocol.github.io/python-sdk/server/

View File

@ -0,0 +1,48 @@
# opencode Usage
Open the repository:
```bash
cd /Users/leaf/Documents/future/MiroFish
```
Run MiroFish through the CLI from the backend:
```bash
cd backend
uv run mirofish-agent init --seed /path/to/seed.md --requirement "预测未来10年全球芯片能力格局变化" --output ../runs/chip-2036 --json
uv run mirofish-agent run --run ../runs/chip-2036 --json
```
When a command returns `need_agent_response`, read `request_file`, produce JSON that exactly matches `expected_schema`, write it to `expected_response_file`, validate it, and resume:
```bash
uv run mirofish-agent responses validate --run ../runs/chip-2036 --response ../runs/chip-2036/responses/req_000001.json --json
uv run mirofish-agent resume --run ../runs/chip-2036 --json
```
MCP server config shape:
```json
{
"mcpServers": {
"mirofish": {
"command": "uv",
"args": ["run", "mirofish-mcp"],
"cwd": "/Users/leaf/Documents/future/MiroFish/backend",
"env": {
"MIROFISH_MODE": "agent",
"MIROFISH_LLM_PROVIDER": "agent_queue",
"MIROFISH_GRAPH_PROVIDER": "graphiti"
}
}
}
}
```
Follow-up questions use the same queue:
```bash
uv run mirofish-agent followup ask --run ../runs/chip-2036 --question "这个预测里最大的风险是什么?" --json
uv run mirofish-agent followup show --run ../runs/chip-2036 --request-id req_000007 --json
```

View File

@ -0,0 +1,36 @@
# QoderWork Staged Usage
QoderWork should use the MiroFish MCP server as a staged business workflow, not as a one-shot prompt wrapper.
## Run Sequence
1. Call `mirofish_doctor`.
2. Call `mirofish_create_run` with hard simulation settings:
```json
{
"seed": "/absolute/path/seed.md",
"requirement": "预测未来10年全球芯片能力格局变化",
"output": "/Users/leaf/Documents/future/MiroFish/runs/chip-2036",
"mode": "staged",
"rounds": 10,
"round_unit": "year",
"minutes_per_round": 525600,
"pause_each_round": false,
"agent_count": 5,
"simulation_name": "chip-2036"
}
```
3. Call `mirofish_get_current_stage` and present the summary to the user.
4. After the user confirms, call `mirofish_approve_stage`.
5. Call `mirofish_resume_run`.
6. If the result is `need_agent_response`, read `request_file`, generate the response JSON exactly against `expected_schema`, then call `mirofish_validate_response` and `mirofish_submit_response`.
7. If the result is `awaiting_user_confirmation`, show the stage summary and ask whether to approve, reject, update settings, or rerun.
8. Continue until the run returns `completed`.
9. Read artifacts with `mirofish_get_report` and `mirofish_list_artifacts`.
10. Ask follow-up questions with `mirofish_ask_followup_question`.
## Important Rule
Do not put the round count only in the natural-language requirement. Always pass `rounds` and `round_unit` through MCP fields so the simulation config, timeline, verdict, and report record the actual hard settings.

View File

@ -0,0 +1,36 @@
# Triple Extraction Response Format
Responses must be strict JSON with no extra top-level fields:
```json
{
"request_id": "req_000001",
"status": "ok",
"output": {
"triples": [
{
"subject": "美国商务部",
"predicate": "限制",
"object": "先进AI芯片出口",
"fact": "美国商务部限制先进AI芯片出口。",
"valid_at": "2024-01-01",
"invalid_at": null,
"source": "现实种子",
"source_file": "seed.md",
"evidence": "原文证据片段",
"confidence": 0.82,
"metadata": {}
}
]
}
}
```
Rules:
- `request_id` must exactly match the request file.
- `status` must be `ok`, `error`, or `skipped`.
- `output` must match `expected_schema`.
- `confidence` must be between `0.0` and `1.0`.
- Do not add extra fields.
- Do not invent facts not present in the seed or referenced context.

View File

@ -1,6 +1,22 @@
import axios from 'axios'
import i18n from '../i18n'
const getResponseErrorMessage = (error) => {
const data = error?.response?.data
if (data?.error) return data.error
if (data?.message) return data.message
if (typeof data === 'string') return data
return error?.message || 'Error'
}
const normalizeApiError = (error) => {
const normalized = new Error(getResponseErrorMessage(error))
normalized.status = error?.response?.status
normalized.data = error?.response?.data
normalized.originalError = error
return normalized
}
// 创建axios实例
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:5001',
@ -48,7 +64,7 @@ service.interceptors.response.use(
console.error('Network error - please check your connection')
}
return Promise.reject(error)
return Promise.reject(normalizeApiError(error))
}
)
@ -58,6 +74,10 @@ export const requestWithRetry = async (requestFn, maxRetries = 3, delay = 1000)
try {
return await requestFn()
} catch (error) {
if (error.status >= 400 && error.status < 500) {
throw error
}
if (i === maxRetries - 1) throw error
console.warn(`Request failed, retrying (${i + 1}/${maxRetries})...`)

View File

@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Fail if business code imports direct model or Zep SDKs."""
from __future__ import annotations
import ast
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCAN_ROOTS = [ROOT / "backend" / "app", ROOT / "backend" / "scripts"]
ALLOWED = {
ROOT / "backend" / "app" / "adapters" / "llm" / "openai_compatible.py",
ROOT / "backend" / "app" / "adapters" / "llm" / "camel_adapter.py",
ROOT / "backend" / "app" / "adapters" / "graph" / "zep.py",
}
ALLOWED_LEGACY_ADAPTER_IMPORTS = {
ROOT / "backend" / "app" / "adapters" / "llm" / "factory.py",
ROOT / "backend" / "app" / "adapters" / "graph" / "factory.py",
}
ALLOWED_GRAPHITI_SCHEMA = {
ROOT / "backend" / "app" / "adapters" / "graph" / "graphiti.py",
}
FORBIDDEN = {
"openai",
"anthropic",
"dashscope",
"qwen",
"zep_cloud",
"camel.messages",
"camel.models",
}
FORBIDDEN_STRING_PATTERNS = tuple(
pattern
for module in FORBIDDEN
for pattern in (f"import {module}", f"from {module}")
)
FORBIDDEN_LEGACY_ADAPTER_IMPORTS = {
"app.adapters.llm.openai_compatible",
"app.adapters.graph.zep",
"backend.app.adapters.llm.openai_compatible",
"backend.app.adapters.graph.zep",
}
FORBIDDEN_GRAPHITI_SCHEMA_PATTERNS = {
"MiroFishEntity",
"MiroFishEpisode",
"MiroFishAgentMemory",
"MIROFISH_FACT",
"CREATE CONSTRAINT",
"MERGE (",
"MATCH (",
}
def module_name(node: ast.AST) -> str | None:
if isinstance(node, ast.Import):
return None
if isinstance(node, ast.ImportFrom):
return node.module
return None
def imported_names(node: ast.AST) -> list[str]:
if isinstance(node, ast.Import):
return [alias.name for alias in node.names]
if isinstance(node, ast.ImportFrom):
return [node.module or ""]
return []
def is_forbidden(name: str) -> bool:
return any(name == forbidden or name.startswith(f"{forbidden}.") for forbidden in FORBIDDEN)
def is_forbidden_legacy_adapter_import(name: str) -> bool:
return any(
name == forbidden or name.startswith(f"{forbidden}.")
for forbidden in FORBIDDEN_LEGACY_ADAPTER_IMPORTS
)
def display_path(path: Path, root: Path) -> str:
try:
return str(path.relative_to(root))
except ValueError:
return str(path)
def collect_violations(
scan_roots: list[Path],
*,
root: Path = ROOT,
allowed: set[Path] = ALLOWED,
allowed_legacy_adapter_imports: set[Path] = ALLOWED_LEGACY_ADAPTER_IMPORTS,
allowed_graphiti_schema: set[Path] = ALLOWED_GRAPHITI_SCHEMA,
) -> list[str]:
violations: list[str] = []
for scan_root in scan_roots:
for path in scan_root.rglob("*.py"):
if "__pycache__" in path.parts or path in allowed:
continue
try:
tree = ast.parse(path.read_text(encoding="utf-8"))
except SyntaxError as exc:
violations.append(f"{path}: syntax error: {exc}")
continue
for node in ast.walk(tree):
if isinstance(node, (ast.Import, ast.ImportFrom)):
for name in imported_names(node):
if is_forbidden(name):
violations.append(f"{display_path(path, root)} imports forbidden SDK module {name}")
if path not in allowed_legacy_adapter_imports and is_forbidden_legacy_adapter_import(name):
violations.append(
f"{display_path(path, root)} imports legacy provider adapter directly: {name}"
)
elif isinstance(node, ast.Constant) and isinstance(node.value, str):
for pattern in FORBIDDEN_STRING_PATTERNS:
if pattern in node.value:
violations.append(
f"{display_path(path, root)} contains forbidden SDK import string {pattern!r}"
)
if path not in allowed_graphiti_schema:
for pattern in FORBIDDEN_GRAPHITI_SCHEMA_PATTERNS:
if pattern in node.value:
violations.append(
f"{display_path(path, root)} contains Graphiti/Neo4j schema assumption {pattern!r}"
)
return violations
def main() -> int:
violations = collect_violations(SCAN_ROOTS)
if violations:
print("Provider boundary violations found:")
for violation in violations:
print(f"- {violation}")
return 1
print("Provider boundary check passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

310
scripts/setup_agent_deps.sh Executable file
View File

@ -0,0 +1,310 @@
#!/usr/bin/env bash
set -u
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BACKEND_DIR="$ROOT_DIR/backend"
NEO4J_MODE="existing"
START_DOCKER="prompt"
FAILURES=0
if [ -f "$ROOT_DIR/.env" ]; then
set -a
# shellcheck disable=SC1091
. "$ROOT_DIR/.env"
set +a
fi
usage() {
cat <<'EOF'
Usage: scripts/setup_agent_deps.sh [options]
Options:
--neo4j desktop|native|docker|existing
Choose the Neo4j setup path to explain/check. Default: existing.
desktop = Neo4j Desktop-managed local database.
native = Homebrew or other local host installation.
docker = optional Docker Compose path.
existing = already-running local or remote Neo4j instance.
--start-docker
Start the optional Docker Compose Neo4j service if Docker is available.
--skip-services
Do not try to start Docker services. Still checks Neo4j connectivity.
--start-services
Backward-compatible alias for --start-docker.
Installs MiroFish agent-mode Python dependencies and checks required local
services. Graphiti is installed as a Python dependency; its source is not
vendored into this repository. Docker is optional.
EOF
}
ok() {
printf '[ok] %s\n' "$1"
}
warn() {
printf '[warn] %s\n' "$1"
}
fail() {
printf '[fail] %s\n' "$1"
FAILURES=$((FAILURES + 1))
}
while [ "$#" -gt 0 ]; do
case "$1" in
--neo4j)
shift
if [ "$#" -eq 0 ]; then
fail "--neo4j requires one of: desktop, native, docker, existing"
usage
exit 2
fi
case "$1" in
desktop|native|docker|existing)
NEO4J_MODE="$1"
;;
*)
fail "unsupported --neo4j mode: $1"
usage
exit 2
;;
esac
;;
--start-docker|--start-services)
START_DOCKER="yes"
;;
--skip-services)
START_DOCKER="no"
;;
-h|--help)
usage
exit 0
;;
*)
fail "unknown argument: $1"
usage
exit 2
;;
esac
shift
done
install_agent_deps() {
if command -v uv >/dev/null 2>&1; then
ok "installing backend agent extras with uv"
if (cd "$BACKEND_DIR" && uv sync --extra agent --group dev); then
ok "Python agent dependencies installed"
else
fail "uv sync --extra agent --group dev failed"
fi
else
warn "uv is not installed; install dependencies manually from backend:"
warn "python -m pip install -e '.[agent]'"
fi
}
explain_neo4j_mode() {
case "$NEO4J_MODE" in
desktop)
warn "Neo4j Desktop path selected"
warn "Create/start a Neo4j 5.26+ DBMS in Neo4j Desktop, then export NEO4J_URI/NEO4J_USER/NEO4J_PASSWORD/NEO4J_DATABASE."
;;
native)
warn "Native Neo4j path selected"
if command -v brew >/dev/null 2>&1; then
warn "Homebrew detected. Typical install/start: brew install neo4j && brew services start neo4j"
else
warn "Homebrew not detected. Install Neo4j 5.26+ with your local package manager and start it on NEO4J_URI."
fi
;;
docker)
warn "Optional Docker Compose Neo4j path selected"
warn "Compose file: docker-compose.agent.yml"
;;
existing)
warn "Existing Neo4j path selected"
warn "Set NEO4J_URI/NEO4J_USER/NEO4J_PASSWORD/NEO4J_DATABASE for a reachable Neo4j 5.26+ instance."
;;
esac
}
check_docker_optional() {
if command -v docker >/dev/null 2>&1; then
ok "$(docker --version)"
else
warn "Docker optional, skipped"
return 1
fi
if docker compose version >/dev/null 2>&1; then
ok "$(docker compose version)"
else
warn "Docker Compose optional, skipped"
return 1
fi
return 0
}
maybe_start_docker_neo4j() {
if [ "$NEO4J_MODE" != "docker" ]; then
return
fi
if ! check_docker_optional; then
return
fi
if [ "$START_DOCKER" = "prompt" ]; then
if [ -t 0 ]; then
printf 'Start optional Neo4j Docker Compose service now? [y/N] '
read -r answer
case "$answer" in
y|Y|yes|YES)
START_DOCKER="yes"
;;
*)
START_DOCKER="no"
;;
esac
else
START_DOCKER="no"
warn "non-interactive shell; skipping optional Docker startup prompt"
fi
fi
if [ "$START_DOCKER" = "yes" ]; then
if docker compose -f "$ROOT_DIR/docker-compose.agent.yml" up -d neo4j; then
ok "optional Neo4j compose service requested"
else
warn "optional Docker Compose startup failed; Neo4j connectivity check will report actual readiness"
fi
else
warn "optional Docker startup skipped"
warn "manual Docker command: docker compose -f docker-compose.agent.yml up -d neo4j"
fi
}
run_backend_python() {
if command -v uv >/dev/null 2>&1; then
(cd "$BACKEND_DIR" && uv run python "$@")
else
python3 "$@"
fi
}
check_neo4j() {
run_backend_python - <<'PY'
import os
import sys
uri = os.environ.get("NEO4J_URI", "bolt://localhost:7687")
user = os.environ.get("NEO4J_USER", "neo4j")
password = os.environ.get("NEO4J_PASSWORD", "password")
database = os.environ.get("NEO4J_DATABASE", "neo4j")
try:
from neo4j import GraphDatabase
except Exception as exc:
print(f"[fail] neo4j Python driver import failed: {exc}")
sys.exit(1)
def parse_version(value):
cleaned = value.split("-", 1)[0]
parts = cleaned.split(".")
major = int(parts[0]) if len(parts) > 0 and parts[0].isdigit() else 0
minor = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0
return major, minor
try:
driver = GraphDatabase.driver(uri, auth=(user, password))
with driver.session(database=database) as session:
record = session.run(
"CALL dbms.components() YIELD name, versions RETURN name, versions LIMIT 1"
).single()
driver.close()
except Exception as exc:
print(f"[fail] Neo4j connection/version check failed for {uri}: {exc}")
sys.exit(1)
versions = record["versions"] if record else []
version = versions[0] if versions else "unknown"
major, minor = parse_version(version)
if not (major > 5 or (major == 5 and minor >= 26)):
print(f"[fail] Neo4j version {version} is unsupported; use Neo4j 5.26+")
sys.exit(1)
print(f"[ok] Neo4j version {version}")
PY
status=$?
if [ "$status" -ne 0 ]; then
FAILURES=$((FAILURES + 1))
fi
}
check_ollama_if_configured() {
provider="${MIROFISH_EMBEDDING_PROVIDER:-none}"
search_mode="${MIROFISH_GRAPH_SEARCH_MODE:-fulltext}"
if [ "$search_mode" != "semantic" ] && [ "$search_mode" != "hybrid" ]; then
warn "Ollama optional, skipped because MIROFISH_GRAPH_SEARCH_MODE=$search_mode uses no semantic embedding"
return
fi
if [ "$provider" != "ollama" ]; then
warn "Ollama optional, skipped because MIROFISH_EMBEDDING_PROVIDER=$provider; semantic retrieval may be degraded"
return
fi
run_backend_python - <<'PY'
import json
import os
import sys
import urllib.error
import urllib.request
base_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434").rstrip("/")
model = os.environ.get("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text")
url = f"{base_url}/api/tags"
try:
with urllib.request.urlopen(url, timeout=5) as response:
payload = json.loads(response.read().decode("utf-8"))
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
print(f"[fail] Ollama tags check failed for {url}: {exc}")
print(f"[hint] start Ollama and run: ollama pull {model}")
sys.exit(1)
names = [item.get("name", "") for item in payload.get("models", [])]
found = any(name == model or name.startswith(model + ":") for name in names)
if not found:
print(f"[fail] Ollama embedding model '{model}' not found")
print(f"[hint] run: ollama pull {model}")
sys.exit(1)
print(f"[ok] Ollama embedding model available: {model}")
PY
status=$?
if [ "$status" -ne 0 ]; then
FAILURES=$((FAILURES + 1))
fi
}
install_agent_deps
explain_neo4j_mode
if [ "$NEO4J_MODE" != "docker" ]; then
check_docker_optional || true
fi
maybe_start_docker_neo4j
check_neo4j
check_ollama_if_configured
if [ "$FAILURES" -gt 0 ]; then
printf '[fail] setup completed with %s required failure(s)\n' "$FAILURES"
exit 1
fi
ok "agent dependency and required service checks completed"

View File

@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BACKEND="$ROOT/backend"
TMPDIR="$(mktemp -d)"
RUN_DIR="$TMPDIR/chip-2036"
SEED="$TMPDIR/seed.md"
OBSERVED_TYPES="$TMPDIR/observed_request_types.txt"
export MIROFISH_MODE=agent
export MIROFISH_LLM_PROVIDER=agent_queue
export MIROFISH_GRAPH_PROVIDER=graphiti
export MIROFISH_GRAPHITI_STORE=file
export MIROFISH_GRAPHITI_COMPAT_PATH="$TMPDIR/graphiti-store.json"
export MIROFISH_RUNS_DIR="$TMPDIR/runs"
unset LLM_API_KEY
unset OPENAI_API_KEY
unset ZEP_API_KEY
printf '%s\n' '美国商务部限制先进AI芯片出口。' > "$SEED"
cd "$BACKEND"
uv run mirofish-agent init --seed "$SEED" --requirement "预测未来10年全球芯片能力格局变化" --output "$RUN_DIR" --json >/tmp/mirofish_smoke_init.json
for _ in 1 2 3 4 5 6 7 8 9 10; do
uv run mirofish-agent resume --run "$RUN_DIR" --json > /tmp/mirofish_smoke_resume.json
STATUS="$(python -c 'import json; print(json.load(open("/tmp/mirofish_smoke_resume.json"))["status"])')"
if [ "$STATUS" = "completed" ]; then
test -f "$RUN_DIR/artifacts/report.md"
test -f "$RUN_DIR/artifacts/verdict.json"
test -f "$RUN_DIR/artifacts/timeline.json"
test -f "$RUN_DIR/artifacts/graph_snapshot.json"
uv run mirofish-agent followup ask --run "$RUN_DIR" --question "先进AI芯片出口限制有什么影响?" --json > /tmp/mirofish_smoke_followup.json
FOLLOWUP_STATUS="$(python -c 'import json; print(json.load(open("/tmp/mirofish_smoke_followup.json"))["status"])')"
test "$FOLLOWUP_STATUS" = "need_agent_response"
FOLLOWUP_REQUEST_ID="$(python -c 'import json; print(json.load(open("/tmp/mirofish_smoke_followup.json"))["request_id"])')"
FOLLOWUP_RESPONSE="$(python "$ROOT/scripts/write_mock_agent_response.py" --run "$RUN_DIR" --request-id "$FOLLOWUP_REQUEST_ID")"
uv run mirofish-agent responses validate --run "$RUN_DIR" --response "$FOLLOWUP_RESPONSE" --json >/tmp/mirofish_smoke_followup_validate.json
python -c 'import json,sys; data=json.load(open("/tmp/mirofish_smoke_followup_validate.json")); sys.exit(0 if data["ok"] else 1)'
uv run mirofish-agent followup show --run "$RUN_DIR" --request-id "$FOLLOWUP_REQUEST_ID" --json >/tmp/mirofish_smoke_followup_show.json
python -c 'import json,sys; data=json.load(open("/tmp/mirofish_smoke_followup_show.json")); sys.exit(0 if data["status"] == "ok" else 1)'
test -f "$RUN_DIR/artifacts/followups/$FOLLOWUP_REQUEST_ID.md"
for expected_type in generate_ontology extract_triples generate_oasis_profiles generate_simulation_config simulate_agent_action generate_report; do
if ! grep -qx "$expected_type" "$OBSERVED_TYPES"; then
echo "missing expected agent_queue request type: $expected_type"
cat "$OBSERVED_TYPES" || true
exit 1
fi
done
echo "CLI full agent_queue smoke passed: $RUN_DIR"
exit 0
fi
if [ "$STATUS" != "need_agent_response" ]; then
cat /tmp/mirofish_smoke_resume.json
exit 1
fi
REQUEST_ID="$(python -c 'import json; print(json.load(open("/tmp/mirofish_smoke_resume.json"))["request_id"])')"
REQUEST_FILE="$(python -c 'import json; print(json.load(open("/tmp/mirofish_smoke_resume.json"))["request_file"])')"
python -c 'import json,sys; print(json.load(open(sys.argv[1]))["type"])' "$REQUEST_FILE" >> "$OBSERVED_TYPES"
RESPONSE="$(python "$ROOT/scripts/write_mock_agent_response.py" --run "$RUN_DIR" --request-id "$REQUEST_ID")"
uv run mirofish-agent responses validate --run "$RUN_DIR" --response "$RESPONSE" --json >/tmp/mirofish_smoke_validate.json
python -c 'import json,sys; data=json.load(open("/tmp/mirofish_smoke_validate.json")); sys.exit(0 if data["ok"] else 1)'
done
echo "CLI smoke did not complete within expected steps"
exit 1

View File

@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BACKEND="$ROOT/backend"
TMPDIR="$(mktemp -d)"
RUN_DIR="$TMPDIR/staged-chip-2036"
SEED="$TMPDIR/seed.md"
OBSERVED_TYPES="$TMPDIR/observed_request_types.txt"
export MIROFISH_MODE=agent
export MIROFISH_LLM_PROVIDER=agent_queue
export MIROFISH_GRAPH_PROVIDER=graphiti
export MIROFISH_GRAPHITI_STORE=file
export MIROFISH_GRAPHITI_COMPAT_PATH="$TMPDIR/graphiti-store.json"
export MIROFISH_RUNS_DIR="$TMPDIR/runs"
unset LLM_API_KEY
unset OPENAI_API_KEY
unset ZEP_API_KEY
printf '%s\n' '美国商务部限制先进AI芯片出口。' > "$SEED"
cd "$BACKEND"
uv run mirofish-agent create-run \
--seed "$SEED" \
--requirement "预测未来10年全球芯片能力格局变化" \
--output "$RUN_DIR" \
--mode staged \
--rounds 10 \
--round-unit year \
--json >/tmp/mirofish_staged_init.json
for _ in $(seq 1 80); do
uv run mirofish-agent resume --run "$RUN_DIR" --json > /tmp/mirofish_staged_resume.json
STATUS="$(python -c 'import json; print(json.load(open("/tmp/mirofish_staged_resume.json"))["status"])')"
if [ "$STATUS" = "completed" ]; then
test -f "$RUN_DIR/artifacts/report.md"
test -f "$RUN_DIR/artifacts/verdict.json"
test -f "$RUN_DIR/artifacts/timeline.json"
test -f "$RUN_DIR/artifacts/graph_snapshot.json"
python - "$RUN_DIR" <<'PY'
import json, sys
run = sys.argv[1]
verdict = json.load(open(f"{run}/artifacts/verdict.json"))
timeline = json.load(open(f"{run}/artifacts/timeline.json"))
assert verdict["rounds"] == 10, verdict
assert verdict["simulation_settings"]["round_unit"] == "year", verdict
assert len(timeline) == 10, timeline
PY
for expected_type in generate_ontology extract_triples generate_oasis_profiles generate_simulation_config simulate_agent_action generate_report; do
if ! grep -qx "$expected_type" "$OBSERVED_TYPES"; then
echo "missing expected staged request type: $expected_type"
cat "$OBSERVED_TYPES" || true
exit 1
fi
done
echo "CLI staged agent_queue smoke passed: $RUN_DIR"
exit 0
fi
if [ "$STATUS" = "awaiting_user_confirmation" ]; then
uv run mirofish-agent stage approve --run "$RUN_DIR" --json >/tmp/mirofish_staged_approve.json
continue
fi
if [ "$STATUS" != "need_agent_response" ]; then
cat /tmp/mirofish_staged_resume.json
exit 1
fi
REQUEST_ID="$(python -c 'import json; print(json.load(open("/tmp/mirofish_staged_resume.json"))["request_id"])')"
REQUEST_FILE="$(python -c 'import json; print(json.load(open("/tmp/mirofish_staged_resume.json"))["request_file"])')"
python -c 'import json,sys; print(json.load(open(sys.argv[1]))["type"])' "$REQUEST_FILE" >> "$OBSERVED_TYPES"
RESPONSE="$(python "$ROOT/scripts/write_mock_agent_response.py" --run "$RUN_DIR" --request-id "$REQUEST_ID")"
uv run mirofish-agent responses validate --run "$RUN_DIR" --response "$RESPONSE" --json >/tmp/mirofish_staged_validate.json
python -c 'import json,sys; data=json.load(open("/tmp/mirofish_staged_validate.json")); sys.exit(0 if data["ok"] else 1)'
done
echo "CLI staged smoke did not complete within expected steps"
exit 1

216
scripts/smoke_mcp_full.py Executable file
View File

@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""MCP lifecycle smoke.
This verifies the FastMCP server can be constructed and then exercises the
same lifecycle service used by MCP tools. If the MCP SDK is missing, the script
fails with a clear dependency blocker.
"""
from __future__ import annotations
import json
import os
import tempfile
import asyncio
from pathlib import Path
from write_mock_agent_response import output_for
async def call_tool(server, name: str, arguments: dict) -> dict:
result = await server.call_tool(name, arguments)
if isinstance(result, tuple) and len(result) > 1 and isinstance(result[1], dict):
return result[1]["result"]
if isinstance(result, dict):
return result.get("result", result)
raise RuntimeError(f"Unexpected MCP tool result for {name}: {result!r}")
async def async_main() -> int:
try:
import mcp # noqa: F401
except ImportError as exc:
print("BLOCKER: MCP Python SDK package 'mcp' is not installed.")
print(f"Import error: {exc}")
return 2
from app.mcp_server.server import create_server
server = create_server()
if server is None:
print("BLOCKER: create_server returned None")
return 2
tmp = Path(tempfile.mkdtemp())
os.environ["MIROFISH_MODE"] = "agent"
os.environ["MIROFISH_LLM_PROVIDER"] = "agent_queue"
os.environ["MIROFISH_GRAPH_PROVIDER"] = "graphiti"
os.environ["MIROFISH_GRAPHITI_STORE"] = "file"
os.environ["MIROFISH_GRAPHITI_COMPAT_PATH"] = str(tmp / "graphiti-store.json")
os.environ.pop("LLM_API_KEY", None)
os.environ.pop("OPENAI_API_KEY", None)
os.environ.pop("ZEP_API_KEY", None)
seed = tmp / "seed.md"
seed.write_text("美国商务部限制先进AI芯片出口。", encoding="utf-8")
run_dir = tmp / "mcp-run"
tools = await server.list_tools()
tool_names = {tool.name for tool in tools}
required = {
"mirofish_create_run",
"mirofish_run",
"mirofish_resume_run",
"mirofish_get_status",
"mirofish_get_current_stage",
"mirofish_update_simulation_settings",
"mirofish_approve_stage",
"mirofish_reject_stage",
"mirofish_rerun_stage",
"mirofish_list_requests",
"mirofish_get_request",
"mirofish_submit_response",
"mirofish_validate_response",
"mirofish_build_graph",
"mirofish_search_graph",
"mirofish_export_graph",
"mirofish_start_simulation",
"mirofish_resume_simulation",
"mirofish_generate_report",
"mirofish_get_report",
"mirofish_ask_followup_question",
"mirofish_get_followup_answer",
"mirofish_list_artifacts",
"mirofish_doctor",
}
missing = sorted(required - tool_names)
if missing:
print(f"BLOCKER: MCP tools missing: {missing}")
return 2
create_tool = next(tool for tool in tools if tool.name == "mirofish_create_run")
create_schema = getattr(create_tool, "inputSchema", {}) or {}
create_props = create_schema.get("properties", {})
if "rounds" not in create_props or "mode" not in create_props:
print(f"BLOCKER: mirofish_create_run schema does not expose rounds/mode: {create_schema}")
return 2
staged_dir = tmp / "mcp-staged-run"
staged = await call_tool(
server,
"mirofish_create_run",
{
"seed": str(seed),
"requirement": "预测未来10年全球芯片能力格局变化",
"output": str(staged_dir),
"mode": "staged",
"rounds": 10,
"round_unit": "year",
},
)
assert staged["status"] == "created", staged
assert staged["state"]["workflow_mode"] == "staged", staged
assert staged["state"]["simulation_settings"]["rounds"] == 10, staged
current_stage = await call_tool(server, "mirofish_get_current_stage", {"run": str(staged_dir)})
assert current_stage["stage"]["current_stage"] == "seed_input", current_stage
approved_stage = await call_tool(server, "mirofish_approve_stage", {"run": str(staged_dir)})
assert approved_stage["next_stage"] == "prediction_requirement", approved_stage
created = await call_tool(
server,
"mirofish_create_run",
{"seed": str(seed), "requirement": "预测未来10年全球芯片能力格局变化", "output": str(run_dir), "rounds": 10},
)
assert created["status"] == "created"
result = await call_tool(server, "mirofish_run", {"run": str(run_dir)})
for _ in range(10):
if result["status"] == "completed":
status = await call_tool(server, "mirofish_get_status", {"run": str(run_dir)})
assert status["status"] == "ok", status
report = await call_tool(server, "mirofish_get_report", {"run": str(run_dir)})
assert report["status"] == "ok"
search = await call_tool(
server,
"mirofish_search_graph",
{"run": str(run_dir), "query": "先进AI芯片出口", "limit": 5},
)
assert search["status"] == "ok", search
exported = await call_tool(server, "mirofish_export_graph", {"run": str(run_dir), "output": None})
assert exported["status"] == "ok", exported
artifacts = await call_tool(server, "mirofish_list_artifacts", {"run": str(run_dir)})
artifact_names = {artifact["name"] for artifact in artifacts["artifacts"]}
assert {"report.md", "verdict.json", "timeline.json", "graph_snapshot.json"}.issubset(artifact_names)
doctor = await call_tool(server, "mirofish_doctor", {"runs_dir": str(tmp / "doctor-runs")})
assert doctor["status"] == "ok", doctor
followup = await call_tool(
server,
"mirofish_ask_followup_question",
{"run": str(run_dir), "question": "先进AI芯片出口限制有什么影响?", "limit": 5},
)
assert followup["status"] == "need_agent_response", followup
followup_request = await call_tool(
server,
"mirofish_get_request",
{"run": str(run_dir), "request_id": followup["request_id"]},
)
request = followup_request["request"]
response_path = run_dir / "responses" / f"{request['request_id']}.json"
response_path.write_text(
json.dumps(
{"request_id": request["request_id"], "status": "ok", "output": output_for(request)},
ensure_ascii=False,
),
encoding="utf-8",
)
submitted = await call_tool(
server,
"mirofish_submit_response",
{"run": str(run_dir), "response": str(response_path)},
)
assert submitted["ok"], submitted
answer = await call_tool(
server,
"mirofish_get_followup_answer",
{"run": str(run_dir), "request_id": request["request_id"]},
)
assert answer["status"] == "ok", answer
print(f"MCP lifecycle smoke passed: {run_dir}")
return 0
assert result["status"] == "need_agent_response", result
listed = await call_tool(server, "mirofish_list_requests", {"run": str(run_dir)})
assert any(item["request_id"] == result["request_id"] for item in listed["requests"])
request_result = await call_tool(
server,
"mirofish_get_request",
{"run": str(run_dir), "request_id": result["request_id"]},
)
request = request_result["request"]
request_id = request["request_id"]
response_path = run_dir / "responses" / f"{request_id}.json"
response_path.write_text(
json.dumps({"request_id": request_id, "status": "ok", "output": output_for(request)}, ensure_ascii=False),
encoding="utf-8",
)
validation = await call_tool(
server,
"mirofish_validate_response",
{"run": str(run_dir), "response": str(response_path)},
)
assert validation["ok"], validation
submitted = await call_tool(
server,
"mirofish_submit_response",
{"run": str(run_dir), "response": str(response_path)},
)
assert submitted["ok"], submitted
result = await call_tool(server, "mirofish_resume_run", {"run": str(run_dir)})
print("MCP lifecycle smoke did not complete within expected steps")
return 1
def main() -> int:
return asyncio.run(async_main())
if __name__ == "__main__":
raise SystemExit(main())

8
scripts/smoke_mcp_full.sh Executable file
View File

@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BACKEND="$ROOT/backend"
cd "$BACKEND"
uv run python "$ROOT/scripts/smoke_mcp_full.py"

View File

@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Write a deterministic agent response for the latest or selected request."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
def load_request(run_dir: Path, request_id: str | None) -> dict:
requests = sorted((run_dir / "requests").glob("req_*.json"))
if request_id:
path = run_dir / "requests" / f"{request_id}.json"
else:
unanswered = [path for path in requests if not (run_dir / "responses" / path.name).exists()]
path = unanswered[-1] if unanswered else requests[-1]
return json.loads(path.read_text(encoding="utf-8"))
def output_for(request: dict) -> dict:
task_type = request["type"]
if task_type == "generate_ontology":
return {"ontology": {"entity_types": [{"name": "Organization"}], "edge_types": [{"name": "AFFECTS"}]}}
if task_type == "extract_triples":
return {
"triples": [
{
"subject": "美国商务部",
"predicate": "限制",
"object": "先进AI芯片出口",
"fact": "美国商务部限制先进AI芯片出口。",
"valid_at": "2024-01-01",
"invalid_at": None,
"source": "smoke",
"source_file": "seed.md",
"evidence": "美国商务部限制先进AI芯片出口。",
"confidence": 0.82,
"metadata": {},
}
]
}
if task_type == "generate_oasis_profiles":
return {"profiles": [{"agent_id": "agent_1", "name": "芯片分析员", "persona": "关注芯片供应链变化。"}]}
if task_type == "generate_simulation_config":
return {"config": {"rounds": 1, "platforms": ["agent_queue"], "agents": ["agent_1"]}}
if task_type == "simulate_agent_action":
actions = []
for item in request.get("structured_input", {}).get("actions", []):
actions.append(
{
"agent_id": str(item.get("agent_id")),
"action_id": str(item.get("action_id")),
"action_type": "CREATE_POST",
"content": "先进AI芯片出口限制会推动供应链分化。",
}
)
return {"actions": actions}
if task_type == "summarize_round":
return {
"summary_markdown": "Mock round summary generated without model APIs.",
"key_events": [],
"memory_updates": [],
}
if task_type == "update_memory":
return {
"memory": request.get("structured_input", {}).get("memory", {}),
"events": request.get("structured_input", {}).get("events", []),
}
if task_type == "generate_report":
return {
"report_markdown": "# MiroFish Agent Smoke Report\n\n先进AI芯片出口限制可能推动供应链分化。",
"verdict": {"status": "ok", "confidence": 0.7},
"timeline": [{"valid_at": "2024-01-01", "fact": "美国商务部限制先进AI芯片出口。"}],
}
if task_type == "answer_followup_question":
question = request.get("structured_input", {}).get("question", "")
graph_results = request.get("structured_input", {}).get("graph_results", [])
return {
"answer_markdown": f"Mock follow-up answer for: {question}",
"used_graph_results": graph_results[:3],
"confidence": 0.6,
}
if task_type == "validate_json_output":
return {
"valid": True,
"errors": [],
"output": request.get("structured_input", {}).get("candidate", {}),
}
if task_type == "repair_invalid_json":
return request.get("structured_input", {}).get("invalid_response", {}).get("output", {})
return {"result": {}}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--run", required=True)
parser.add_argument("--request-id", default=None)
args = parser.parse_args()
run_dir = Path(args.run)
request = load_request(run_dir, args.request_id)
response = {"request_id": request["request_id"], "status": "ok", "output": output_for(request)}
response_path = run_dir / "responses" / f"{request['request_id']}.json"
response_path.parent.mkdir(parents=True, exist_ok=True)
response_path.write_text(json.dumps(response, ensure_ascii=False, indent=2), encoding="utf-8")
print(response_path)
return 0
if __name__ == "__main__":
raise SystemExit(main())