feature: webui, kobolcpp intergration memory, game module
This commit is contained in:
+22
@@ -0,0 +1,22 @@
|
||||
/reference
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
*.pyc
|
||||
|
||||
# Agent runtime
|
||||
koboldcpp.exe
|
||||
model.gguf
|
||||
*.gguf
|
||||
data/
|
||||
src/LocalDiplomacy.Agent/data/
|
||||
src/LocalDiplomacy.Agent/koboldcpp.exe
|
||||
src/LocalDiplomacy.Agent/model.gguf
|
||||
src/LocalDiplomacy.Agent/config.yaml
|
||||
|
||||
# .NET/Bannerlord build output
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
@@ -0,0 +1,29 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.5.2.0
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LocalDiplomacy", "src\LocalDiplomacy\LocalDiplomacy.csproj", "{5FF51413-8983-57DD-BFCC-236F7B3B3F5F}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{5FF51413-8983-57DD-BFCC-236F7B3B3F5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5FF51413-8983-57DD-BFCC-236F7B3B3F5F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5FF51413-8983-57DD-BFCC-236F7B3B3F5F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5FF51413-8983-57DD-BFCC-236F7B3B3F5F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{5FF51413-8983-57DD-BFCC-236F7B3B3F5F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {28D30FCB-3EC1-4E01-9CFE-F2BFF6280675}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
@@ -0,0 +1,153 @@
|
||||
# LocalDiplomacy Implementation Status
|
||||
|
||||
Last updated: 2026-04-30
|
||||
|
||||
## Working / Implemented
|
||||
|
||||
### Bannerlord Module
|
||||
|
||||
- Module folder exists at `module/LocalDiplomacy`.
|
||||
- `SubModule.xml` is present and configured for singleplayer.
|
||||
- Module builds to:
|
||||
- `module/LocalDiplomacy/bin/Win64_Shipping_Client/LocalDiplomacy.dll`
|
||||
- Current dependencies:
|
||||
- `Native`
|
||||
- `SandBoxCore`
|
||||
- `Sandbox`
|
||||
- `StoryMode`
|
||||
- `LocalDiplomacy.SubModule` loads in Bannerlord.
|
||||
- Campaign behavior is registered on campaign game start.
|
||||
- Dedicated game-side log exists:
|
||||
- `Documents/Mount and Blade II Bannerlord/Configs/ModLogs/LocalDiplomacy.log`
|
||||
- Dialogue hook is registered for several conversation tokens:
|
||||
- `lord_pretalk`
|
||||
- `lord_talk`
|
||||
- `lord_talk_speak_diplomacy_2`
|
||||
- `hero_main_options`
|
||||
- `hero_talk`
|
||||
- `start`
|
||||
- In-game dialogue option exists:
|
||||
- `[LocalDiplomacy] Ask what news they have.`
|
||||
- Selecting the dialogue option sends a compact `ConversationRequest` to the local Python agent.
|
||||
- Game client is hardcoded to call:
|
||||
- `http://127.0.0.1:8766`
|
||||
- Game client logs outgoing payloads and failed HTTP responses.
|
||||
- Agent responses are displayed in-game with `InformationManager.DisplayMessage`.
|
||||
- Returned game actions are passed through local validation before execution.
|
||||
- Action executor currently displays accepted actions as messages.
|
||||
|
||||
### Python Agent
|
||||
|
||||
- FastAPI agent exists at `src/LocalDiplomacy.Agent`.
|
||||
- Agent runs with `uv`.
|
||||
- Main app module:
|
||||
- `localdiplomacy_agent.app:app`
|
||||
- Main endpoints implemented:
|
||||
- `GET /health`
|
||||
- `GET /debug/status`
|
||||
- `POST /conversation/respond`
|
||||
- `POST /world/tick`
|
||||
- `POST /actions/result`
|
||||
- Web dashboard exists:
|
||||
- `http://127.0.0.1:8766/ui`
|
||||
- Dashboard shows:
|
||||
- agent status
|
||||
- KoboldCpp reachability
|
||||
- memory status
|
||||
- last latency
|
||||
- live logs
|
||||
- KoboldCpp API address
|
||||
- model id
|
||||
- timeout
|
||||
- Dashboard can update KoboldCpp API settings.
|
||||
- Dashboard can ping KoboldCpp.
|
||||
- KoboldCpp API can point at another machine, for example:
|
||||
- `http://192.168.1.17:5001`
|
||||
- Agent logs game requests, agent replies, queued actions, warnings, and errors.
|
||||
- Agent config persists to `config.yaml`.
|
||||
- Agent supports `/no_think` prompt prefixing for Qwen3-style thinking models.
|
||||
- Agent uses focused tool selection for action-looking requests.
|
||||
- Agent uses narrowed action schemas for small models.
|
||||
- Agent can force `propose_game_action` tool calls for explicit action requests.
|
||||
- Python contract accepts C#-style nullable `traits`.
|
||||
- Memory layer scaffold exists and currently defaults to disabled.
|
||||
- SQLite event ledger exists.
|
||||
|
||||
### KoboldCpp / Model Testing
|
||||
|
||||
- KoboldCpp OpenAI-compatible `/v1/chat/completions` flow is implemented.
|
||||
- Tool calling uses OpenAI-style `tools` and `tool_choice`.
|
||||
- Model smoke-test script exists:
|
||||
- `scripts/test_kobold_model.py`
|
||||
- Smoke-test script accepts:
|
||||
- model path
|
||||
- context size
|
||||
- KoboldCpp path
|
||||
- port
|
||||
- keep-running flag
|
||||
- stop-existing flag
|
||||
- extra KoboldCpp args
|
||||
- Smoke-test script runs:
|
||||
- normal dialogue test
|
||||
- auto tool-call test
|
||||
- forced tool-call test
|
||||
- LocalDiplomacy action-planner tool-call test
|
||||
- `Qwen3-4B-abliterated-q4_k_m.gguf` was tested at `40960` context and passed smoke tests with `/no_think`.
|
||||
|
||||
### Tests / Build
|
||||
|
||||
- Python test suite passes.
|
||||
- Current Python test count:
|
||||
- 11 tests
|
||||
- C# module builds successfully with local .NET SDK and Bannerlord references.
|
||||
- Build command:
|
||||
```powershell
|
||||
$env:PATH="$env:USERPROFILE\.dotnet;$env:PATH"
|
||||
$env:BANNERLORD_REFERENCES="D:\SteamLibrary\steamapps\common\Mount & Blade II Bannerlord\bin\Win64_Shipping_Client"
|
||||
dotnet build src\LocalDiplomacy\LocalDiplomacy.csproj
|
||||
```
|
||||
|
||||
## Partially Implemented / Scaffolded
|
||||
|
||||
### Bannerlord Gameplay Integration
|
||||
|
||||
- Dialogue integration is currently a single debug-style option.
|
||||
- Player cannot yet type custom dialogue text inside Bannerlord.
|
||||
- NPC answer is currently displayed as an information message, not as a full custom conversation UI.
|
||||
- No Gauntlet UI has been built inside the game.
|
||||
- No confirmation UI exists yet for dangerous actions.
|
||||
- Local validation exists, but most action-specific validation is still basic.
|
||||
- Action execution is mostly placeholder behavior.
|
||||
- World tick endpoint exists, but the game does not yet send rich world diffs.
|
||||
- Nearby parties, settlements, kingdom state, and recent events are still minimal in the in-game request.
|
||||
|
||||
### Agent
|
||||
|
||||
- Tool registry contains broad AIInfluence-style feature coverage.
|
||||
- Many tools are declared but backed by placeholder/read-only behavior until the Bannerlord connector supplies richer live data.
|
||||
- Memory storage is scaffolded but disabled by default.
|
||||
- Mem0 + Qdrant is planned but not wired as the default runtime path yet.
|
||||
- No separate desktop UI exists; the current UI is browser-based FastAPI HTML.
|
||||
|
||||
## Known Issues / Watch Items
|
||||
|
||||
- Small models can narrate tool intentions unless `/no_think`, narrowed schemas, and forced tool calls are used.
|
||||
- Full tool catalog is too large for reliable 4B model tool choice.
|
||||
- The Python dashboard updates KoboldCpp settings at runtime, but active in-flight calls use the currently loaded config object.
|
||||
- Bannerlord must be closed before rebuilding if the DLL is locked.
|
||||
- The first working in-game path depends on the player selecting the debug dialogue option.
|
||||
- Returned action execution is intentionally conservative and mostly non-mutating.
|
||||
|
||||
## Next Recommended Work
|
||||
|
||||
1. Replace the debug prompt with a small in-game text input or selectable prompt menu.
|
||||
2. Show NPC responses in a proper dialogue/inquiry window instead of only notification messages.
|
||||
3. Expand `BuildDebugConversationRequest` with real nearby parties, settlement, war, clan, and kingdom context.
|
||||
4. Add confirmation UI for diplomacy, hostile, and high-impact actions.
|
||||
5. Implement a small set of real Bannerlord mutations:
|
||||
- follow player
|
||||
- patrol settlement
|
||||
- propose peace
|
||||
- declare war proposal validation only
|
||||
6. Wire world tick diffs from Bannerlord to the agent.
|
||||
7. Enable Mem0 + Qdrant memory once the dialogue loop is stable.
|
||||
@@ -0,0 +1,759 @@
|
||||
# LocalDiplomacy Architecture Plan
|
||||
|
||||
## Summary
|
||||
|
||||
LocalDiplomacy is a Mount & Blade II: Bannerlord singleplayer mod that keeps the roleplay and diplomacy feel of AIInfluence while moving AI orchestration out of the game process.
|
||||
|
||||
The game mod should be a thin, safe client. It captures compact world and dialogue context from Bannerlord, displays AI-driven conversations and events, validates proposed actions, and executes only whitelisted mutations. The external `LocalDiplomacy.Agent` service owns prompt construction, memory retrieval, KoboldCpp calls, tool execution, and audit logging.
|
||||
|
||||
Default stack:
|
||||
|
||||
- Bannerlord C# module: `LocalDiplomacy`
|
||||
- External service: Python 3.11+ FastAPI app, `LocalDiplomacy.Agent`
|
||||
- LLM backend: KoboldCpp OpenAI-compatible `/v1/chat/completions`, launched by the agent from the app working directory
|
||||
- Local memory: Mem0 OSS with Qdrant
|
||||
- Audit/source-of-truth event log: SQLite
|
||||
- Tool calling: OpenAI-style tools, with mod-side validation before any Bannerlord mutation
|
||||
|
||||
## Goals
|
||||
|
||||
- Replace giant prompt text files with compact, structured game-state packets.
|
||||
- Keep rich NPC dialogue, world events, and diplomatic statements.
|
||||
- Allow AI to propose actions, but never let AI directly mutate the game.
|
||||
- Make the AI layer swappable and debuggable outside Bannerlord.
|
||||
- Store long-term character, kingdom, and campaign memories locally.
|
||||
- Keep all player and campaign data on the user's machine by default.
|
||||
|
||||
## Non-Goals For v1
|
||||
|
||||
- Do not copy AIInfluence source, binary code, or assets.
|
||||
- Do not implement intimate/child systems in the first pass.
|
||||
- Do not implement TTS, STT, lip-sync, or generated voice in the first pass.
|
||||
- Do not require cloud APIs.
|
||||
- Do not send full save files, huge world dumps, or unbounded conversation logs to the model.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
Target layout:
|
||||
|
||||
```text
|
||||
LocalDiplomacy/
|
||||
├── docs/
|
||||
│ └── LocalDiplomacy_PLAN.md
|
||||
├── src/
|
||||
│ ├── LocalDiplomacy/
|
||||
│ │ ├── LocalDiplomacy.csproj
|
||||
│ │ ├── SubModule.cs
|
||||
│ │ ├── Agent/
|
||||
│ │ ├── Campaign/
|
||||
│ │ ├── Contracts/
|
||||
│ │ ├── Diplomacy/
|
||||
│ │ ├── Settings/
|
||||
│ │ └── UI/
|
||||
│ └── LocalDiplomacy.Agent/
|
||||
│ ├── pyproject.toml
|
||||
│ ├── config.example.yaml
|
||||
│ ├── localdiplomacy_agent/
|
||||
│ │ ├── app.py
|
||||
│ │ ├── config.py
|
||||
│ │ ├── contracts.py
|
||||
│ │ ├── koboldcpp_client.py
|
||||
│ │ ├── memory.py
|
||||
│ │ ├── prompts.py
|
||||
│ │ ├── tools.py
|
||||
│ │ └── event_log.py
|
||||
│ └── tests/
|
||||
└── module/
|
||||
└── LocalDiplomacy/
|
||||
├── SubModule.xml
|
||||
├── bin/
|
||||
├── GUI/
|
||||
└── ModuleData/
|
||||
```
|
||||
|
||||
## Bannerlord Module Design
|
||||
|
||||
The Bannerlord module should stay intentionally small and defensive.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Load as a singleplayer module named `LocalDiplomacy`.
|
||||
- Register campaign behavior for dialogue and world tick hooks.
|
||||
- Collect relevant context from Bannerlord APIs.
|
||||
- Serialize context into compact JSON requests.
|
||||
- Send requests to the local agent.
|
||||
- Render NPC replies, world events, and diplomatic statements.
|
||||
- Validate proposed actions against current game state.
|
||||
- Execute approved and valid actions.
|
||||
- Report action results back to the agent.
|
||||
|
||||
Suggested dependencies:
|
||||
|
||||
- `Bannerlord.Harmony`
|
||||
- `Bannerlord.UIExtenderEx`
|
||||
- Native Bannerlord modules: `Native`, `SandBoxCore`, `Sandbox`, `StoryMode`, `CustomBattle`
|
||||
- Optional later: MCM for settings UI
|
||||
|
||||
Core C# subsystems:
|
||||
|
||||
- `AgentClient`: HTTP client for the FastAPI service.
|
||||
- `ContextBuilder`: creates compact request DTOs from Bannerlord state.
|
||||
- `ActionValidator`: checks proposed actions against whitelists and live state.
|
||||
- `ActionExecutor`: mutates Bannerlord only after validation.
|
||||
- `DialogueBehavior`: hooks conversation entry points.
|
||||
- `WorldTickBehavior`: sends periodic compact world diffs.
|
||||
- `LocalDiplomacySettings`: service URL, timeout, debug flags, confirmation policy.
|
||||
- `DebugLog`: mod-local logs for requests, responses, validation failures, and connectivity.
|
||||
|
||||
Feature coverage from `reference/aiinfulence_features.md` must be represented in the Bannerlord connector as validated action/context categories, not as in-game AI logic. The connector should expose live state and validate proposals for AI dialogue, NPC memory, trust/lie reactions, dynamic world events, diplomacy, romance/intimacy, settlement combat, death history, party tasks, trade/workshops/items, naval context, recruitment opportunities, visit history, non-combatant protection, and economic effects.
|
||||
|
||||
## External Agent Design
|
||||
|
||||
`LocalDiplomacy.Agent` is a local FastAPI app listening on `127.0.0.1:8766`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Accept compact game-state requests from the mod.
|
||||
- Locate and launch `koboldcpp.exe` and `model.gguf` from the directory the app is run from.
|
||||
- Monitor the KoboldCpp child process and wait for its API to become reachable before accepting AI requests.
|
||||
- Retrieve relevant memories from Mem0/Qdrant.
|
||||
- Read recent events from SQLite.
|
||||
- Build bounded prompts/messages.
|
||||
- Call KoboldCpp via OpenAI-compatible chat completions.
|
||||
- Provide tool schemas to KoboldCpp.
|
||||
- Execute local read/write-intention tools.
|
||||
- Return final assistant text and action proposals to the mod.
|
||||
- Persist memory writes and audit events.
|
||||
|
||||
Default command:
|
||||
|
||||
```powershell
|
||||
uvicorn localdiplomacy_agent.app:app --host 127.0.0.1 --port 8766
|
||||
```
|
||||
|
||||
Expected runtime files in the app working directory:
|
||||
|
||||
```text
|
||||
LocalDiplomacy.Agent/
|
||||
├── koboldcpp.exe
|
||||
├── model.gguf
|
||||
├── config.yaml
|
||||
└── data/
|
||||
```
|
||||
|
||||
On startup, the agent should:
|
||||
|
||||
1. Resolve the current working directory.
|
||||
2. Verify `koboldcpp.exe` exists there.
|
||||
3. Verify `model.gguf` exists there.
|
||||
4. Start KoboldCpp as a child process if no compatible server is already reachable at the configured URL.
|
||||
5. Launch with the configured port, context size, GPU flags, and `--jinjatools`.
|
||||
6. Poll `/api/extra/version` or `/v1/models` until ready.
|
||||
7. Surface a clear `/health` error if the executable, model, or API startup fails.
|
||||
|
||||
Default config:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "127.0.0.1"
|
||||
port: 8766
|
||||
|
||||
koboldcpp:
|
||||
autostart: true
|
||||
executable_path: "./koboldcpp.exe"
|
||||
model_path: "./model.gguf"
|
||||
base_url: "http://127.0.0.1:5001"
|
||||
chat_path: "/v1/chat/completions"
|
||||
model: "local-model"
|
||||
port: 5001
|
||||
context_size: 8192
|
||||
extra_args:
|
||||
- "--jinjatools"
|
||||
timeout_seconds: 120
|
||||
tool_mode: "openai_tools"
|
||||
json_repair_retry: true
|
||||
|
||||
memory:
|
||||
provider: "mem0"
|
||||
vector_store: "qdrant"
|
||||
qdrant_host: "127.0.0.1"
|
||||
qdrant_port: 6333
|
||||
collection: "localdiplomacy_memories"
|
||||
embedder_provider: "ollama"
|
||||
embedder_model: "nomic-embed-text"
|
||||
llm_provider: "openai_compatible"
|
||||
|
||||
event_log:
|
||||
sqlite_path: "./data/localdiplomacy_events.sqlite3"
|
||||
|
||||
generation:
|
||||
temperature: 0.7
|
||||
max_tokens: 800
|
||||
```
|
||||
|
||||
## KoboldCpp Integration
|
||||
|
||||
Use KoboldCpp latest stable. For packaged/local use, place `koboldcpp.exe` and the selected GGUF model named exactly `model.gguf` in the directory where `LocalDiplomacy.Agent` is run. The agent should launch KoboldCpp automatically using those files.
|
||||
|
||||
Default agent-launched command:
|
||||
|
||||
```powershell
|
||||
.\koboldcpp.exe --model .\model.gguf --port 5001 --contextsize 8192 --jinjatools
|
||||
```
|
||||
|
||||
Manual launch remains useful for debugging:
|
||||
|
||||
```powershell
|
||||
koboldcpp.exe --model C:\Models\your-model.gguf --port 5001 --contextsize 8192 --jinjatools
|
||||
```
|
||||
|
||||
Use `--jinja` or a custom chat template only when `model.gguf` requires it. Some modern instruct models are template-sensitive, so every model should pass a smoke test before gameplay.
|
||||
|
||||
Agent request shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "local-model",
|
||||
"messages": [
|
||||
{ "role": "system", "content": "LocalDiplomacy system instructions..." },
|
||||
{ "role": "user", "content": "Compact scene packet..." }
|
||||
],
|
||||
"tools": [],
|
||||
"tool_choice": "auto",
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 800
|
||||
}
|
||||
```
|
||||
|
||||
Tool loop:
|
||||
|
||||
1. Agent sends messages plus tool schemas to KoboldCpp.
|
||||
2. If the assistant returns `tool_calls`, the agent executes those tool handlers locally.
|
||||
3. Agent appends `role: tool` messages with results.
|
||||
4. Agent calls KoboldCpp again until the assistant returns final text or a configured loop limit is reached.
|
||||
5. Agent returns the final response plus queued action proposals to the mod.
|
||||
|
||||
Fallback mode:
|
||||
|
||||
- If a model fails native tool calling, use a strict JSON response contract.
|
||||
- Validate the JSON against Pydantic models.
|
||||
- Attempt one repair prompt.
|
||||
- If still invalid, return a graceful failure response and no actions.
|
||||
|
||||
## Public HTTP Interfaces
|
||||
|
||||
### `GET /health`
|
||||
|
||||
Returns service and dependency status.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"agent_version": "0.1.0",
|
||||
"koboldcpp": "reachable",
|
||||
"memory": "reachable",
|
||||
"event_log": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /conversation/respond`
|
||||
|
||||
Main dialogue endpoint.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"campaign_id": "campaign-123",
|
||||
"save_id": "save-abc",
|
||||
"turn_id": "turn-001",
|
||||
"player_message": "What do you think of the war?",
|
||||
"player": {
|
||||
"id": "player",
|
||||
"name": "Aldric",
|
||||
"clan_id": "player_clan",
|
||||
"kingdom_id": "vlandia",
|
||||
"gold": 12000,
|
||||
"traits": {}
|
||||
},
|
||||
"npc": {
|
||||
"id": "lord_derthert",
|
||||
"name": "Derthert",
|
||||
"occupation": "lord",
|
||||
"clan_id": "dey_meroc",
|
||||
"kingdom_id": "vlandia",
|
||||
"traits": {},
|
||||
"relation_to_player": 12
|
||||
},
|
||||
"scene": {
|
||||
"location_id": "town_sargot",
|
||||
"conversation_state": "lord_dialogue",
|
||||
"player_is_prisoner": false,
|
||||
"npc_is_prisoner": false
|
||||
},
|
||||
"nearby_parties": [],
|
||||
"nearby_settlements": [],
|
||||
"kingdom_state": {},
|
||||
"recent_events": []
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"turn_id": "turn-001",
|
||||
"assistant_text": "The war has teeth, but not yet a mind...",
|
||||
"mood": "wary",
|
||||
"tone": "formal",
|
||||
"visible_world_events": [],
|
||||
"game_actions": [],
|
||||
"memory_writes": [],
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /world/tick`
|
||||
|
||||
Receives compact world diffs. The mod should call this on a low-frequency campaign tick, not every frame.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"campaign_id": "campaign-123",
|
||||
"save_id": "save-abc",
|
||||
"campaign_day": 481.5,
|
||||
"diff_id": "diff-001",
|
||||
"changed_kingdoms": [],
|
||||
"changed_settlements": [],
|
||||
"wars": [],
|
||||
"peace_deals": [],
|
||||
"battles": [],
|
||||
"notable_relation_changes": []
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"diff_id": "diff-001",
|
||||
"accepted": true,
|
||||
"created_events": [],
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /actions/result`
|
||||
|
||||
Reports whether the mod executed, rejected, or failed an action proposal.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"campaign_id": "campaign-123",
|
||||
"turn_id": "turn-001",
|
||||
"action_id": "action-001",
|
||||
"status": "executed",
|
||||
"reason": null,
|
||||
"result_summary": "Derthert proposed peace to Battania."
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"accepted": true
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /debug/status`
|
||||
|
||||
Returns runtime diagnostics for the debug UI.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"last_koboldcpp_latency_ms": 5420,
|
||||
"memory_count_estimate": 128,
|
||||
"queued_action_count": 0,
|
||||
"recent_errors": []
|
||||
}
|
||||
```
|
||||
|
||||
## Core Contracts
|
||||
|
||||
### `GameAction`
|
||||
|
||||
All AI action proposals use one normalized envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"action_id": "action-001",
|
||||
"action_type": "propose_peace",
|
||||
"actor_id": "lord_derthert",
|
||||
"target_id": "kingdom_battania",
|
||||
"args": {
|
||||
"tribute_daily": 500
|
||||
},
|
||||
"confidence": 0.82,
|
||||
"reason": "Vlandia is losing two fronts and needs time.",
|
||||
"requires_player_confirmation": true
|
||||
}
|
||||
```
|
||||
|
||||
Validation rules:
|
||||
|
||||
- `action_type` must be in the whitelist.
|
||||
- `actor_id` and `target_id` must resolve in current game state where required.
|
||||
- Dangerous actions always require confirmation unless explicitly disabled in settings.
|
||||
- The executor must reject stale proposals if game state changed materially.
|
||||
- The AI's `reason` is for logs/UI only and must not override validation.
|
||||
|
||||
### Initial Action Whitelist
|
||||
|
||||
Dialogue:
|
||||
|
||||
- `attack`
|
||||
- `surrender`
|
||||
- `accept_surrender`
|
||||
- `release`
|
||||
- `propose_marriage`
|
||||
- `accept_marriage`
|
||||
- `reject_marriage`
|
||||
|
||||
Party:
|
||||
|
||||
- `follow_player`
|
||||
- `go_to_settlement`
|
||||
- `return_to_player`
|
||||
- `attack_party`
|
||||
- `patrol_settlement`
|
||||
- `wait_near_settlement`
|
||||
- `siege_settlement`
|
||||
- `raid_village`
|
||||
|
||||
Diplomacy:
|
||||
|
||||
- `declare_war`
|
||||
- `propose_peace`
|
||||
- `accept_peace`
|
||||
- `reject_peace`
|
||||
- `propose_alliance`
|
||||
- `accept_alliance`
|
||||
- `reject_alliance`
|
||||
- `break_alliance`
|
||||
- `propose_trade_agreement`
|
||||
- `accept_trade_agreement`
|
||||
- `reject_trade_agreement`
|
||||
- `end_trade_agreement`
|
||||
- `demand_territory`
|
||||
- `transfer_territory`
|
||||
- `demand_tribute`
|
||||
- `accept_tribute`
|
||||
- `reject_tribute`
|
||||
- `demand_reparations`
|
||||
- `accept_reparations`
|
||||
- `reject_reparations`
|
||||
|
||||
Recruitment:
|
||||
|
||||
- `hire_mercenary`
|
||||
- `dismiss_mercenary`
|
||||
- `offer_vassalage`
|
||||
- `dismiss_vassal`
|
||||
- `join_player_clan`
|
||||
- `join_player_kingdom`
|
||||
- `hire_mercenary_clan`
|
||||
- `kick_from_clan`
|
||||
- `dismiss_npc_mercenary`
|
||||
- `release_npc_vassal`
|
||||
|
||||
## Tool Handlers
|
||||
|
||||
Tools exposed to the LLM should be narrow and descriptive.
|
||||
|
||||
Read-only tools:
|
||||
|
||||
- `get_npc_profile(character_id)`
|
||||
- `get_relation_summary(source_id, target_id)`
|
||||
- `get_nearby_parties(reference_id, limit)`
|
||||
- `get_kingdom_status(kingdom_id)`
|
||||
- `get_settlement_status(settlement_id)`
|
||||
- `get_military_info(scope_id)`
|
||||
- `get_naval_info(scope_id)`
|
||||
- `get_trade_info(scope_id)`
|
||||
- `get_romance_status(character_id, player_id)`
|
||||
- `get_death_history(character_id)`
|
||||
- `get_visit_history(character_id)`
|
||||
- `get_recruitment_opportunities(scope_id)`
|
||||
- `search_memory(query, campaign_id, character_id, kingdom_id, limit)`
|
||||
- `list_recent_events(campaign_id, scope_id, limit)`
|
||||
|
||||
Write/intention tools:
|
||||
|
||||
- `remember_fact(scope, text, importance, tags)`
|
||||
- `analyze_lie(speaker_id, listener_id, claim)`
|
||||
- `propose_game_action(action_type, actor_id, target_id, args, confidence, reason)`
|
||||
- `publish_diplomatic_statement(kingdom_id, speaker_id, statement_text, related_event_id)`
|
||||
- `create_world_event(event_type, title, summary, involved_ids, importance, expires_after_days)`
|
||||
- `update_world_event(event_id, summary, spread_to_ids, importance_delta)`
|
||||
- `record_war_statistics(war_id, summary, stats)`
|
||||
- `record_death_history(character_id, history_text, interaction_count)`
|
||||
|
||||
Important rule: no tool handler directly mutates Bannerlord. Mutation tools queue proposals and return proposal IDs. The mod decides whether execution is legal.
|
||||
|
||||
The initial action catalog should include every feature family in the AIInfluence reference: personality/backstory/speech generation, trust and lie detection, conversation memory, known secrets/info, dynamic event creation/spread/update/personalization, diplomatic statements/rounds/alliances/trade/territory/tribute/reparations/war fatigue/war stats/clan expulsion/pardons, romance/intimacy/marriage/degradation, AI-managed settlement combat, death history, party task chains, workshop sales, item exchanges, RP item creation, recruitment detection, settlement/party/visit tracking, non-combatant protection, naval context, and economic effects.
|
||||
|
||||
## Memory System
|
||||
|
||||
Use Mem0 OSS with Qdrant by default.
|
||||
|
||||
Memory scopes:
|
||||
|
||||
- `campaign_id`: isolates saves/campaigns.
|
||||
- `character_id`: NPC-specific memory.
|
||||
- `kingdom_id`: ruler and diplomacy memory.
|
||||
- `player_id`: player-facing preferences and promises.
|
||||
|
||||
Memory categories:
|
||||
|
||||
- `personal_fact`
|
||||
- `relationship_shift`
|
||||
- `promise`
|
||||
- `grudge`
|
||||
- `secret`
|
||||
- `diplomatic_history`
|
||||
- `world_event_summary`
|
||||
- `player_preference`
|
||||
|
||||
Retrieval policy:
|
||||
|
||||
- Retrieve by campaign plus the active NPC/kingdom.
|
||||
- Include only the top relevant memories.
|
||||
- Prefer atomic facts over full conversation excerpts.
|
||||
- Include timestamps and confidence where available.
|
||||
- Never rely on Mem0 as the authoritative game state; live state comes from the mod.
|
||||
|
||||
SQLite event ledger:
|
||||
|
||||
- Append every request summary, response summary, tool call, action proposal, action result, and memory write.
|
||||
- Store enough metadata to debug behavior without storing huge raw prompts by default.
|
||||
- Allow debug mode to store full prompts/responses for troubleshooting.
|
||||
|
||||
## Prompt Strategy
|
||||
|
||||
System prompt should define:
|
||||
|
||||
- The assistant role: an in-world Bannerlord NPC/diplomacy narrator.
|
||||
- The hard boundary: never invent IDs, never claim actions happened unless tools/results confirm them.
|
||||
- Tool usage rules: use read tools for missing relevant facts, use proposal tools for intended actions.
|
||||
- Safety rules: major game actions require explicit agreement and valid targets.
|
||||
- Style: grounded medieval political roleplay, concise enough for live gameplay.
|
||||
|
||||
Per-turn context should include:
|
||||
|
||||
- Current speaker and listener summaries.
|
||||
- Live scene state.
|
||||
- Relevant nearby parties/settlements.
|
||||
- Current kingdom status if politically relevant.
|
||||
- Retrieved memories.
|
||||
- Recent events from SQLite.
|
||||
- Player message.
|
||||
|
||||
Prompt budget policy:
|
||||
|
||||
- Keep the system message stable.
|
||||
- Keep live state compact.
|
||||
- Keep memories capped.
|
||||
- Summarize old events.
|
||||
- Do not paste giant rule files or full campaign state.
|
||||
|
||||
## UI Plan
|
||||
|
||||
v1 UI should mirror the useful feel of AIInfluence without copying assets:
|
||||
|
||||
- Dialogue popup/window for AI conversation.
|
||||
- World events button and window.
|
||||
- Diplomatic statements list.
|
||||
- Debug status panel.
|
||||
- Connection error banner when the external service is offline.
|
||||
|
||||
UI states:
|
||||
|
||||
- `Idle`
|
||||
- `WaitingForAgent`
|
||||
- `ShowingResponse`
|
||||
- `ActionRequiresConfirmation`
|
||||
- `AgentUnavailable`
|
||||
- `ValidationFailed`
|
||||
|
||||
## Settings
|
||||
|
||||
Bannerlord settings:
|
||||
|
||||
- Agent URL, default `http://127.0.0.1:8766`
|
||||
- Request timeout
|
||||
- Enable debug logging
|
||||
- Store full prompts in debug logs
|
||||
- Auto-execute safe actions
|
||||
- Always confirm diplomacy actions
|
||||
- Always confirm hostile actions
|
||||
- World tick interval
|
||||
|
||||
Agent settings:
|
||||
|
||||
- KoboldCpp autostart enabled/disabled
|
||||
- KoboldCpp executable path, default `./koboldcpp.exe`
|
||||
- KoboldCpp model path, default `./model.gguf`
|
||||
- KoboldCpp base URL
|
||||
- KoboldCpp port, context size, and extra launch args
|
||||
- Model name
|
||||
- Tool-call mode
|
||||
- Temperature and max tokens
|
||||
- Memory provider settings
|
||||
- Qdrant connection
|
||||
- SQLite path
|
||||
- Prompt/debug logging level
|
||||
|
||||
## Implementation Milestones
|
||||
|
||||
### Milestone 1: Documentation and Contracts
|
||||
|
||||
- Add this architecture plan.
|
||||
- Define JSON schemas/Pydantic models for all public contracts.
|
||||
- Define matching C# DTOs.
|
||||
- Add sample request/response fixtures.
|
||||
|
||||
### Milestone 2: External Agent Skeleton
|
||||
|
||||
- Create FastAPI app.
|
||||
- Implement `/health`, `/debug/status`, and config loading.
|
||||
- Implement KoboldCpp runtime file checks for `koboldcpp.exe` and `model.gguf`.
|
||||
- Implement child-process launch/stop management for KoboldCpp.
|
||||
- Add event log initialization.
|
||||
- Add unit tests for config and contracts.
|
||||
|
||||
### Milestone 3: KoboldCpp Chat Loop
|
||||
|
||||
- Implement OpenAI-compatible client.
|
||||
- Wait for autostarted KoboldCpp readiness before sending chat requests.
|
||||
- Implement tool schema generation.
|
||||
- Implement bounded tool loop.
|
||||
- Add smoke test script for KoboldCpp connectivity and tool-call behavior.
|
||||
|
||||
### Milestone 4: Memory Layer
|
||||
|
||||
- Add Mem0/Qdrant integration.
|
||||
- Add scoped memory writes and searches.
|
||||
- Add fallback no-memory mode when Qdrant is offline.
|
||||
- Test memory isolation by campaign and character.
|
||||
|
||||
### Milestone 5: Bannerlord Thin Client
|
||||
|
||||
- Create C# module skeleton and `SubModule.xml`.
|
||||
- Add service health check.
|
||||
- Add compact context builder.
|
||||
- Add dialogue request/response path.
|
||||
- Add graceful offline behavior.
|
||||
|
||||
### Milestone 6: Action Proposal and Validation
|
||||
|
||||
- Implement action proposal contract end-to-end.
|
||||
- Add mod-side action whitelist.
|
||||
- Validate IDs and game-state preconditions.
|
||||
- Execute one safe action category first, then expand.
|
||||
|
||||
### Milestone 7: Diplomacy and Events
|
||||
|
||||
- Add diplomatic statement creation.
|
||||
- Add world event ingestion and display.
|
||||
- Add ruler dialogue actions.
|
||||
- Add confirmation UI for dangerous actions.
|
||||
|
||||
## Test Plan
|
||||
|
||||
Agent unit tests:
|
||||
|
||||
- Contract validation rejects malformed requests.
|
||||
- Tool schemas match handler signatures.
|
||||
- `GameAction` normalization fills required defaults.
|
||||
- Memory scope keys isolate campaigns and NPCs.
|
||||
- SQLite event ledger appends expected records.
|
||||
|
||||
Agent integration tests:
|
||||
|
||||
- `/health` succeeds with dependencies mocked.
|
||||
- KoboldCpp chat succeeds with a basic response.
|
||||
- Tool loop handles one read-only tool call.
|
||||
- Tool loop handles one action proposal.
|
||||
- Invalid tool JSON triggers one repair attempt.
|
||||
- Failed repair returns no actions.
|
||||
|
||||
Bannerlord smoke tests:
|
||||
|
||||
- Module loads with required dependencies.
|
||||
- Settings load defaults.
|
||||
- Agent unavailable state is shown gracefully.
|
||||
- Dialogue UI sends a compact request.
|
||||
- Response displays without executing actions.
|
||||
- Invalid action proposal is rejected and logged.
|
||||
|
||||
Gameplay acceptance tests:
|
||||
|
||||
- NPC remembers a prior promise after save reload.
|
||||
- Ruler creates a diplomatic statement from recent events.
|
||||
- AI refuses or asks clarification when a target id is missing.
|
||||
- Peace/war proposals require confirmation.
|
||||
- Large campaign state remains compact and avoids giant generated text files.
|
||||
|
||||
## Rollout Strategy
|
||||
|
||||
Start with developer-only debug mode:
|
||||
|
||||
1. Put `koboldcpp.exe` and `model.gguf` beside the agent startup directory.
|
||||
2. Run Qdrant manually or through Docker.
|
||||
3. Run `LocalDiplomacy.Agent`; it should launch KoboldCpp automatically.
|
||||
4. Launch Bannerlord with the mod.
|
||||
5. Use debug panel to verify service health.
|
||||
6. Test one dialogue response with actions disabled.
|
||||
7. Enable action proposals but keep all execution behind confirmation.
|
||||
8. Gradually enable safe auto-execution for low-risk actions.
|
||||
|
||||
## Risks And Mitigations
|
||||
|
||||
- KoboldCpp tool calling varies by model.
|
||||
- Mitigation: require a smoke test and keep JSON fallback mode.
|
||||
|
||||
- KoboldCpp executable/model may be missing from the working directory.
|
||||
- Mitigation: startup checks must produce explicit errors naming the missing file and expected path.
|
||||
|
||||
- Autostarted KoboldCpp may hang during model load.
|
||||
- Mitigation: readiness polling, startup timeout, process logs, and a manual-launch override.
|
||||
|
||||
- AI may hallucinate entities or action parameters.
|
||||
- Mitigation: IDs must come from context/tools and mod-side validation is mandatory.
|
||||
|
||||
- Memory retrieval may surface stale or wrong facts.
|
||||
- Mitigation: live game state always wins; memory is contextual, not authoritative.
|
||||
|
||||
- External service may be offline or slow.
|
||||
- Mitigation: short health checks, clear UI state, configurable timeouts.
|
||||
|
||||
- Bannerlord action APIs can have hidden constraints.
|
||||
- Mitigation: implement action categories incrementally and log validation failures.
|
||||
|
||||
## Research Sources
|
||||
|
||||
- KoboldCpp README and wiki: https://github.com/LostRuins/koboldcpp and https://github.com/LostRuins/koboldcpp/wiki
|
||||
- KoboldCpp releases/tool-calling notes: https://github.com/LostRuins/koboldcpp/releases
|
||||
- Mem0 OSS docs: https://github.com/mem0ai/mem0/blob/main/LLM.md
|
||||
- Mem0/OpenMemory local MCP overview: https://mem0.ai/blog/how-to-make-your-clients-more-context-aware-with-openmemory-mcp
|
||||
- Qdrant Mem0 integration: https://qdrant.tech/documentation/frameworks/mem0/
|
||||
- Graphiti/Zep comparison option: https://www.getzep.com/product/open-source/
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Module>
|
||||
<Name value="LocalDiplomacy" />
|
||||
<Id value="LocalDiplomacy" />
|
||||
<Version value="v0.1.0" />
|
||||
<Official value="false" />
|
||||
<DefaultModule value="false" />
|
||||
<SingleplayerModule value="true" />
|
||||
<MultiplayerModule value="false" />
|
||||
<ModuleCategory value="Singleplayer" />
|
||||
<DependedModules>
|
||||
<DependedModule Id="Native" />
|
||||
<DependedModule Id="SandBoxCore" />
|
||||
<DependedModule Id="Sandbox" />
|
||||
<DependedModule Id="StoryMode" />
|
||||
</DependedModules>
|
||||
<SubModules>
|
||||
<SubModule>
|
||||
<Name value="LocalDiplomacy" />
|
||||
<DLLName value="LocalDiplomacy.dll" />
|
||||
<SubModuleClassType value="LocalDiplomacy.SubModule" />
|
||||
</SubModule>
|
||||
</SubModules>
|
||||
<Xmls />
|
||||
</Module>
|
||||
@@ -1 +1,39 @@
|
||||
asd
|
||||
# LocalDiplomacy
|
||||
|
||||
LocalDiplomacy is a Bannerlord singleplayer mod experiment that keeps the AI roleplay and diplomacy loop outside the game process.
|
||||
|
||||
See [docs/LocalDiplomacy_PLAN.md](docs/LocalDiplomacy_PLAN.md) for the architecture.
|
||||
|
||||
## Agent Quick Start
|
||||
|
||||
From `src/LocalDiplomacy.Agent`:
|
||||
|
||||
```powershell
|
||||
Copy-Item config.example.yaml config.yaml
|
||||
# Put koboldcpp.exe and model.gguf in this directory.
|
||||
uv sync --extra test
|
||||
uv run uvicorn localdiplomacy_agent.app:app --host 127.0.0.1 --port 8766
|
||||
```
|
||||
|
||||
The agent will launch `koboldcpp.exe --model .\model.gguf --port 5001 --contextsize 8192 --jinjatools` when `koboldcpp.autostart` is enabled.
|
||||
|
||||
## Mock Game Client
|
||||
|
||||
Use the mock connector to test the same commands the Bannerlord mod will send without launching the game:
|
||||
|
||||
```powershell
|
||||
cd src\LocalDiplomacy.Agent
|
||||
uv run python -m localdiplomacy_agent.mock_game --mock-agent say "Can we make peace with Battania?"
|
||||
uv run python -m localdiplomacy_agent.mock_game --mock-agent tick --day 12.5
|
||||
```
|
||||
|
||||
Drop `--mock-agent` to send the same requests to a running agent at `http://127.0.0.1:8766`.
|
||||
|
||||
## Bannerlord Build Notes
|
||||
|
||||
Install a .NET SDK that can target `net472`, then set `BANNERLORD_REFERENCES` to Bannerlord's `bin\Win64_Shipping_Client` directory before building:
|
||||
|
||||
```powershell
|
||||
$env:BANNERLORD_REFERENCES="C:\Program Files (x86)\Steam\steamapps\common\Mount & Blade II Bannerlord\bin\Win64_Shipping_Client"
|
||||
dotnet build src\LocalDiplomacy\LocalDiplomacy.csproj
|
||||
```
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestResult:
|
||||
name: str
|
||||
passed: bool
|
||||
details: str
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
repo_root = Path.cwd()
|
||||
model_path = Path(args.model_path).expanduser().resolve()
|
||||
koboldcpp_path = Path(args.koboldcpp_path).expanduser().resolve()
|
||||
|
||||
if not model_path.exists():
|
||||
print(f"FAIL: model does not exist: {model_path}")
|
||||
return 2
|
||||
if not koboldcpp_path.exists():
|
||||
print(f"FAIL: koboldcpp executable does not exist: {koboldcpp_path}")
|
||||
return 2
|
||||
|
||||
logs_dir = repo_root / "data" / "logs"
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
safe_name = model_path.stem.replace(" ", "_")
|
||||
stdout_path = logs_dir / f"model-smoke-{safe_name}-ctx{args.context_size}.out.log"
|
||||
stderr_path = logs_dir / f"model-smoke-{safe_name}-ctx{args.context_size}.err.log"
|
||||
|
||||
process: subprocess.Popen[Any] | None = None
|
||||
try:
|
||||
if args.stop_existing:
|
||||
stop_existing_koboldcpp()
|
||||
|
||||
process = start_koboldcpp(
|
||||
koboldcpp_path=koboldcpp_path,
|
||||
model_path=model_path,
|
||||
port=args.port,
|
||||
context_size=args.context_size,
|
||||
extra_args=args.kobold_arg,
|
||||
stdout_path=stdout_path,
|
||||
stderr_path=stderr_path,
|
||||
)
|
||||
base_url = f"http://127.0.0.1:{args.port}"
|
||||
model_id = wait_for_model(base_url, process, args.startup_timeout_seconds)
|
||||
print(f"Model ready: {model_id}")
|
||||
|
||||
results = [
|
||||
run_dialogue_test(base_url, model_id),
|
||||
run_auto_tool_test(base_url, model_id),
|
||||
run_forced_tool_test(base_url, model_id),
|
||||
run_agent_action_prompt_test(base_url, model_id),
|
||||
]
|
||||
|
||||
passed = all(result.passed for result in results)
|
||||
print()
|
||||
print("KoboldCpp model smoke test summary")
|
||||
print(f"Model: {model_path}")
|
||||
print(f"Context size: {args.context_size}")
|
||||
print(f"KoboldCpp logs: {stdout_path} | {stderr_path}")
|
||||
for result in results:
|
||||
status = "PASS" if result.passed else "FAIL"
|
||||
print(f"{status}: {result.name} - {result.details}")
|
||||
print()
|
||||
print("PASS" if passed else "FAIL")
|
||||
return 0 if passed else 1
|
||||
finally:
|
||||
if process is not None and process.poll() is None and not args.keep_running:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=20)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Start KoboldCpp with a GGUF and run LocalDiplomacy tool-calling smoke tests."
|
||||
)
|
||||
parser.add_argument("model_path", help="Path to the GGUF model to test.")
|
||||
parser.add_argument("--context-size", type=int, default=40960, help="KoboldCpp context size.")
|
||||
parser.add_argument("--koboldcpp-path", default="./koboldcpp.exe", help="Path to koboldcpp.exe.")
|
||||
parser.add_argument("--port", type=int, default=5001, help="KoboldCpp HTTP port.")
|
||||
parser.add_argument("--startup-timeout-seconds", type=float, default=240.0)
|
||||
parser.add_argument("--request-timeout-seconds", type=float, default=180.0)
|
||||
parser.add_argument("--keep-running", action="store_true", help="Leave the started KoboldCpp process running.")
|
||||
parser.add_argument("--stop-existing", action="store_true", help="Stop existing koboldcpp.exe processes first.")
|
||||
parser.add_argument(
|
||||
"--kobold-arg",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Extra argument passed to KoboldCpp. Repeat for multiple args.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def start_koboldcpp(
|
||||
*,
|
||||
koboldcpp_path: Path,
|
||||
model_path: Path,
|
||||
port: int,
|
||||
context_size: int,
|
||||
extra_args: list[str],
|
||||
stdout_path: Path,
|
||||
stderr_path: Path,
|
||||
) -> subprocess.Popen[Any]:
|
||||
command = [
|
||||
str(koboldcpp_path),
|
||||
"--model",
|
||||
str(model_path),
|
||||
"--port",
|
||||
str(port),
|
||||
"--contextsize",
|
||||
str(context_size),
|
||||
"--jinja",
|
||||
"--jinjatools",
|
||||
*extra_args,
|
||||
]
|
||||
print("Starting KoboldCpp:")
|
||||
print(" ".join(quote_arg(part) for part in command))
|
||||
return subprocess.Popen(
|
||||
command,
|
||||
stdout=stdout_path.open("w", encoding="utf-8", errors="replace"),
|
||||
stderr=stderr_path.open("w", encoding="utf-8", errors="replace"),
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
|
||||
)
|
||||
|
||||
|
||||
def wait_for_model(base_url: str, process: subprocess.Popen[Any], timeout_seconds: float) -> str:
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
last_error = ""
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(f"KoboldCpp exited early with code {process.returncode}.")
|
||||
try:
|
||||
data = get_json(f"{base_url}/v1/models", timeout=10)
|
||||
models = data.get("data") or []
|
||||
if models:
|
||||
return str(models[0].get("id") or "local-model")
|
||||
except Exception as exc: # noqa: BLE001 - report the last startup error.
|
||||
last_error = str(exc)
|
||||
time.sleep(2)
|
||||
raise TimeoutError(f"KoboldCpp did not become ready within {timeout_seconds}s. Last error: {last_error}")
|
||||
|
||||
|
||||
def run_dialogue_test(base_url: str, model_id: str) -> TestResult:
|
||||
data = chat(
|
||||
base_url,
|
||||
{
|
||||
"model": model_id,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "/no_think You are Derthert, a Bannerlord lord. Answer in one sentence. Never expose reasoning.",
|
||||
},
|
||||
{"role": "user", "content": "/no_think Greetings. What news from the border?"},
|
||||
],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 120,
|
||||
},
|
||||
)
|
||||
content = first_message(data).get("content") or ""
|
||||
bad_markers = ["<think>", "</think>", '"arguments"', "<tool_call"]
|
||||
passed = bool(content.strip()) and not any(marker in content for marker in bad_markers)
|
||||
return TestResult("dialogue_no_visible_reasoning", passed, truncate(content.strip()))
|
||||
|
||||
|
||||
def run_auto_tool_test(base_url: str, model_id: str) -> TestResult:
|
||||
data = chat(
|
||||
base_url,
|
||||
{
|
||||
"model": model_id,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "/no_think Use tools through the API when a game action is needed. Never expose reasoning.",
|
||||
},
|
||||
{"role": "user", "content": "/no_think Derthert should propose peace with Battania."},
|
||||
],
|
||||
"tools": [action_tool_schema(["propose_peace", "declare_war"])],
|
||||
"tool_choice": "auto",
|
||||
"temperature": 0.0,
|
||||
"max_tokens": 300,
|
||||
},
|
||||
)
|
||||
return validate_tool_call("auto_tool_call", data)
|
||||
|
||||
|
||||
def run_forced_tool_test(base_url: str, model_id: str) -> TestResult:
|
||||
data = chat(
|
||||
base_url,
|
||||
{
|
||||
"model": model_id,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "/no_think Use tools through the API when a game action is needed. Never expose reasoning.",
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"/no_think Use propose_game_action now: action_type propose_peace, "
|
||||
"actor_id lord_derthert, target_id kingdom_battania, reason end the border raids."
|
||||
),
|
||||
},
|
||||
],
|
||||
"tools": [action_tool_schema(["accept_peace", "propose_peace", "reject_peace"])],
|
||||
"tool_choice": {"type": "function", "function": {"name": "propose_game_action"}},
|
||||
"temperature": 0.0,
|
||||
"max_tokens": 300,
|
||||
},
|
||||
)
|
||||
return validate_tool_call("forced_tool_call", data)
|
||||
|
||||
|
||||
def run_agent_action_prompt_test(base_url: str, model_id: str) -> TestResult:
|
||||
context = {
|
||||
"campaign_id": "mock-campaign",
|
||||
"player_id": "player",
|
||||
"npc_id": "lord_derthert",
|
||||
"npc_name": "Derthert",
|
||||
"npc_kingdom_id": "kingdom_vlandia",
|
||||
"player_kingdom_id": "kingdom_vlandia",
|
||||
"kingdom_state": {"wars": [{"enemy_kingdom_id": "kingdom_battania", "days": 42}], "war_fatigue": 0.67},
|
||||
"nearby_parties": [{"id": "party_battania_raiders", "faction_id": "kingdom_battania"}],
|
||||
"nearby_settlements": [{"id": "town_sargot", "owner_kingdom_id": "kingdom_vlandia"}],
|
||||
"recent_events": [{"id": "event_border_raids", "summary": "Border raids have strained Vlandia and Battania."}],
|
||||
}
|
||||
data = chat(
|
||||
base_url,
|
||||
{
|
||||
"model": model_id,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"/no_think You are LocalDiplomacy's Bannerlord action planner. "
|
||||
"Call the provided tool through the API. Do not answer in prose. Use only IDs present in the request."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"/no_think "
|
||||
f"Context: {json.dumps(context, ensure_ascii=False)}\n"
|
||||
"Command: Use propose_game_action now: action_type propose_peace, actor_id lord_derthert, "
|
||||
"target_id kingdom_battania, reason end the border raids."
|
||||
),
|
||||
},
|
||||
],
|
||||
"tools": [action_tool_schema(["accept_peace", "propose_peace", "reject_peace"])],
|
||||
"tool_choice": {"type": "function", "function": {"name": "propose_game_action"}},
|
||||
"temperature": 0.0,
|
||||
"max_tokens": 400,
|
||||
},
|
||||
)
|
||||
return validate_tool_call("agent_action_prompt_tool_call", data)
|
||||
|
||||
|
||||
def action_tool_schema(action_types: list[str]) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "propose_game_action",
|
||||
"description": "Queue one Bannerlord game action proposal for mod-side validation.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action_type": {"type": "string", "enum": action_types},
|
||||
"actor_id": {"type": "string"},
|
||||
"target_id": {"type": "string"},
|
||||
"args": {"type": "object"},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"reason": {"type": "string"},
|
||||
"requires_player_confirmation": {"type": "boolean"},
|
||||
},
|
||||
"required": ["action_type", "actor_id", "target_id", "reason"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def validate_tool_call(name: str, data: dict[str, Any]) -> TestResult:
|
||||
message = first_message(data)
|
||||
tool_calls = message.get("tool_calls") or []
|
||||
if not tool_calls:
|
||||
return TestResult(name, False, f"no tool_calls; content={truncate(str(message.get('content') or ''))}")
|
||||
|
||||
function = (tool_calls[0] or {}).get("function") or {}
|
||||
arguments_raw = function.get("arguments") or "{}"
|
||||
try:
|
||||
arguments = json.loads(arguments_raw)
|
||||
except json.JSONDecodeError:
|
||||
return TestResult(name, False, f"invalid arguments JSON: {truncate(arguments_raw)}")
|
||||
|
||||
passed = (
|
||||
function.get("name") == "propose_game_action"
|
||||
and arguments.get("action_type") == "propose_peace"
|
||||
and bool(arguments.get("actor_id"))
|
||||
and bool(arguments.get("target_id"))
|
||||
)
|
||||
return TestResult(name, passed, json.dumps(arguments, ensure_ascii=False))
|
||||
|
||||
|
||||
def chat(base_url: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return post_json(f"{base_url}/v1/chat/completions", payload, timeout=180)
|
||||
|
||||
|
||||
def first_message(data: dict[str, Any]) -> dict[str, Any]:
|
||||
choices = data.get("choices") or [{}]
|
||||
choice = choices[0] or {}
|
||||
return choice.get("message") or {}
|
||||
|
||||
|
||||
def get_json(url: str, timeout: float) -> dict[str, Any]:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
|
||||
def post_json(url: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
error_body = exc.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"HTTP {exc.code}: {error_body}") from exc
|
||||
|
||||
|
||||
def stop_existing_koboldcpp() -> None:
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
subprocess.run(
|
||||
[
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"Get-Process koboldcpp -ErrorAction SilentlyContinue | Stop-Process -Force",
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def quote_arg(value: str) -> str:
|
||||
if " " in value or "\t" in value:
|
||||
return f'"{value}"'
|
||||
return value
|
||||
|
||||
|
||||
def truncate(value: str, limit: int = 220) -> str:
|
||||
value = " ".join(value.split())
|
||||
return value if len(value) <= limit else f"{value[: limit - 3]}..."
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,39 @@
|
||||
server:
|
||||
host: "127.0.0.1"
|
||||
port: 8766
|
||||
|
||||
koboldcpp:
|
||||
autostart: true
|
||||
executable_path: "./koboldcpp.exe"
|
||||
model_path: "./model.gguf"
|
||||
base_url: "http://127.0.0.1:5001"
|
||||
chat_path: "/v1/chat/completions"
|
||||
model: "local-model"
|
||||
port: 5001
|
||||
context_size: 8192
|
||||
extra_args:
|
||||
- "--jinja"
|
||||
- "--jinjatools"
|
||||
startup_timeout_seconds: 180
|
||||
timeout_seconds: 120
|
||||
tool_mode: "openai_tools"
|
||||
json_repair_retry: true
|
||||
|
||||
memory:
|
||||
provider: "disabled"
|
||||
vector_store: "qdrant"
|
||||
qdrant_host: "127.0.0.1"
|
||||
qdrant_port: 6333
|
||||
collection: "localdiplomacy_memories"
|
||||
embedder_provider: "ollama"
|
||||
embedder_model: "nomic-embed-text"
|
||||
|
||||
event_log:
|
||||
sqlite_path: "./data/localdiplomacy_events.sqlite3"
|
||||
|
||||
generation:
|
||||
temperature: 0.7
|
||||
max_tokens: 800
|
||||
# Useful for Qwen3-style thinking models when tool calls must be machine-readable.
|
||||
suppress_thinking: false
|
||||
suppress_thinking_token: "/no_think"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""LocalDiplomacy external AI agent."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Feature and action names shared by LocalDiplomacy's agent-side policy."""
|
||||
|
||||
ACTION_TYPES: set[str] = {
|
||||
# Dialogue and conversation state.
|
||||
"attack",
|
||||
"surrender",
|
||||
"accept_surrender",
|
||||
"release",
|
||||
"escalate_conflict",
|
||||
"deescalate_conflict",
|
||||
"update_trust",
|
||||
"record_lie_detection",
|
||||
"generate_personality_profile",
|
||||
"generate_backstory",
|
||||
"set_speech_pattern",
|
||||
"propose_tts_line",
|
||||
# Memory and information tracking.
|
||||
"remember_conversation",
|
||||
"remember_secret",
|
||||
"remember_known_info",
|
||||
"record_relationship",
|
||||
"record_mentioned_entity",
|
||||
"record_visit",
|
||||
"protect_noncombatant_party",
|
||||
# Dynamic world events.
|
||||
"create_world_event",
|
||||
"update_world_event",
|
||||
"spread_world_event",
|
||||
"personalize_world_event",
|
||||
"apply_economic_effect",
|
||||
# Diplomacy.
|
||||
"publish_diplomatic_statement",
|
||||
"run_diplomacy_round",
|
||||
"declare_war",
|
||||
"propose_peace",
|
||||
"accept_peace",
|
||||
"reject_peace",
|
||||
"propose_alliance",
|
||||
"accept_alliance",
|
||||
"reject_alliance",
|
||||
"break_alliance",
|
||||
"propose_trade_agreement",
|
||||
"accept_trade_agreement",
|
||||
"reject_trade_agreement",
|
||||
"end_trade_agreement",
|
||||
"demand_territory",
|
||||
"transfer_territory",
|
||||
"demand_tribute",
|
||||
"accept_tribute",
|
||||
"reject_tribute",
|
||||
"demand_reparations",
|
||||
"accept_reparations",
|
||||
"reject_reparations",
|
||||
"record_war_statistics",
|
||||
"update_war_fatigue",
|
||||
"expel_clan",
|
||||
"pardon_clan",
|
||||
# Romance.
|
||||
"start_romance_interest",
|
||||
"increase_romance",
|
||||
"decrease_romance",
|
||||
"propose_marriage",
|
||||
"accept_marriage",
|
||||
"reject_marriage",
|
||||
"propose_intimacy",
|
||||
"accept_intimacy",
|
||||
"reject_intimacy",
|
||||
# Settlement combat.
|
||||
"propose_settlement_combat",
|
||||
"spawn_settlement_defenders",
|
||||
"set_civilian_panic",
|
||||
"set_companion_combat_decision",
|
||||
"set_lord_intervention",
|
||||
"continue_location_combat",
|
||||
"create_post_combat_event",
|
||||
"raid_village_after_combat",
|
||||
"burn_village_after_combat",
|
||||
"capture_player_after_knockout",
|
||||
"rescue_player_after_knockout",
|
||||
# Death history.
|
||||
"record_hero_death",
|
||||
"generate_death_history",
|
||||
"decline_death_history",
|
||||
# Party/task actions.
|
||||
"follow_player",
|
||||
"go_to_settlement",
|
||||
"return_to_player",
|
||||
"create_party",
|
||||
"attack_party",
|
||||
"patrol_settlement",
|
||||
"wait_near_settlement",
|
||||
"siege_settlement",
|
||||
"raid_village",
|
||||
"save_task_chain",
|
||||
"manage_companion_party",
|
||||
# Trading/recruitment.
|
||||
"sell_workshop",
|
||||
"exchange_items",
|
||||
"create_rp_item",
|
||||
"hire_mercenary",
|
||||
"dismiss_mercenary",
|
||||
"offer_vassalage",
|
||||
"dismiss_vassal",
|
||||
"join_player_clan",
|
||||
"join_player_kingdom",
|
||||
"hire_mercenary_clan",
|
||||
"kick_from_clan",
|
||||
"dismiss_npc_mercenary",
|
||||
"release_npc_vassal",
|
||||
"record_recruitment_opportunity",
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import traceback
|
||||
from copy import deepcopy
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from . import __version__
|
||||
from .config import save_config, load_config, resolve_runtime_path
|
||||
from .contracts import (
|
||||
ActionResultRequest,
|
||||
ActionResultResponse,
|
||||
ConversationRequest,
|
||||
ConversationResponse,
|
||||
DebugStatusResponse,
|
||||
HealthResponse,
|
||||
WorldTickRequest,
|
||||
WorldTickResponse,
|
||||
)
|
||||
from .event_log import EventLog
|
||||
from .koboldcpp_client import KoboldCppClient
|
||||
from .koboldcpp_process import KoboldCppProcess
|
||||
from .memory import MemoryStore
|
||||
from .tools import ToolRegistry
|
||||
|
||||
|
||||
class AppState:
|
||||
def __init__(self) -> None:
|
||||
self.config = load_config()
|
||||
self.event_log = EventLog(resolve_runtime_path(self.config.event_log.sqlite_path))
|
||||
self.memory = MemoryStore(self.config.memory)
|
||||
self.kobold_process = KoboldCppProcess(self.config.koboldcpp, Path.cwd())
|
||||
self.kobold_client = KoboldCppClient(self.config)
|
||||
self.recent_errors: list[str] = []
|
||||
self.ui_logs: list[dict[str, str]] = []
|
||||
|
||||
def log(self, level: str, message: str) -> None:
|
||||
self.ui_logs.append(
|
||||
{
|
||||
"time": datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"level": level.upper(),
|
||||
"message": message,
|
||||
}
|
||||
)
|
||||
del self.ui_logs[:-200]
|
||||
|
||||
|
||||
state = AppState()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
state.memory.initialize()
|
||||
state.log("info", "LocalDiplomacy.Agent starting.")
|
||||
try:
|
||||
state.kobold_process.ensure_started()
|
||||
state.log("info", f"KoboldCpp endpoint configured as {state.config.koboldcpp.base_url}.")
|
||||
except Exception as exc: # Keep service up so /health can explain the problem.
|
||||
state.recent_errors.append(str(exc))
|
||||
state.log("error", str(exc))
|
||||
yield
|
||||
state.log("info", "LocalDiplomacy.Agent stopping.")
|
||||
state.kobold_process.stop()
|
||||
|
||||
|
||||
app = FastAPI(title="LocalDiplomacy.Agent", version=__version__, lifespan=lifespan)
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def dashboard() -> HTMLResponse:
|
||||
return HTMLResponse(DASHBOARD_HTML)
|
||||
|
||||
|
||||
@app.get("/ui", response_class=HTMLResponse)
|
||||
def dashboard_alias() -> HTMLResponse:
|
||||
return dashboard()
|
||||
|
||||
|
||||
@app.get("/api/dashboard")
|
||||
def dashboard_state() -> JSONResponse:
|
||||
health_data = health().model_dump(mode="json")
|
||||
debug_data = debug_status().model_dump(mode="json")
|
||||
return JSONResponse(
|
||||
{
|
||||
"version": __version__,
|
||||
"health": health_data,
|
||||
"debug": debug_data,
|
||||
"koboldcpp": {
|
||||
"base_url": state.config.koboldcpp.base_url,
|
||||
"chat_path": state.config.koboldcpp.chat_path,
|
||||
"model": state.config.koboldcpp.model,
|
||||
"autostart": state.config.koboldcpp.autostart,
|
||||
"timeout_seconds": state.config.koboldcpp.timeout_seconds,
|
||||
},
|
||||
"generation": state.config.generation.model_dump(mode="json"),
|
||||
"logs": state.ui_logs[-100:],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/koboldcpp")
|
||||
async def update_koboldcpp_settings(request: Request) -> JSONResponse:
|
||||
payload = await request.json()
|
||||
base_url = str(payload.get("base_url") or "").strip().rstrip("/")
|
||||
model = str(payload.get("model") or "").strip()
|
||||
timeout_seconds = payload.get("timeout_seconds")
|
||||
autostart = payload.get("autostart")
|
||||
|
||||
if not base_url.startswith(("http://", "https://")):
|
||||
return JSONResponse({"ok": False, "error": "KoboldCpp API URL must start with http:// or https://."}, status_code=400)
|
||||
|
||||
state.config.koboldcpp.base_url = base_url
|
||||
if model:
|
||||
state.config.koboldcpp.model = model
|
||||
if isinstance(timeout_seconds, (int, float)) and timeout_seconds > 0:
|
||||
state.config.koboldcpp.timeout_seconds = int(timeout_seconds)
|
||||
if isinstance(autostart, bool):
|
||||
state.config.koboldcpp.autostart = autostart
|
||||
|
||||
save_config(state.config)
|
||||
state.log("info", f"KoboldCpp API settings updated: {state.config.koboldcpp.base_url}, model {state.config.koboldcpp.model}.")
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.post("/api/koboldcpp/ping")
|
||||
def ping_koboldcpp() -> JSONResponse:
|
||||
reachable = state.kobold_process.is_reachable()
|
||||
state.log("info" if reachable else "warning", f"KoboldCpp ping {'succeeded' if reachable else 'failed'} for {state.config.koboldcpp.base_url}.")
|
||||
return JSONResponse({"ok": reachable, "base_url": state.config.koboldcpp.base_url})
|
||||
|
||||
|
||||
@app.get("/health", response_model=HealthResponse)
|
||||
def health() -> HealthResponse:
|
||||
errors = list(state.recent_errors[-5:])
|
||||
kobold = "reachable" if state.kobold_process.is_reachable() else "unreachable"
|
||||
if kobold == "unreachable":
|
||||
errors.extend(state.kobold_process.validate_runtime_files())
|
||||
status = "ok" if not errors and kobold == "reachable" else "degraded"
|
||||
return HealthResponse(
|
||||
status=status,
|
||||
agent_version=__version__,
|
||||
koboldcpp=kobold,
|
||||
memory=state.memory.status,
|
||||
event_log="ok",
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/debug/status", response_model=DebugStatusResponse)
|
||||
def debug_status() -> DebugStatusResponse:
|
||||
return DebugStatusResponse(
|
||||
last_koboldcpp_latency_ms=state.kobold_client.last_latency_ms,
|
||||
memory_count_estimate=state.memory.count_estimate(),
|
||||
queued_action_count=0,
|
||||
recent_errors=state.recent_errors[-10:],
|
||||
)
|
||||
|
||||
|
||||
@app.post("/conversation/respond", response_model=ConversationResponse)
|
||||
async def conversation_respond(request: ConversationRequest) -> ConversationResponse:
|
||||
state.log("game", f"Conversation request from {request.player.name} to {request.npc.name}: {request.player_message}")
|
||||
state.event_log.append(
|
||||
"conversation_request",
|
||||
request.model_dump(mode="json"),
|
||||
campaign_id=request.campaign_id,
|
||||
turn_id=request.turn_id,
|
||||
)
|
||||
registry = ToolRegistry(state.memory, state.event_log)
|
||||
tool_schemas = _select_tool_schemas(request, registry)
|
||||
forced_tool_name = _forced_tool_name(tool_schemas)
|
||||
messages = _build_action_messages(request) if forced_tool_name else _build_messages(request)
|
||||
|
||||
try:
|
||||
final_text = await _run_tool_loop(messages, registry, tool_schemas, forced_tool_name)
|
||||
response = ConversationResponse(
|
||||
turn_id=request.turn_id,
|
||||
assistant_text=final_text,
|
||||
game_actions=registry.queued_actions,
|
||||
memory_writes=registry.memory_writes,
|
||||
)
|
||||
state.log("agent", f"Response: {final_text[:240] or '(tool-only response)'}")
|
||||
for action in registry.queued_actions:
|
||||
state.log("action", f"Queued {action.action_type if hasattr(action, 'action_type') else action.ActionType}.")
|
||||
except Exception as exc:
|
||||
details = traceback.format_exc()
|
||||
state.recent_errors.append(details)
|
||||
state.log("error", str(exc))
|
||||
response = ConversationResponse(
|
||||
turn_id=request.turn_id,
|
||||
assistant_text="I need a moment. The local diplomacy engine is not answering cleanly.",
|
||||
warnings=[details],
|
||||
)
|
||||
|
||||
state.event_log.append(
|
||||
"conversation_response",
|
||||
response.model_dump(mode="json"),
|
||||
campaign_id=request.campaign_id,
|
||||
turn_id=request.turn_id,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@app.post("/world/tick", response_model=WorldTickResponse)
|
||||
def world_tick(request: WorldTickRequest) -> WorldTickResponse:
|
||||
state.log("game", f"World tick received: {request.diff_id}.")
|
||||
state.event_log.append(
|
||||
"world_tick",
|
||||
request.model_dump(mode="json"),
|
||||
campaign_id=request.campaign_id,
|
||||
)
|
||||
return WorldTickResponse(diff_id=request.diff_id)
|
||||
|
||||
|
||||
@app.post("/actions/result", response_model=ActionResultResponse)
|
||||
def action_result(request: ActionResultRequest) -> ActionResultResponse:
|
||||
state.log("game", f"Action result received: {request.action_id} -> {request.status}.")
|
||||
state.event_log.append(
|
||||
"action_result",
|
||||
request.model_dump(mode="json"),
|
||||
campaign_id=request.campaign_id,
|
||||
turn_id=request.turn_id,
|
||||
)
|
||||
return ActionResultResponse()
|
||||
|
||||
|
||||
def _build_messages(request: ConversationRequest) -> list[dict[str, str]]:
|
||||
system = (
|
||||
"You are LocalDiplomacy, an in-world Bannerlord roleplay and diplomacy engine. "
|
||||
"Stay grounded in the supplied game state. Do not invent entity IDs. "
|
||||
"Use the provided tools only through the API tool-calling mechanism. "
|
||||
"Never write JSON arrays, XML tags, pseudo-functions, or tool calls in your visible message content. "
|
||||
"If an action is needed, call propose_game_action. Otherwise, answer as the NPC in natural language. "
|
||||
"Proposed actions are requests only; the game validates them before anything happens."
|
||||
)
|
||||
packet = {
|
||||
"campaign_id": request.campaign_id,
|
||||
"save_id": request.save_id,
|
||||
"player": request.player.model_dump(mode="json"),
|
||||
"npc": request.npc.model_dump(mode="json"),
|
||||
"scene": request.scene.model_dump(mode="json"),
|
||||
"nearby_parties": request.nearby_parties[:10],
|
||||
"nearby_settlements": request.nearby_settlements[:10],
|
||||
"kingdom_state": request.kingdom_state,
|
||||
"recent_events": request.recent_events[:10],
|
||||
"player_message": request.player_message,
|
||||
}
|
||||
return [
|
||||
{"role": "system", "content": system},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Game state packet follows as JSON. Use it as context, then respond to the player. "
|
||||
"Visible content must be ordinary NPC dialogue, not JSON.\n\n"
|
||||
f"{json.dumps(packet, ensure_ascii=False)}"
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _build_action_messages(request: ConversationRequest) -> list[dict[str, str]]:
|
||||
context = {
|
||||
"campaign_id": request.campaign_id,
|
||||
"player_id": request.player.id,
|
||||
"npc_id": request.npc.id,
|
||||
"npc_name": request.npc.name,
|
||||
"npc_kingdom_id": request.npc.kingdom_id,
|
||||
"player_kingdom_id": request.player.kingdom_id,
|
||||
"kingdom_state": request.kingdom_state,
|
||||
"nearby_parties": request.nearby_parties[:5],
|
||||
"nearby_settlements": request.nearby_settlements[:5],
|
||||
"recent_events": request.recent_events[:5],
|
||||
}
|
||||
return [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are LocalDiplomacy's Bannerlord action planner. "
|
||||
"Call the provided tool through the API. Do not answer in prose. "
|
||||
"Use only IDs present in the request."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"Context: {json.dumps(context, ensure_ascii=False)}\n"
|
||||
f"Command: {request.player_message}"
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _select_tool_schemas(request: ConversationRequest, registry: ToolRegistry) -> list[dict[str, Any]]:
|
||||
message = request.player_message.lower()
|
||||
action_terms = {
|
||||
"attack",
|
||||
"surrender",
|
||||
"release",
|
||||
"marriage",
|
||||
"marry",
|
||||
"follow",
|
||||
"patrol",
|
||||
"siege",
|
||||
"raid",
|
||||
"war",
|
||||
"peace",
|
||||
"alliance",
|
||||
"tribute",
|
||||
"reparations",
|
||||
"territory",
|
||||
"mercenary",
|
||||
"vassal",
|
||||
"recruit",
|
||||
"dismiss",
|
||||
"propose_game_action",
|
||||
"action_type",
|
||||
}
|
||||
if any(term in message for term in action_terms):
|
||||
schema = registry.schema_by_name("propose_game_action")
|
||||
return [_narrow_action_schema(schema, message)] if schema else []
|
||||
else:
|
||||
names = ["search_memory", "remember_fact", "get_npc_profile", "get_relation_summary"]
|
||||
|
||||
schemas = [schema for name in names if (schema := registry.schema_by_name(name))]
|
||||
return schemas or registry.schemas()
|
||||
|
||||
|
||||
def _forced_tool_name(tool_schemas: list[dict[str, Any]]) -> str | None:
|
||||
if len(tool_schemas) != 1:
|
||||
return None
|
||||
function = tool_schemas[0].get("function") or {}
|
||||
name = function.get("name")
|
||||
return name if isinstance(name, str) else None
|
||||
|
||||
|
||||
def _narrow_action_schema(schema: dict[str, Any], message: str) -> dict[str, Any]:
|
||||
narrowed = deepcopy(schema)
|
||||
function = narrowed.get("function") or {}
|
||||
function["description"] = "Queue one Bannerlord game action proposal for mod-side validation."
|
||||
parameters = function.get("parameters") or {}
|
||||
properties = parameters.get("properties") or {}
|
||||
|
||||
candidates: list[str] = []
|
||||
if "peace" in message:
|
||||
candidates.extend(["propose_peace", "accept_peace", "reject_peace"])
|
||||
if "war" in message:
|
||||
candidates.append("declare_war")
|
||||
if "alliance" in message:
|
||||
candidates.extend(["propose_alliance", "accept_alliance", "reject_alliance", "break_alliance"])
|
||||
if "tribute" in message:
|
||||
candidates.extend(["demand_tribute", "accept_tribute", "reject_tribute"])
|
||||
if "reparation" in message:
|
||||
candidates.extend(["demand_reparations", "accept_reparations", "reject_reparations"])
|
||||
if "attack" in message:
|
||||
candidates.extend(["attack", "attack_party"])
|
||||
if "surrender" in message:
|
||||
candidates.extend(["surrender", "accept_surrender"])
|
||||
if "release" in message:
|
||||
candidates.append("release")
|
||||
if "marriage" in message or "marry" in message:
|
||||
candidates.extend(["propose_marriage", "accept_marriage", "reject_marriage"])
|
||||
if "follow" in message:
|
||||
candidates.append("follow_player")
|
||||
if "patrol" in message:
|
||||
candidates.append("patrol_settlement")
|
||||
if "siege" in message:
|
||||
candidates.append("siege_settlement")
|
||||
if "raid" in message:
|
||||
candidates.append("raid_village")
|
||||
if "mercenary" in message:
|
||||
candidates.extend(["hire_mercenary", "dismiss_mercenary", "hire_mercenary_clan"])
|
||||
if "vassal" in message:
|
||||
candidates.extend(["offer_vassalage", "dismiss_vassal", "release_npc_vassal"])
|
||||
if not candidates:
|
||||
candidates = list(properties.get("action_type", {}).get("enum", []))
|
||||
|
||||
properties["action_type"]["enum"] = sorted(set(candidates))
|
||||
parameters["required"] = sorted(set(parameters.get("required", []) + ["target_id"]))
|
||||
return narrowed
|
||||
|
||||
|
||||
async def _run_tool_loop(
|
||||
messages: list[dict[str, Any]],
|
||||
registry: ToolRegistry,
|
||||
tool_schemas: list[dict[str, Any]],
|
||||
forced_tool_name: str | None = None,
|
||||
) -> str:
|
||||
for index in range(4):
|
||||
tool_choice: str | dict[str, Any] | None = None
|
||||
if index == 0 and forced_tool_name:
|
||||
tool_choice = {"type": "function", "function": {"name": forced_tool_name}}
|
||||
data = await state.kobold_client.chat(messages, tool_schemas, tool_choice=tool_choice) or {}
|
||||
choices = data.get("choices") or [{}]
|
||||
choice = choices[0] or {}
|
||||
message = choice.get("message") or {}
|
||||
tool_calls = message.get("tool_calls") or []
|
||||
if not tool_calls:
|
||||
content = str(message.get("content") or "").strip()
|
||||
if _looks_like_pseudo_tool_content(content):
|
||||
return await _repair_visible_response(messages, content)
|
||||
return content
|
||||
|
||||
messages.append(message)
|
||||
for call in tool_calls:
|
||||
call = call or {}
|
||||
function = call.get("function") or {}
|
||||
result = registry.dispatch(function.get("name") or "", function.get("arguments") or "{}")
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": call.get("id"),
|
||||
"content": json.dumps(result, ensure_ascii=False),
|
||||
}
|
||||
)
|
||||
return "I have considered the matter, but I need more certainty before I act."
|
||||
|
||||
|
||||
def _looks_like_pseudo_tool_content(content: str) -> bool:
|
||||
stripped = content.strip()
|
||||
return (
|
||||
stripped.startswith("[")
|
||||
or stripped.startswith("{")
|
||||
or "<tool_call" in stripped
|
||||
or "<end_of_turn>" in stripped
|
||||
or '"arguments"' in stripped and '"name"' in stripped
|
||||
)
|
||||
|
||||
|
||||
async def _repair_visible_response(messages: list[dict[str, Any]], bad_content: str) -> str:
|
||||
repair_messages = [
|
||||
*messages,
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": bad_content[:2000],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Your previous output was invalid because it exposed tool/pseudo-tool JSON. "
|
||||
"Rewrite it as one concise in-character NPC dialogue response only. "
|
||||
"Do not include JSON, tags, markdown, or tool names."
|
||||
),
|
||||
},
|
||||
]
|
||||
data = await state.kobold_client.chat(repair_messages, [])
|
||||
choices = data.get("choices") or [{}]
|
||||
choice = choices[0] or {}
|
||||
message = choice.get("message") or {}
|
||||
repaired = str(message.get("content") or "").strip()
|
||||
if _looks_like_pseudo_tool_content(repaired):
|
||||
return "I understand the matter, but I will not move until the terms are made plain."
|
||||
return repaired
|
||||
|
||||
|
||||
DASHBOARD_HTML = """
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>LocalDiplomacy Agent</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Segoe UI, Roboto, Arial, sans-serif;
|
||||
background: #111416;
|
||||
color: #eef2f3;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: #111416;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 18px 22px;
|
||||
background: #171c1f;
|
||||
border-bottom: 1px solid #2c353a;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 650;
|
||||
}
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 380px) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
padding: 18px;
|
||||
}
|
||||
section {
|
||||
background: #171c1f;
|
||||
border: 1px solid #2c353a;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 15px;
|
||||
color: #b9c6cc;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin: 12px 0 6px;
|
||||
color: #b9c6cc;
|
||||
font-size: 13px;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #3a464d;
|
||||
border-radius: 6px;
|
||||
background: #0f1214;
|
||||
color: #eef2f3;
|
||||
padding: 9px 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
button {
|
||||
border: 1px solid #50616a;
|
||||
border-radius: 6px;
|
||||
background: #26323a;
|
||||
color: #eef2f3;
|
||||
padding: 9px 12px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.primary {
|
||||
background: #2f6f73;
|
||||
border-color: #3c8d91;
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.status {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid #263035;
|
||||
padding-bottom: 7px;
|
||||
}
|
||||
.value {
|
||||
color: #ffffff;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
}
|
||||
.ok {
|
||||
color: #8fe19a;
|
||||
}
|
||||
.bad {
|
||||
color: #ff9c8f;
|
||||
}
|
||||
.logs {
|
||||
height: calc(100vh - 176px);
|
||||
min-height: 420px;
|
||||
overflow: auto;
|
||||
background: #0f1214;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #263035;
|
||||
padding: 10px;
|
||||
font-family: Consolas, Cascadia Mono, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.42;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.logline {
|
||||
margin: 0 0 5px;
|
||||
}
|
||||
.INFO { color: #b8d7ff; }
|
||||
.GAME { color: #d8c28c; }
|
||||
.AGENT { color: #9fe1c0; }
|
||||
.ACTION { color: #c8a7ff; }
|
||||
.WARNING { color: #ffd28d; }
|
||||
.ERROR { color: #ff9c8f; }
|
||||
@media (max-width: 820px) {
|
||||
main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.logs {
|
||||
height: 480px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>LocalDiplomacy Agent</h1>
|
||||
<div id="headline">Loading...</div>
|
||||
</header>
|
||||
<main>
|
||||
<div>
|
||||
<section>
|
||||
<h2>Status</h2>
|
||||
<div class="status">
|
||||
<div class="row"><span>Agent</span><span class="value" id="agentStatus">...</span></div>
|
||||
<div class="row"><span>KoboldCpp</span><span class="value" id="koboldStatus">...</span></div>
|
||||
<div class="row"><span>Memory</span><span class="value" id="memoryStatus">...</span></div>
|
||||
<div class="row"><span>Last latency</span><span class="value" id="latency">...</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<section style="margin-top:18px">
|
||||
<h2>KoboldCpp API</h2>
|
||||
<label for="baseUrl">API address</label>
|
||||
<input id="baseUrl" placeholder="http://127.0.0.1:5001">
|
||||
<label for="model">Model id</label>
|
||||
<input id="model" placeholder="local-model">
|
||||
<label for="timeout">Timeout seconds</label>
|
||||
<input id="timeout" type="number" min="1" step="1">
|
||||
<div class="buttons">
|
||||
<button class="primary" onclick="saveSettings()">Save</button>
|
||||
<button onclick="pingKobold()">Ping</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<section>
|
||||
<h2>Live Logs</h2>
|
||||
<div class="logs" id="logs"></div>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
let loadedConfig = false;
|
||||
|
||||
async function refresh() {
|
||||
const response = await fetch('/api/dashboard');
|
||||
const data = await response.json();
|
||||
const agentOk = data.health.status === 'ok';
|
||||
const koboldOk = data.health.koboldcpp === 'reachable';
|
||||
document.getElementById('headline').textContent = agentOk ? 'Ready for Bannerlord' : 'Needs attention';
|
||||
document.getElementById('headline').className = agentOk ? 'ok' : 'bad';
|
||||
document.getElementById('agentStatus').textContent = data.health.status;
|
||||
document.getElementById('agentStatus').className = 'value ' + (agentOk ? 'ok' : 'bad');
|
||||
document.getElementById('koboldStatus').textContent = data.health.koboldcpp;
|
||||
document.getElementById('koboldStatus').className = 'value ' + (koboldOk ? 'ok' : 'bad');
|
||||
document.getElementById('memoryStatus').textContent = data.health.memory;
|
||||
document.getElementById('latency').textContent = data.debug.last_koboldcpp_latency_ms === null ? 'none' : data.debug.last_koboldcpp_latency_ms + ' ms';
|
||||
if (!loadedConfig) {
|
||||
document.getElementById('baseUrl').value = data.koboldcpp.base_url;
|
||||
document.getElementById('model').value = data.koboldcpp.model;
|
||||
document.getElementById('timeout').value = data.koboldcpp.timeout_seconds;
|
||||
loadedConfig = true;
|
||||
}
|
||||
const logs = document.getElementById('logs');
|
||||
logs.innerHTML = data.logs.map(line => {
|
||||
const text = `[${line.time}] ${line.level.padEnd(7)} ${line.message}`;
|
||||
return `<div class="logline ${line.level}">${escapeHtml(text)}</div>`;
|
||||
}).join('');
|
||||
logs.scrollTop = logs.scrollHeight;
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const payload = {
|
||||
base_url: document.getElementById('baseUrl').value,
|
||||
model: document.getElementById('model').value,
|
||||
timeout_seconds: Number(document.getElementById('timeout').value),
|
||||
autostart: false
|
||||
};
|
||||
const response = await fetch('/api/koboldcpp', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
alert(data.error || 'Failed to save KoboldCpp settings.');
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function pingKobold() {
|
||||
await fetch('/api/koboldcpp/ping', {method: 'POST'});
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return value.replace(/[&<>"']/g, ch => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 2000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
CONFIG_PATH = Path("config.yaml")
|
||||
|
||||
|
||||
class ServerConfig(BaseModel):
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 8766
|
||||
|
||||
|
||||
class KoboldCppConfig(BaseModel):
|
||||
autostart: bool = True
|
||||
executable_path: str = "./koboldcpp.exe"
|
||||
model_path: str = "./model.gguf"
|
||||
base_url: str = "http://127.0.0.1:5001"
|
||||
chat_path: str = "/v1/chat/completions"
|
||||
model: str = "local-model"
|
||||
port: int = 5001
|
||||
context_size: int = 8192
|
||||
extra_args: list[str] = Field(default_factory=lambda: ["--jinja", "--jinjatools"])
|
||||
startup_timeout_seconds: int = 180
|
||||
timeout_seconds: int = 120
|
||||
tool_mode: str = "openai_tools"
|
||||
json_repair_retry: bool = True
|
||||
|
||||
|
||||
class MemoryConfig(BaseModel):
|
||||
provider: str = "disabled"
|
||||
vector_store: str = "qdrant"
|
||||
qdrant_host: str = "127.0.0.1"
|
||||
qdrant_port: int = 6333
|
||||
collection: str = "localdiplomacy_memories"
|
||||
embedder_provider: str = "ollama"
|
||||
embedder_model: str = "nomic-embed-text"
|
||||
|
||||
|
||||
class EventLogConfig(BaseModel):
|
||||
sqlite_path: str = "./data/localdiplomacy_events.sqlite3"
|
||||
|
||||
|
||||
class GenerationConfig(BaseModel):
|
||||
temperature: float = 0.7
|
||||
max_tokens: int = 800
|
||||
suppress_thinking: bool = False
|
||||
suppress_thinking_token: str = "/no_think"
|
||||
|
||||
|
||||
class AppConfig(BaseModel):
|
||||
server: ServerConfig = Field(default_factory=ServerConfig)
|
||||
koboldcpp: KoboldCppConfig = Field(default_factory=KoboldCppConfig)
|
||||
memory: MemoryConfig = Field(default_factory=MemoryConfig)
|
||||
event_log: EventLogConfig = Field(default_factory=EventLogConfig)
|
||||
generation: GenerationConfig = Field(default_factory=GenerationConfig)
|
||||
|
||||
|
||||
def load_config(path: str | Path = CONFIG_PATH) -> AppConfig:
|
||||
config_path = Path(path)
|
||||
if not config_path.exists():
|
||||
return AppConfig()
|
||||
|
||||
with config_path.open("r", encoding="utf-8") as handle:
|
||||
data: dict[str, Any] = yaml.safe_load(handle) or {}
|
||||
return AppConfig.model_validate(data)
|
||||
|
||||
|
||||
def save_config(config: AppConfig, path: str | Path = CONFIG_PATH) -> None:
|
||||
config_path = Path(path)
|
||||
data = config.model_dump(mode="json")
|
||||
with config_path.open("w", encoding="utf-8") as handle:
|
||||
yaml.safe_dump(data, handle, sort_keys=False)
|
||||
|
||||
|
||||
def resolve_runtime_path(path: str | Path, cwd: Path | None = None) -> Path:
|
||||
candidate = Path(path)
|
||||
if candidate.is_absolute():
|
||||
return candidate
|
||||
return (cwd or Path.cwd()) / candidate
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
JsonObject = dict[str, Any]
|
||||
|
||||
|
||||
class CharacterSummary(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
clan_id: str | None = None
|
||||
kingdom_id: str | None = None
|
||||
occupation: str | None = None
|
||||
traits: JsonObject | None = None
|
||||
relation_to_player: int | None = None
|
||||
|
||||
|
||||
class PlayerSummary(CharacterSummary):
|
||||
gold: int | None = None
|
||||
|
||||
|
||||
class SceneSummary(BaseModel):
|
||||
location_id: str | None = None
|
||||
conversation_state: str | None = None
|
||||
player_is_prisoner: bool = False
|
||||
npc_is_prisoner: bool = False
|
||||
|
||||
|
||||
class GameAction(BaseModel):
|
||||
action_id: str | None = None
|
||||
action_type: str
|
||||
actor_id: str
|
||||
target_id: str | None = None
|
||||
args: JsonObject = Field(default_factory=dict)
|
||||
confidence: float = Field(default=0.5, ge=0.0, le=1.0)
|
||||
reason: str = ""
|
||||
requires_player_confirmation: bool = True
|
||||
|
||||
|
||||
class ConversationRequest(BaseModel):
|
||||
campaign_id: str
|
||||
save_id: str
|
||||
turn_id: str
|
||||
player_message: str
|
||||
player: PlayerSummary
|
||||
npc: CharacterSummary
|
||||
scene: SceneSummary = Field(default_factory=SceneSummary)
|
||||
nearby_parties: list[JsonObject] = Field(default_factory=list)
|
||||
nearby_settlements: list[JsonObject] = Field(default_factory=list)
|
||||
kingdom_state: JsonObject = Field(default_factory=dict)
|
||||
recent_events: list[JsonObject] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MemoryWrite(BaseModel):
|
||||
scope: JsonObject
|
||||
text: str
|
||||
importance: int = Field(default=3, ge=1, le=10)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ConversationResponse(BaseModel):
|
||||
turn_id: str
|
||||
assistant_text: str
|
||||
mood: str | None = None
|
||||
tone: str | None = None
|
||||
visible_world_events: list[JsonObject] = Field(default_factory=list)
|
||||
game_actions: list[GameAction] = Field(default_factory=list)
|
||||
memory_writes: list[MemoryWrite] = Field(default_factory=list)
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WorldTickRequest(BaseModel):
|
||||
campaign_id: str
|
||||
save_id: str
|
||||
campaign_day: float
|
||||
diff_id: str
|
||||
changed_kingdoms: list[JsonObject] = Field(default_factory=list)
|
||||
changed_settlements: list[JsonObject] = Field(default_factory=list)
|
||||
wars: list[JsonObject] = Field(default_factory=list)
|
||||
peace_deals: list[JsonObject] = Field(default_factory=list)
|
||||
battles: list[JsonObject] = Field(default_factory=list)
|
||||
notable_relation_changes: list[JsonObject] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WorldTickResponse(BaseModel):
|
||||
diff_id: str
|
||||
accepted: bool = True
|
||||
created_events: list[JsonObject] = Field(default_factory=list)
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ActionStatus(str, Enum):
|
||||
executed = "executed"
|
||||
rejected = "rejected"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class ActionResultRequest(BaseModel):
|
||||
campaign_id: str
|
||||
turn_id: str
|
||||
action_id: str
|
||||
status: ActionStatus
|
||||
reason: str | None = None
|
||||
result_summary: str | None = None
|
||||
|
||||
|
||||
class ActionResultResponse(BaseModel):
|
||||
accepted: bool = True
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: Literal["ok", "degraded", "error"]
|
||||
agent_version: str
|
||||
koboldcpp: str
|
||||
memory: str
|
||||
event_log: str
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DebugStatusResponse(BaseModel):
|
||||
last_koboldcpp_latency_ms: int | None = None
|
||||
memory_count_estimate: int | None = None
|
||||
queued_action_count: int = 0
|
||||
recent_errors: list[str] = Field(default_factory=list)
|
||||
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class EventLog:
|
||||
def __init__(self, sqlite_path: str | Path):
|
||||
self.path = Path(sqlite_path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(self.path)
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
event_type TEXT NOT NULL,
|
||||
campaign_id TEXT,
|
||||
turn_id TEXT,
|
||||
payload_json TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
def append(
|
||||
self,
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
campaign_id: str | None = None,
|
||||
turn_id: str | None = None,
|
||||
) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO events (event_type, campaign_id, turn_id, payload_json)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(event_type, campaign_id, turn_id, json.dumps(payload, ensure_ascii=False)),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
def recent(self, limit: int = 10) -> list[dict[str, Any]]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT event_type, campaign_id, turn_id, payload_json, created_at
|
||||
FROM events
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"event_type": event_type,
|
||||
"campaign_id": campaign_id,
|
||||
"turn_id": turn_id,
|
||||
"payload": json.loads(payload_json),
|
||||
"created_at": created_at,
|
||||
}
|
||||
for event_type, campaign_id, turn_id, payload_json, created_at in rows
|
||||
]
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import AppConfig
|
||||
|
||||
|
||||
class KoboldCppClient:
|
||||
def __init__(self, config: AppConfig):
|
||||
self.config = config
|
||||
self.last_latency_ms: int | None = None
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"model": self.config.koboldcpp.model,
|
||||
"messages": self._prepare_messages(messages),
|
||||
"temperature": self.config.generation.temperature,
|
||||
"max_tokens": self.config.generation.max_tokens,
|
||||
}
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
payload["tool_choice"] = tool_choice or "auto"
|
||||
|
||||
url = f"{self.config.koboldcpp.base_url}{self.config.koboldcpp.chat_path}"
|
||||
started = time.perf_counter()
|
||||
async with httpx.AsyncClient(timeout=self.config.koboldcpp.timeout_seconds) as client:
|
||||
response = await client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
self.last_latency_ms = int((time.perf_counter() - started) * 1000)
|
||||
return response.json()
|
||||
|
||||
def _prepare_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
if not self.config.generation.suppress_thinking:
|
||||
return messages
|
||||
|
||||
token = self.config.generation.suppress_thinking_token.strip()
|
||||
if not token:
|
||||
return messages
|
||||
|
||||
prepared: list[dict[str, Any]] = []
|
||||
token_prefix = f"{token} "
|
||||
for message in messages:
|
||||
copied = dict(message)
|
||||
content = copied.get("content")
|
||||
role = copied.get("role")
|
||||
if role in {"system", "user"} and isinstance(content, str) and not content.lstrip().startswith(token):
|
||||
copied["content"] = f"{token_prefix}{content}"
|
||||
prepared.append(copied)
|
||||
return prepared
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import KoboldCppConfig, resolve_runtime_path
|
||||
|
||||
|
||||
class KoboldCppProcess:
|
||||
def __init__(self, config: KoboldCppConfig, cwd: Path | None = None):
|
||||
self.config = config
|
||||
self.cwd = cwd or Path.cwd()
|
||||
self.process: subprocess.Popen[str] | None = None
|
||||
self.last_error: str | None = None
|
||||
|
||||
def validate_runtime_files(self) -> list[str]:
|
||||
errors: list[str] = []
|
||||
executable = resolve_runtime_path(self.config.executable_path, self.cwd)
|
||||
model = resolve_runtime_path(self.config.model_path, self.cwd)
|
||||
if not executable.exists():
|
||||
errors.append(f"Missing KoboldCpp executable: {executable}")
|
||||
if not model.exists():
|
||||
errors.append(f"Missing KoboldCpp model: {model}")
|
||||
return errors
|
||||
|
||||
def is_reachable(self) -> bool:
|
||||
try:
|
||||
with httpx.Client(timeout=2) as client:
|
||||
response = client.get(f"{self.config.base_url}/v1/models")
|
||||
return response.status_code < 500
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
def ensure_started(self) -> None:
|
||||
if not self.config.autostart or self.is_reachable():
|
||||
return
|
||||
|
||||
errors = self.validate_runtime_files()
|
||||
if errors:
|
||||
self.last_error = "; ".join(errors)
|
||||
raise RuntimeError(self.last_error)
|
||||
|
||||
executable = resolve_runtime_path(self.config.executable_path, self.cwd)
|
||||
model = resolve_runtime_path(self.config.model_path, self.cwd)
|
||||
command = [
|
||||
str(executable),
|
||||
"--model",
|
||||
str(model),
|
||||
"--port",
|
||||
str(self.config.port),
|
||||
"--contextsize",
|
||||
str(self.config.context_size),
|
||||
*self.config.extra_args,
|
||||
]
|
||||
|
||||
self.process = subprocess.Popen(
|
||||
command,
|
||||
cwd=self.cwd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
)
|
||||
self._wait_until_ready()
|
||||
|
||||
def _wait_until_ready(self) -> None:
|
||||
deadline = time.monotonic() + self.config.startup_timeout_seconds
|
||||
while time.monotonic() < deadline:
|
||||
if self.process is not None and self.process.poll() is not None:
|
||||
self.last_error = f"KoboldCpp exited early with code {self.process.returncode}"
|
||||
raise RuntimeError(self.last_error)
|
||||
if self.is_reachable():
|
||||
return
|
||||
time.sleep(1)
|
||||
|
||||
self.last_error = "Timed out waiting for KoboldCpp API readiness"
|
||||
raise TimeoutError(self.last_error)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self.process is None or self.process.poll() is not None:
|
||||
return
|
||||
self.process.terminate()
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from .config import MemoryConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryStore:
|
||||
config: MemoryConfig
|
||||
_fallback: list[dict[str, Any]] = field(default_factory=list)
|
||||
_mem0: Any = None
|
||||
|
||||
def initialize(self) -> None:
|
||||
if self.config.provider.lower() != "mem0":
|
||||
return
|
||||
|
||||
try:
|
||||
from mem0 import Memory # type: ignore
|
||||
except ImportError:
|
||||
self._mem0 = None
|
||||
return
|
||||
|
||||
mem0_config = {
|
||||
"vector_store": {
|
||||
"provider": "qdrant",
|
||||
"config": {
|
||||
"host": self.config.qdrant_host,
|
||||
"port": self.config.qdrant_port,
|
||||
"collection_name": self.config.collection,
|
||||
},
|
||||
},
|
||||
"embedder": {
|
||||
"provider": self.config.embedder_provider,
|
||||
"config": {"model": self.config.embedder_model},
|
||||
},
|
||||
}
|
||||
self._mem0 = Memory.from_config(mem0_config)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if self.config.provider.lower() == "disabled":
|
||||
return "disabled"
|
||||
return "reachable" if self._mem0 is not None else "fallback"
|
||||
|
||||
def remember(self, text: str, metadata: dict[str, Any]) -> None:
|
||||
if self._mem0 is not None:
|
||||
user_id = metadata.get("campaign_id", "default")
|
||||
self._mem0.add(text, user_id=user_id, metadata=metadata)
|
||||
return
|
||||
self._fallback.append({"text": text, "metadata": metadata})
|
||||
|
||||
def search(self, query: str, metadata: dict[str, Any], limit: int = 5) -> list[dict[str, Any]]:
|
||||
if self._mem0 is not None:
|
||||
user_id = metadata.get("campaign_id", "default")
|
||||
result = self._mem0.search(query, user_id=user_id, limit=limit)
|
||||
return result if isinstance(result, list) else [result]
|
||||
|
||||
campaign_id = metadata.get("campaign_id")
|
||||
matches = [
|
||||
item
|
||||
for item in self._fallback
|
||||
if item["metadata"].get("campaign_id") == campaign_id
|
||||
and query.lower() in item["text"].lower()
|
||||
]
|
||||
return matches[:limit]
|
||||
|
||||
def count_estimate(self) -> int:
|
||||
return len(self._fallback)
|
||||
@@ -0,0 +1,286 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .app import action_result as agent_action_result
|
||||
from .app import conversation_respond, health as agent_health
|
||||
from .app import state, world_tick as agent_world_tick
|
||||
from .contracts import (
|
||||
ActionResultRequest,
|
||||
ActionStatus,
|
||||
CharacterSummary,
|
||||
ConversationRequest,
|
||||
PlayerSummary,
|
||||
SceneSummary,
|
||||
WorldTickRequest,
|
||||
)
|
||||
|
||||
|
||||
class MockKoboldClient:
|
||||
"""Deterministic local LLM stand-in for connector testing."""
|
||||
|
||||
last_latency_ms: int | None = 1
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if any(message.get("role") == "tool" for message in messages):
|
||||
return self._final("I will put that before the council. The proposal is prepared.")
|
||||
|
||||
user_packet = _extract_packet(messages[-1]["content"])
|
||||
player_message = user_packet["player_message"].lower()
|
||||
|
||||
if "peace" in player_message:
|
||||
return self._tool_call(
|
||||
"propose_game_action",
|
||||
{
|
||||
"action_type": "propose_peace",
|
||||
"actor_id": user_packet["npc"]["id"],
|
||||
"target_id": "kingdom_battania",
|
||||
"args": {"tribute_daily": 250},
|
||||
"confidence": 0.82,
|
||||
"reason": "The war is dragging on and peace would preserve strength.",
|
||||
"requires_player_confirmation": True,
|
||||
},
|
||||
)
|
||||
|
||||
if "remember" in player_message or "promise" in player_message:
|
||||
return self._tool_call(
|
||||
"remember_fact",
|
||||
{
|
||||
"campaign_id": user_packet["campaign_id"],
|
||||
"character_id": user_packet["npc"]["id"],
|
||||
"kingdom_id": user_packet["npc"].get("kingdom_id"),
|
||||
"category": "promise",
|
||||
"text": f"{user_packet['player']['name']} said: {user_packet['player_message']}",
|
||||
"importance": 6,
|
||||
"tags": ["mock_game"],
|
||||
},
|
||||
)
|
||||
|
||||
return self._final("I hear you. For now, I will answer with caution and keep my sword sheathed.")
|
||||
|
||||
@staticmethod
|
||||
def _final(content: str) -> dict[str, Any]:
|
||||
return {"choices": [{"message": {"role": "assistant", "content": content}}]}
|
||||
|
||||
@staticmethod
|
||||
def _tool_call(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": f"call_{uuid.uuid4().hex[:8]}",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"arguments": json.dumps(arguments),
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def build_conversation_request(message: str) -> ConversationRequest:
|
||||
return ConversationRequest(
|
||||
campaign_id="mock-campaign",
|
||||
save_id="mock-save",
|
||||
turn_id=uuid.uuid4().hex,
|
||||
player_message=message,
|
||||
player=PlayerSummary(
|
||||
id="player",
|
||||
name="Aldric",
|
||||
clan_id="clan_player",
|
||||
kingdom_id="kingdom_vlandia",
|
||||
gold=12000,
|
||||
),
|
||||
npc=CharacterSummary(
|
||||
id="lord_derthert",
|
||||
name="Derthert",
|
||||
clan_id="clan_dey_meroc",
|
||||
kingdom_id="kingdom_vlandia",
|
||||
occupation="Lord",
|
||||
relation_to_player=12,
|
||||
),
|
||||
scene=SceneSummary(
|
||||
location_id="town_sargot",
|
||||
conversation_state="lord_dialogue",
|
||||
),
|
||||
nearby_parties=[
|
||||
{
|
||||
"id": "party_battania_raiders",
|
||||
"name": "Battanian raiders",
|
||||
"faction_id": "kingdom_battania",
|
||||
"distance": 4.2,
|
||||
}
|
||||
],
|
||||
nearby_settlements=[
|
||||
{
|
||||
"id": "town_sargot",
|
||||
"name": "Sargot",
|
||||
"owner_kingdom_id": "kingdom_vlandia",
|
||||
"status": "own",
|
||||
}
|
||||
],
|
||||
kingdom_state={
|
||||
"wars": [{"enemy_kingdom_id": "kingdom_battania", "days": 42}],
|
||||
"war_fatigue": 0.67,
|
||||
},
|
||||
recent_events=[
|
||||
{
|
||||
"id": "event_border_raids",
|
||||
"type": "military",
|
||||
"summary": "Border raids have strained Vlandia and Battania.",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _extract_packet(content: str) -> dict[str, Any]:
|
||||
start = content.find("{")
|
||||
if start < 0:
|
||||
raise ValueError("No JSON game-state packet found in prompt content.")
|
||||
decoder = json.JSONDecoder()
|
||||
packet, _ = decoder.raw_decode(content[start:])
|
||||
if "player_message" not in packet:
|
||||
command_marker = "Command:"
|
||||
if command_marker in content:
|
||||
packet["player_message"] = content.split(command_marker, 1)[1].strip()
|
||||
packet.setdefault("player", {"id": packet.get("player_id", "player"), "name": "Player"})
|
||||
packet.setdefault("npc", {"id": packet.get("npc_id", "npc"), "name": packet.get("npc_name", "NPC")})
|
||||
return packet
|
||||
|
||||
|
||||
async def run_local_mock(command: str, args: argparse.Namespace) -> dict[str, Any]:
|
||||
state.kobold_client = MockKoboldClient() # type: ignore[assignment]
|
||||
|
||||
if command == "health":
|
||||
return agent_health().model_dump(mode="json")
|
||||
|
||||
if command == "say":
|
||||
response = await conversation_respond(build_conversation_request(args.message))
|
||||
return response.model_dump(mode="json")
|
||||
|
||||
if command == "tick":
|
||||
response = agent_world_tick(
|
||||
WorldTickRequest(
|
||||
campaign_id="mock-campaign",
|
||||
save_id="mock-save",
|
||||
campaign_day=args.day,
|
||||
diff_id=uuid.uuid4().hex,
|
||||
wars=[{"kingdom_a": "kingdom_vlandia", "kingdom_b": "kingdom_battania"}],
|
||||
)
|
||||
)
|
||||
return response.model_dump(mode="json")
|
||||
|
||||
if command == "action-result":
|
||||
response = agent_action_result(
|
||||
ActionResultRequest(
|
||||
campaign_id="mock-campaign",
|
||||
turn_id=args.turn_id,
|
||||
action_id=args.action_id,
|
||||
status=ActionStatus(args.status),
|
||||
reason=args.reason,
|
||||
result_summary=args.summary,
|
||||
)
|
||||
)
|
||||
return response.model_dump(mode="json")
|
||||
|
||||
raise ValueError(f"Unknown command: {command}")
|
||||
|
||||
|
||||
async def run_http(command: str, args: argparse.Namespace) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(base_url=args.url, timeout=args.timeout) as client:
|
||||
if command == "health":
|
||||
response = await client.get("/health")
|
||||
elif command == "say":
|
||||
response = await client.post(
|
||||
"/conversation/respond",
|
||||
json=build_conversation_request(args.message).model_dump(mode="json"),
|
||||
)
|
||||
elif command == "tick":
|
||||
response = await client.post(
|
||||
"/world/tick",
|
||||
json=WorldTickRequest(
|
||||
campaign_id="mock-campaign",
|
||||
save_id="mock-save",
|
||||
campaign_day=args.day,
|
||||
diff_id=uuid.uuid4().hex,
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
elif command == "action-result":
|
||||
response = await client.post(
|
||||
"/actions/result",
|
||||
json=ActionResultRequest(
|
||||
campaign_id="mock-campaign",
|
||||
turn_id=args.turn_id,
|
||||
action_id=args.action_id,
|
||||
status=ActionStatus(args.status),
|
||||
reason=args.reason,
|
||||
result_summary=args.summary,
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown command: {command}")
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Mock Bannerlord connector for LocalDiplomacy.Agent.")
|
||||
parser.add_argument("--url", default="http://127.0.0.1:8766", help="Agent URL for HTTP mode.")
|
||||
parser.add_argument("--timeout", type=float, default=120.0, help="HTTP timeout in seconds.")
|
||||
parser.add_argument("--mock-agent", action="store_true", help="Run against the in-process agent with a fake Kobold client.")
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
subparsers.add_parser("health")
|
||||
|
||||
say = subparsers.add_parser("say")
|
||||
say.add_argument("message")
|
||||
|
||||
tick = subparsers.add_parser("tick")
|
||||
tick.add_argument("--day", type=float, default=1.0)
|
||||
|
||||
action = subparsers.add_parser("action-result")
|
||||
action.add_argument("action_id")
|
||||
action.add_argument("--turn-id", default="mock-turn")
|
||||
action.add_argument("--status", choices=[status.value for status in ActionStatus], default="executed")
|
||||
action.add_argument("--reason")
|
||||
action.add_argument("--summary")
|
||||
return parser
|
||||
|
||||
|
||||
async def async_main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
if args.mock_agent:
|
||||
result = await run_local_mock(args.command, args)
|
||||
else:
|
||||
result = await run_http(args.command, args)
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> None:
|
||||
raise SystemExit(asyncio.run(async_main()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,440 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any, Callable
|
||||
|
||||
from .action_catalog import ACTION_TYPES
|
||||
from .contracts import GameAction, MemoryWrite
|
||||
from .event_log import EventLog
|
||||
from .memory import MemoryStore
|
||||
|
||||
|
||||
ToolHandler = Callable[[dict[str, Any]], dict[str, Any]]
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
def __init__(self, memory: MemoryStore, event_log: EventLog):
|
||||
self.memory = memory
|
||||
self.event_log = event_log
|
||||
self.queued_actions: list[GameAction] = []
|
||||
self.memory_writes: list[MemoryWrite] = []
|
||||
|
||||
def schemas(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
self._schema(
|
||||
"get_npc_profile",
|
||||
"Read the current known NPC personality, backstory, trust, speech pattern, romance, and relationship profile.",
|
||||
{
|
||||
"campaign_id": {"type": "string"},
|
||||
"character_id": {"type": "string"},
|
||||
},
|
||||
["campaign_id", "character_id"],
|
||||
),
|
||||
self._schema(
|
||||
"get_relation_summary",
|
||||
"Read known relationship history between two characters, clans, or kingdoms.",
|
||||
{
|
||||
"campaign_id": {"type": "string"},
|
||||
"source_id": {"type": "string"},
|
||||
"target_id": {"type": "string"},
|
||||
},
|
||||
["campaign_id", "source_id", "target_id"],
|
||||
),
|
||||
self._schema(
|
||||
"get_nearby_parties",
|
||||
"Read nearby parties supplied by the Bannerlord connector.",
|
||||
{
|
||||
"campaign_id": {"type": "string"},
|
||||
"reference_id": {"type": "string"},
|
||||
"limit": {"type": "integer", "default": 10},
|
||||
},
|
||||
["campaign_id", "reference_id"],
|
||||
),
|
||||
self._schema(
|
||||
"get_kingdom_status",
|
||||
"Read kingdom state, wars, alliances, treaties, war fatigue, war statistics, tributes, reparations, and diplomatic statements.",
|
||||
{
|
||||
"campaign_id": {"type": "string"},
|
||||
"kingdom_id": {"type": "string"},
|
||||
},
|
||||
["campaign_id", "kingdom_id"],
|
||||
),
|
||||
self._schema(
|
||||
"get_settlement_status",
|
||||
"Read settlement ownership, military status, workshop/trade context, visit history, and nearby parties.",
|
||||
{
|
||||
"campaign_id": {"type": "string"},
|
||||
"settlement_id": {"type": "string"},
|
||||
},
|
||||
["campaign_id", "settlement_id"],
|
||||
),
|
||||
self._schema(
|
||||
"get_military_info",
|
||||
"Read compact military information such as wars, battles, losses, sieges, friend locations, and party strength.",
|
||||
{
|
||||
"campaign_id": {"type": "string"},
|
||||
"scope_id": {"type": "string"},
|
||||
},
|
||||
["campaign_id", "scope_id"],
|
||||
),
|
||||
self._schema(
|
||||
"get_naval_info",
|
||||
"Read compact Naval DLC context such as ships, fleets, sea routes, and sea voyages when the connector provides it.",
|
||||
{
|
||||
"campaign_id": {"type": "string"},
|
||||
"scope_id": {"type": "string"},
|
||||
},
|
||||
["campaign_id", "scope_id"],
|
||||
),
|
||||
self._schema(
|
||||
"get_trade_info",
|
||||
"Read trading context, item exchange opportunities, workshops, economic effects, and treaty information.",
|
||||
{
|
||||
"campaign_id": {"type": "string"},
|
||||
"scope_id": {"type": "string"},
|
||||
},
|
||||
["campaign_id", "scope_id"],
|
||||
),
|
||||
self._schema(
|
||||
"get_romance_status",
|
||||
"Read romance, intimacy, marriage, cultural tradition, initiative, and degradation state for an NPC.",
|
||||
{
|
||||
"campaign_id": {"type": "string"},
|
||||
"character_id": {"type": "string"},
|
||||
"player_id": {"type": "string"},
|
||||
},
|
||||
["campaign_id", "character_id", "player_id"],
|
||||
),
|
||||
self._schema(
|
||||
"get_death_history",
|
||||
"Read death history eligibility and existing generated life history for a hero.",
|
||||
{
|
||||
"campaign_id": {"type": "string"},
|
||||
"character_id": {"type": "string"},
|
||||
},
|
||||
["campaign_id", "character_id"],
|
||||
),
|
||||
self._schema(
|
||||
"get_visit_history",
|
||||
"Read which settlements an NPC has visited or mentioned.",
|
||||
{
|
||||
"campaign_id": {"type": "string"},
|
||||
"character_id": {"type": "string"},
|
||||
},
|
||||
["campaign_id", "character_id"],
|
||||
),
|
||||
self._schema(
|
||||
"get_recruitment_opportunities",
|
||||
"Read possible recruitment, mercenary, vassalage, clan, and companion opportunities.",
|
||||
{
|
||||
"campaign_id": {"type": "string"},
|
||||
"scope_id": {"type": "string"},
|
||||
},
|
||||
["campaign_id", "scope_id"],
|
||||
),
|
||||
self._schema(
|
||||
"search_memory",
|
||||
"Search local long-term memory for relevant facts.",
|
||||
{
|
||||
"query": {"type": "string"},
|
||||
"campaign_id": {"type": "string"},
|
||||
"character_id": {"type": "string"},
|
||||
"kingdom_id": {"type": "string"},
|
||||
"limit": {"type": "integer", "default": 5},
|
||||
},
|
||||
["query", "campaign_id"],
|
||||
),
|
||||
self._schema(
|
||||
"remember_fact",
|
||||
"Store an atomic long-term memory fact: conversations, secrets, known info, relationships, visits, events, promises, or personality changes.",
|
||||
{
|
||||
"text": {"type": "string"},
|
||||
"campaign_id": {"type": "string"},
|
||||
"character_id": {"type": "string"},
|
||||
"kingdom_id": {"type": "string"},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"conversation",
|
||||
"secret",
|
||||
"known_info",
|
||||
"relationship",
|
||||
"event",
|
||||
"promise",
|
||||
"personality",
|
||||
"backstory",
|
||||
"speech_pattern",
|
||||
"romance",
|
||||
"death_history",
|
||||
"visit",
|
||||
"mentioned_entity",
|
||||
],
|
||||
},
|
||||
"importance": {"type": "integer", "minimum": 1, "maximum": 10, "default": 3},
|
||||
"tags": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
["text", "campaign_id"],
|
||||
),
|
||||
self._schema(
|
||||
"analyze_lie",
|
||||
"Analyze whether a player statement conflicts with known memories or live state; returns a recommendation, not a game mutation.",
|
||||
{
|
||||
"campaign_id": {"type": "string"},
|
||||
"speaker_id": {"type": "string"},
|
||||
"listener_id": {"type": "string"},
|
||||
"claim": {"type": "string"},
|
||||
},
|
||||
["campaign_id", "speaker_id", "listener_id", "claim"],
|
||||
),
|
||||
self._schema(
|
||||
"propose_game_action",
|
||||
"Queue any AIInfluence-style Bannerlord feature/action proposal for mod-side validation. This never mutates the game directly.",
|
||||
{
|
||||
"action_type": {"type": "string", "enum": sorted(ACTION_TYPES)},
|
||||
"actor_id": {"type": "string"},
|
||||
"target_id": {"type": "string"},
|
||||
"args": {"type": "object"},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"reason": {"type": "string"},
|
||||
"requires_player_confirmation": {"type": "boolean"},
|
||||
},
|
||||
["action_type", "actor_id", "reason"],
|
||||
),
|
||||
self._schema(
|
||||
"create_world_event",
|
||||
"Queue a dynamic world event proposal: political, military, economic, social, mysterious, or rumor.",
|
||||
{
|
||||
"campaign_id": {"type": "string"},
|
||||
"event_type": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"summary": {"type": "string"},
|
||||
"involved_ids": {"type": "array", "items": {"type": "string"}},
|
||||
"importance": {"type": "integer", "minimum": 1, "maximum": 10},
|
||||
"expires_after_days": {"type": "number"},
|
||||
},
|
||||
["campaign_id", "event_type", "title", "summary"],
|
||||
),
|
||||
self._schema(
|
||||
"update_world_event",
|
||||
"Queue an update/development/spread change for an existing dynamic world event.",
|
||||
{
|
||||
"campaign_id": {"type": "string"},
|
||||
"event_id": {"type": "string"},
|
||||
"summary": {"type": "string"},
|
||||
"spread_to_ids": {"type": "array", "items": {"type": "string"}},
|
||||
"importance_delta": {"type": "integer"},
|
||||
},
|
||||
["campaign_id", "event_id", "summary"],
|
||||
),
|
||||
self._schema(
|
||||
"publish_diplomatic_statement",
|
||||
"Queue a ruler or kingdom diplomatic statement for mod-side display and validation.",
|
||||
{
|
||||
"campaign_id": {"type": "string"},
|
||||
"kingdom_id": {"type": "string"},
|
||||
"speaker_id": {"type": "string"},
|
||||
"statement_text": {"type": "string"},
|
||||
"related_event_id": {"type": "string"},
|
||||
"proposed_action": {"type": "object"},
|
||||
},
|
||||
["campaign_id", "kingdom_id", "speaker_id", "statement_text"],
|
||||
),
|
||||
self._schema(
|
||||
"record_war_statistics",
|
||||
"Record AI-derived war statistics summary for later diplomacy reasoning.",
|
||||
{
|
||||
"campaign_id": {"type": "string"},
|
||||
"war_id": {"type": "string"},
|
||||
"summary": {"type": "string"},
|
||||
"stats": {"type": "object"},
|
||||
},
|
||||
["campaign_id", "war_id", "summary"],
|
||||
),
|
||||
self._schema(
|
||||
"record_death_history",
|
||||
"Record a generated death/life history proposal for a hero after sufficient interactions.",
|
||||
{
|
||||
"campaign_id": {"type": "string"},
|
||||
"character_id": {"type": "string"},
|
||||
"history_text": {"type": "string"},
|
||||
"interaction_count": {"type": "integer"},
|
||||
},
|
||||
["campaign_id", "character_id", "history_text"],
|
||||
),
|
||||
self._schema(
|
||||
"list_recent_events",
|
||||
"List recent LocalDiplomacy audit/world events.",
|
||||
{"limit": {"type": "integer", "default": 5}},
|
||||
[],
|
||||
),
|
||||
]
|
||||
|
||||
def schema_by_name(self, name: str) -> dict[str, Any] | None:
|
||||
for schema in self.schemas():
|
||||
function = schema.get("function") or {}
|
||||
if function.get("name") == name:
|
||||
return schema
|
||||
return None
|
||||
|
||||
def dispatch(self, name: str, arguments_json: str) -> dict[str, Any]:
|
||||
arguments = json.loads(arguments_json or "{}")
|
||||
handlers: dict[str, ToolHandler] = {
|
||||
"get_npc_profile": self._read_context,
|
||||
"get_relation_summary": self._read_context,
|
||||
"get_nearby_parties": self._read_context,
|
||||
"get_kingdom_status": self._read_context,
|
||||
"get_settlement_status": self._read_context,
|
||||
"get_military_info": self._read_context,
|
||||
"get_naval_info": self._read_context,
|
||||
"get_trade_info": self._read_context,
|
||||
"get_romance_status": self._read_context,
|
||||
"get_death_history": self._read_context,
|
||||
"get_visit_history": self._read_context,
|
||||
"get_recruitment_opportunities": self._read_context,
|
||||
"search_memory": self._search_memory,
|
||||
"remember_fact": self._remember_fact,
|
||||
"analyze_lie": self._analyze_lie,
|
||||
"propose_game_action": self._propose_game_action,
|
||||
"create_world_event": self._create_world_event,
|
||||
"update_world_event": self._update_world_event,
|
||||
"publish_diplomatic_statement": self._publish_diplomatic_statement,
|
||||
"record_war_statistics": self._record_war_statistics,
|
||||
"record_death_history": self._record_death_history,
|
||||
"list_recent_events": self._list_recent_events,
|
||||
}
|
||||
if name not in handlers:
|
||||
return {"ok": False, "error": f"Unknown tool: {name}"}
|
||||
return handlers[name](arguments)
|
||||
|
||||
@staticmethod
|
||||
def _schema(
|
||||
name: str,
|
||||
description: str,
|
||||
properties: dict[str, Any],
|
||||
required: list[str],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
"additionalProperties": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def _search_memory(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
metadata = {
|
||||
"campaign_id": args.get("campaign_id"),
|
||||
"character_id": args.get("character_id"),
|
||||
"kingdom_id": args.get("kingdom_id"),
|
||||
}
|
||||
memories = self.memory.search(args["query"], metadata, int(args.get("limit", 5)))
|
||||
return {"ok": True, "memories": memories}
|
||||
|
||||
def _read_context(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": True,
|
||||
"source": "bannerlord_connector",
|
||||
"available": False,
|
||||
"message": "This read tool is declared for the Bannerlord connector; live data must be supplied by current request context or a future connector cache.",
|
||||
"request": args,
|
||||
}
|
||||
|
||||
def _remember_fact(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
scope = {
|
||||
"campaign_id": args.get("campaign_id"),
|
||||
"character_id": args.get("character_id"),
|
||||
"kingdom_id": args.get("kingdom_id"),
|
||||
}
|
||||
write = MemoryWrite(
|
||||
scope=scope,
|
||||
text=args["text"],
|
||||
importance=int(args.get("importance", 3)),
|
||||
tags=args.get("tags") or [],
|
||||
)
|
||||
metadata = {
|
||||
**write.scope,
|
||||
"tags": write.tags,
|
||||
"importance": write.importance,
|
||||
"category": args.get("category"),
|
||||
}
|
||||
self.memory.remember(write.text, metadata)
|
||||
self.memory_writes.append(write)
|
||||
return {"ok": True}
|
||||
|
||||
def _analyze_lie(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
memories = self.memory.search(
|
||||
args["claim"],
|
||||
{"campaign_id": args.get("campaign_id"), "character_id": args.get("listener_id")},
|
||||
5,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"likely_lie": False,
|
||||
"confidence": 0.25,
|
||||
"evidence": memories,
|
||||
"message": "No contradiction engine is implemented yet; use retrieved evidence conservatively.",
|
||||
}
|
||||
|
||||
def _propose_game_action(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
action = GameAction(
|
||||
action_id=str(uuid.uuid4()),
|
||||
action_type=args["action_type"],
|
||||
actor_id=args["actor_id"],
|
||||
target_id=args.get("target_id"),
|
||||
args=args.get("args") or {},
|
||||
confidence=float(args.get("confidence", 0.5)),
|
||||
reason=args["reason"],
|
||||
requires_player_confirmation=bool(args.get("requires_player_confirmation", True)),
|
||||
)
|
||||
self.queued_actions.append(action)
|
||||
return {"ok": True, "action_id": action.action_id}
|
||||
|
||||
def _create_world_event(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
action = GameAction(
|
||||
action_id=str(uuid.uuid4()),
|
||||
action_type="create_world_event",
|
||||
actor_id=args.get("campaign_id", "system"),
|
||||
target_id=args.get("campaign_id"),
|
||||
args=args,
|
||||
confidence=0.8,
|
||||
reason=args.get("summary", "AI-created dynamic world event."),
|
||||
requires_player_confirmation=False,
|
||||
)
|
||||
self.queued_actions.append(action)
|
||||
return {"ok": True, "action_id": action.action_id}
|
||||
|
||||
def _update_world_event(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
return self._queue_system_action("update_world_event", args)
|
||||
|
||||
def _publish_diplomatic_statement(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
return self._queue_system_action("publish_diplomatic_statement", args)
|
||||
|
||||
def _record_war_statistics(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
return self._queue_system_action("record_war_statistics", args)
|
||||
|
||||
def _record_death_history(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
return self._queue_system_action("record_death_history", args)
|
||||
|
||||
def _queue_system_action(self, action_type: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
action = GameAction(
|
||||
action_id=str(uuid.uuid4()),
|
||||
action_type=action_type,
|
||||
actor_id=args.get("speaker_id") or args.get("character_id") or args.get("kingdom_id") or args.get("campaign_id", "system"),
|
||||
target_id=args.get("event_id") or args.get("character_id") or args.get("kingdom_id"),
|
||||
args=args,
|
||||
confidence=0.75,
|
||||
reason=args.get("summary") or args.get("statement_text") or f"AI queued {action_type}.",
|
||||
requires_player_confirmation=False,
|
||||
)
|
||||
self.queued_actions.append(action)
|
||||
return {"ok": True, "action_id": action.action_id}
|
||||
|
||||
def _list_recent_events(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"ok": True, "events": self.event_log.recent(int(args.get("limit", 5)))}
|
||||
@@ -0,0 +1,25 @@
|
||||
[project]
|
||||
name = "localdiplomacy-agent"
|
||||
version = "0.1.0"
|
||||
description = "LocalDiplomacy external AI agent for Bannerlord."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"httpx>=0.27.0",
|
||||
"pydantic>=2.8.0",
|
||||
"pyyaml>=6.0.0",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
memory = [
|
||||
"mem0ai>=0.1.0",
|
||||
"qdrant-client>=1.10.0",
|
||||
]
|
||||
test = [
|
||||
"pytest>=8.2.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
@@ -0,0 +1,24 @@
|
||||
from localdiplomacy_agent.contracts import ConversationRequest, GameAction
|
||||
|
||||
|
||||
def test_game_action_defaults():
|
||||
action = GameAction(action_type="release", actor_id="npc_1")
|
||||
|
||||
assert action.confidence == 0.5
|
||||
assert action.requires_player_confirmation is True
|
||||
|
||||
|
||||
def test_conversation_request_minimal_shape():
|
||||
request = ConversationRequest.model_validate(
|
||||
{
|
||||
"campaign_id": "campaign",
|
||||
"save_id": "save",
|
||||
"turn_id": "turn",
|
||||
"player_message": "Hello.",
|
||||
"player": {"id": "player", "name": "Player"},
|
||||
"npc": {"id": "npc", "name": "Lord"},
|
||||
}
|
||||
)
|
||||
|
||||
assert request.scene.player_is_prisoner is False
|
||||
assert request.nearby_parties == []
|
||||
@@ -0,0 +1,47 @@
|
||||
from localdiplomacy_agent.action_catalog import ACTION_TYPES
|
||||
from localdiplomacy_agent.config import MemoryConfig
|
||||
from localdiplomacy_agent.event_log import EventLog
|
||||
from localdiplomacy_agent.memory import MemoryStore
|
||||
from localdiplomacy_agent.tools import ToolRegistry
|
||||
|
||||
|
||||
def test_agent_exposes_aiinfluence_feature_tools(tmp_path):
|
||||
registry = ToolRegistry(MemoryStore(MemoryConfig()), EventLog(tmp_path / "events.sqlite3"))
|
||||
tool_names = {schema["function"]["name"] for schema in registry.schemas()}
|
||||
|
||||
assert "get_npc_profile" in tool_names
|
||||
assert "get_naval_info" in tool_names
|
||||
assert "get_trade_info" in tool_names
|
||||
assert "get_romance_status" in tool_names
|
||||
assert "get_death_history" in tool_names
|
||||
assert "create_world_event" in tool_names
|
||||
assert "publish_diplomatic_statement" in tool_names
|
||||
assert "record_war_statistics" in tool_names
|
||||
assert "propose_game_action" in tool_names
|
||||
|
||||
|
||||
def test_action_catalog_covers_reference_feature_actions():
|
||||
expected = {
|
||||
"generate_personality_profile",
|
||||
"record_lie_detection",
|
||||
"create_world_event",
|
||||
"spread_world_event",
|
||||
"publish_diplomatic_statement",
|
||||
"propose_trade_agreement",
|
||||
"demand_territory",
|
||||
"demand_tribute",
|
||||
"demand_reparations",
|
||||
"expel_clan",
|
||||
"pardon_clan",
|
||||
"propose_intimacy",
|
||||
"propose_settlement_combat",
|
||||
"generate_death_history",
|
||||
"create_party",
|
||||
"save_task_chain",
|
||||
"sell_workshop",
|
||||
"exchange_items",
|
||||
"record_recruitment_opportunity",
|
||||
"apply_economic_effect",
|
||||
}
|
||||
|
||||
assert expected.issubset(ACTION_TYPES)
|
||||
@@ -0,0 +1,21 @@
|
||||
from localdiplomacy_agent.config import AppConfig, GenerationConfig
|
||||
from localdiplomacy_agent.koboldcpp_client import KoboldCppClient
|
||||
|
||||
|
||||
def test_suppress_thinking_prefixes_text_messages_once():
|
||||
client = KoboldCppClient(
|
||||
AppConfig(generation=GenerationConfig(suppress_thinking=True))
|
||||
)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "Use tools when needed."},
|
||||
{"role": "user", "content": "/no_think Already prefixed."},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "{}"},
|
||||
]
|
||||
|
||||
prepared = client._prepare_messages(messages)
|
||||
|
||||
assert prepared[0]["content"] == "/no_think Use tools when needed."
|
||||
assert prepared[1]["content"] == "/no_think Already prefixed."
|
||||
assert prepared[2]["content"] == "{}"
|
||||
assert messages[0]["content"] == "Use tools when needed."
|
||||
@@ -0,0 +1,13 @@
|
||||
from pathlib import Path
|
||||
|
||||
from localdiplomacy_agent.config import KoboldCppConfig
|
||||
from localdiplomacy_agent.koboldcpp_process import KoboldCppProcess
|
||||
|
||||
|
||||
def test_runtime_file_validation_reports_missing_files(tmp_path: Path):
|
||||
process = KoboldCppProcess(KoboldCppConfig(), cwd=tmp_path)
|
||||
|
||||
errors = process.validate_runtime_files()
|
||||
|
||||
assert any("koboldcpp.exe" in error for error in errors)
|
||||
assert any("model.gguf" in error for error in errors)
|
||||
@@ -0,0 +1,36 @@
|
||||
import pytest
|
||||
|
||||
from localdiplomacy_agent.mock_game import build_parser, run_local_mock
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mock_game_say_returns_peace_action():
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["--mock-agent", "say", "Can we make peace with Battania?"])
|
||||
|
||||
result = await run_local_mock(args.command, args)
|
||||
|
||||
assert result["assistant_text"]
|
||||
assert result["game_actions"][0]["action_type"] == "propose_peace"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mock_game_remember_writes_memory():
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["--mock-agent", "say", "Remember my promise to defend Sargot."])
|
||||
|
||||
result = await run_local_mock(args.command, args)
|
||||
|
||||
assert result["assistant_text"]
|
||||
assert result["memory_writes"][0]["text"].endswith("Remember my promise to defend Sargot.")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mock_game_tick_uses_world_tick_contract():
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["--mock-agent", "tick", "--day", "12.5"])
|
||||
|
||||
result = await run_local_mock(args.command, args)
|
||||
|
||||
assert result["accepted"] is True
|
||||
assert result["diff_id"]
|
||||
@@ -0,0 +1,7 @@
|
||||
from localdiplomacy_agent.app import _looks_like_pseudo_tool_content
|
||||
|
||||
|
||||
def test_detects_pseudo_tool_content():
|
||||
assert _looks_like_pseudo_tool_content('[{"name": "mock_diplomacy", "arguments": {}}]<end_of_turn>')
|
||||
assert _looks_like_pseudo_tool_content('{"name": "tool", "arguments": {}}')
|
||||
assert not _looks_like_pseudo_tool_content("We should speak of peace carefully.")
|
||||
@@ -0,0 +1,30 @@
|
||||
from localdiplomacy_agent.app import _select_tool_schemas
|
||||
from localdiplomacy_agent.contracts import ConversationRequest
|
||||
from localdiplomacy_agent.event_log import EventLog
|
||||
from localdiplomacy_agent.memory import MemoryStore
|
||||
from localdiplomacy_agent.config import MemoryConfig
|
||||
from localdiplomacy_agent.tools import ToolRegistry
|
||||
|
||||
|
||||
def test_action_prompt_gets_focused_action_tools(tmp_path):
|
||||
registry = ToolRegistry(
|
||||
MemoryStore(MemoryConfig()),
|
||||
EventLog(tmp_path / "events.sqlite3"),
|
||||
)
|
||||
request = ConversationRequest.model_validate(
|
||||
{
|
||||
"campaign_id": "campaign",
|
||||
"save_id": "save",
|
||||
"turn_id": "turn",
|
||||
"player_message": "Derthert should propose peace with Battania.",
|
||||
"player": {"id": "player", "name": "Player"},
|
||||
"npc": {"id": "lord_derthert", "name": "Derthert"},
|
||||
}
|
||||
)
|
||||
|
||||
schemas = _select_tool_schemas(request, registry)
|
||||
names = [schema["function"]["name"] for schema in schemas]
|
||||
action_type = schemas[0]["function"]["parameters"]["properties"]["action_type"]
|
||||
|
||||
assert names == ["propose_game_action"]
|
||||
assert action_type["enum"] == ["accept_peace", "propose_peace", "reject_peace"]
|
||||
Generated
+1342
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LocalDiplomacy.Contracts;
|
||||
using LocalDiplomacy.Diagnostics;
|
||||
using LocalDiplomacy.Settings;
|
||||
|
||||
namespace LocalDiplomacy.Agent;
|
||||
|
||||
public sealed class AgentClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
public AgentClient(LocalDiplomacySettings settings)
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(LocalDiplomacySettings.LocalAgentUrl),
|
||||
Timeout = TimeSpan.FromSeconds(settings.TimeoutSeconds)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ConversationResponse?> RespondAsync(
|
||||
ConversationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
LocalDiplomacyLog.Info($"POST /conversation/respond payload: {json}");
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using HttpResponseMessage response = await _httpClient.PostAsync(
|
||||
"/conversation/respond",
|
||||
content,
|
||||
cancellationToken);
|
||||
string responseJson = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
LocalDiplomacyLog.Error($"Agent HTTP {(int)response.StatusCode} {response.ReasonPhrase}: {responseJson}");
|
||||
return null;
|
||||
}
|
||||
|
||||
LocalDiplomacyLog.Info($"Agent HTTP {(int)response.StatusCode}: {responseJson}");
|
||||
return JsonSerializer.Deserialize<ConversationResponse>(responseJson, SerializerOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using LocalDiplomacy.Agent;
|
||||
using LocalDiplomacy.Contracts;
|
||||
using LocalDiplomacy.Diagnostics;
|
||||
using LocalDiplomacy.Diplomacy;
|
||||
using LocalDiplomacy.Settings;
|
||||
using TaleWorlds.CampaignSystem;
|
||||
using TaleWorlds.CampaignSystem.Party;
|
||||
using TaleWorlds.Library;
|
||||
|
||||
namespace LocalDiplomacy.Campaign;
|
||||
|
||||
public sealed class LocalDiplomacyCampaignBehavior : CampaignBehaviorBase
|
||||
{
|
||||
private readonly LocalDiplomacySettings _settings = new();
|
||||
private AgentClient? _agentClient;
|
||||
private ActionValidator? _actionValidator;
|
||||
private ActionExecutor? _actionExecutor;
|
||||
|
||||
public override void RegisterEvents()
|
||||
{
|
||||
LocalDiplomacyLog.Info("RegisterEvents.");
|
||||
CampaignEvents.OnSessionLaunchedEvent.AddNonSerializedListener(this, OnSessionLaunched);
|
||||
CampaignEvents.HourlyTickEvent.AddNonSerializedListener(this, OnHourlyTick);
|
||||
}
|
||||
|
||||
public override void SyncData(IDataStore dataStore)
|
||||
{
|
||||
}
|
||||
|
||||
private void OnSessionLaunched(CampaignGameStarter starter)
|
||||
{
|
||||
LocalDiplomacyLog.Info("OnSessionLaunched.");
|
||||
_agentClient = new AgentClient(_settings);
|
||||
_actionValidator = new ActionValidator(_settings);
|
||||
_actionExecutor = new ActionExecutor();
|
||||
AddDebugDialogueLines(starter);
|
||||
}
|
||||
|
||||
private void OnHourlyTick()
|
||||
{
|
||||
// v1 scaffold: world diff publishing will be wired once action execution is stable.
|
||||
}
|
||||
|
||||
private void AddDebugDialogueLines(CampaignGameStarter starter)
|
||||
{
|
||||
string[] inputTokens =
|
||||
{
|
||||
"lord_pretalk",
|
||||
"lord_talk",
|
||||
"lord_talk_speak_diplomacy_2",
|
||||
"hero_main_options",
|
||||
"hero_talk",
|
||||
"start"
|
||||
};
|
||||
|
||||
foreach (string inputToken in inputTokens)
|
||||
{
|
||||
AddDebugDialogueLine(starter, $"ld_ask_news_from_{inputToken}", inputToken);
|
||||
}
|
||||
|
||||
LocalDiplomacyLog.Info($"Registered {inputTokens.Length} LocalDiplomacy dialogue entry points.");
|
||||
}
|
||||
|
||||
private void AddDebugDialogueLine(CampaignGameStarter starter, string id, string inputToken)
|
||||
{
|
||||
starter.AddPlayerLine(
|
||||
id,
|
||||
inputToken,
|
||||
"lord_pretalk",
|
||||
"[LocalDiplomacy] Ask what news they have.",
|
||||
CanUseLocalDiplomacyDialogue,
|
||||
OnLocalDiplomacyDebugDialogueSelected,
|
||||
100,
|
||||
null,
|
||||
null);
|
||||
LocalDiplomacyLog.Info($"Registered dialogue line {id} on token {inputToken}.");
|
||||
}
|
||||
|
||||
private bool CanUseLocalDiplomacyDialogue()
|
||||
{
|
||||
Hero? npc = Hero.OneToOneConversationHero;
|
||||
bool canUse = _agentClient is not null && npc is not null && npc != Hero.MainHero;
|
||||
if (canUse)
|
||||
{
|
||||
LocalDiplomacyLog.Info($"Dialogue option condition true for {npc?.StringId ?? "unknown"}.");
|
||||
}
|
||||
|
||||
return canUse;
|
||||
}
|
||||
|
||||
private async void OnLocalDiplomacyDebugDialogueSelected()
|
||||
{
|
||||
LocalDiplomacyLog.Info("LocalDiplomacy debug dialogue selected.");
|
||||
await SendDebugConversationAsync("Greetings. What news from the border?");
|
||||
}
|
||||
|
||||
private async Task SendDebugConversationAsync(string playerMessage)
|
||||
{
|
||||
if (_agentClient is null)
|
||||
{
|
||||
LocalDiplomacyLog.Error("Agent client is not ready.");
|
||||
InformationManager.DisplayMessage(new InformationMessage("LocalDiplomacy agent client is not ready."));
|
||||
return;
|
||||
}
|
||||
|
||||
Hero? npc = Hero.OneToOneConversationHero;
|
||||
if (npc is null)
|
||||
{
|
||||
LocalDiplomacyLog.Error("No OneToOneConversationHero.");
|
||||
InformationManager.DisplayMessage(new InformationMessage("LocalDiplomacy could not find the current conversation hero."));
|
||||
return;
|
||||
}
|
||||
|
||||
LocalDiplomacyLog.Info($"Sending debug conversation to agent for {npc.StringId}: {playerMessage}");
|
||||
InformationManager.DisplayMessage(new InformationMessage("LocalDiplomacy is asking the external agent..."));
|
||||
|
||||
try
|
||||
{
|
||||
ConversationResponse? response = await _agentClient.RespondAsync(BuildDebugConversationRequest(playerMessage, npc));
|
||||
if (response is null)
|
||||
{
|
||||
LocalDiplomacyLog.Error("Agent returned null response.");
|
||||
InformationManager.DisplayMessage(new InformationMessage("LocalDiplomacy agent did not return a usable response."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(response.AssistantText))
|
||||
{
|
||||
LocalDiplomacyLog.Info($"Agent response: {response.AssistantText}");
|
||||
InformationManager.DisplayMessage(new InformationMessage($"LocalDiplomacy: {response.AssistantText}"));
|
||||
}
|
||||
|
||||
HandleActions(response.GameActions);
|
||||
|
||||
foreach (string warning in response.Warnings)
|
||||
{
|
||||
InformationManager.DisplayMessage(new InformationMessage($"LocalDiplomacy warning: {warning}"));
|
||||
}
|
||||
}
|
||||
catch (System.Exception exception)
|
||||
{
|
||||
LocalDiplomacyLog.Error(exception.ToString());
|
||||
InformationManager.DisplayMessage(new InformationMessage($"LocalDiplomacy agent error: {exception.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
public ConversationRequest BuildDebugConversationRequest(string playerMessage, Hero npc)
|
||||
{
|
||||
Hero player = Hero.MainHero;
|
||||
return new ConversationRequest(
|
||||
CampaignId: TaleWorlds.CampaignSystem.Campaign.Current?.UniqueGameId ?? "unknown-campaign",
|
||||
SaveId: "unknown-save",
|
||||
TurnId: System.Guid.NewGuid().ToString("N"),
|
||||
PlayerMessage: playerMessage,
|
||||
Player: new PlayerSummary(
|
||||
Id: player.StringId,
|
||||
Name: player.Name?.ToString() ?? "Player",
|
||||
ClanId: player.Clan?.StringId,
|
||||
KingdomId: player.Clan?.Kingdom?.StringId,
|
||||
Gold: MobileParty.MainParty?.PartyTradeGold),
|
||||
Npc: new CharacterSummary(
|
||||
Id: npc.StringId,
|
||||
Name: npc.Name?.ToString() ?? npc.StringId,
|
||||
ClanId: npc.Clan?.StringId,
|
||||
KingdomId: npc.Clan?.Kingdom?.StringId,
|
||||
Occupation: npc.Occupation.ToString(),
|
||||
RelationToPlayer: (int)npc.GetRelation(player)),
|
||||
Scene: new SceneSummary(
|
||||
LocationId: MobileParty.MainParty?.CurrentSettlement?.StringId,
|
||||
ConversationState: "debug"),
|
||||
NearbyParties: new List<Dictionary<string, object>>(),
|
||||
NearbySettlements: new List<Dictionary<string, object>>(),
|
||||
KingdomState: new Dictionary<string, object>(),
|
||||
RecentEvents: new List<Dictionary<string, object>>());
|
||||
}
|
||||
|
||||
private void HandleActions(IReadOnlyList<GameAction> actions)
|
||||
{
|
||||
if (_actionValidator is null || _actionExecutor is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (GameAction action in actions)
|
||||
{
|
||||
ActionValidationResult validation = _actionValidator.Validate(action);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
InformationManager.DisplayMessage(new InformationMessage($"LocalDiplomacy rejected action {action.ActionType}: {validation.Reason}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
_actionExecutor.Execute(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace System.Runtime.CompilerServices;
|
||||
|
||||
internal static class IsExternalInit
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LocalDiplomacy.Contracts;
|
||||
|
||||
public static class ActionCatalog
|
||||
{
|
||||
public static readonly HashSet<string> WhitelistedActions = new()
|
||||
{
|
||||
"attack",
|
||||
"surrender",
|
||||
"accept_surrender",
|
||||
"release",
|
||||
"escalate_conflict",
|
||||
"deescalate_conflict",
|
||||
"update_trust",
|
||||
"record_lie_detection",
|
||||
"generate_personality_profile",
|
||||
"generate_backstory",
|
||||
"set_speech_pattern",
|
||||
"propose_tts_line",
|
||||
"remember_conversation",
|
||||
"remember_secret",
|
||||
"remember_known_info",
|
||||
"record_relationship",
|
||||
"record_mentioned_entity",
|
||||
"record_visit",
|
||||
"protect_noncombatant_party",
|
||||
"create_world_event",
|
||||
"update_world_event",
|
||||
"spread_world_event",
|
||||
"personalize_world_event",
|
||||
"apply_economic_effect",
|
||||
"publish_diplomatic_statement",
|
||||
"run_diplomacy_round",
|
||||
"declare_war",
|
||||
"propose_peace",
|
||||
"accept_peace",
|
||||
"reject_peace",
|
||||
"propose_alliance",
|
||||
"accept_alliance",
|
||||
"reject_alliance",
|
||||
"break_alliance",
|
||||
"propose_trade_agreement",
|
||||
"accept_trade_agreement",
|
||||
"reject_trade_agreement",
|
||||
"end_trade_agreement",
|
||||
"demand_territory",
|
||||
"transfer_territory",
|
||||
"demand_tribute",
|
||||
"accept_tribute",
|
||||
"reject_tribute",
|
||||
"demand_reparations",
|
||||
"accept_reparations",
|
||||
"reject_reparations",
|
||||
"record_war_statistics",
|
||||
"update_war_fatigue",
|
||||
"expel_clan",
|
||||
"pardon_clan",
|
||||
"start_romance_interest",
|
||||
"increase_romance",
|
||||
"decrease_romance",
|
||||
"propose_marriage",
|
||||
"accept_marriage",
|
||||
"reject_marriage",
|
||||
"propose_intimacy",
|
||||
"accept_intimacy",
|
||||
"reject_intimacy",
|
||||
"propose_settlement_combat",
|
||||
"spawn_settlement_defenders",
|
||||
"set_civilian_panic",
|
||||
"set_companion_combat_decision",
|
||||
"set_lord_intervention",
|
||||
"continue_location_combat",
|
||||
"create_post_combat_event",
|
||||
"raid_village_after_combat",
|
||||
"burn_village_after_combat",
|
||||
"capture_player_after_knockout",
|
||||
"rescue_player_after_knockout",
|
||||
"record_hero_death",
|
||||
"generate_death_history",
|
||||
"decline_death_history",
|
||||
"follow_player",
|
||||
"go_to_settlement",
|
||||
"return_to_player",
|
||||
"create_party",
|
||||
"attack_party",
|
||||
"patrol_settlement",
|
||||
"wait_near_settlement",
|
||||
"siege_settlement",
|
||||
"raid_village",
|
||||
"save_task_chain",
|
||||
"manage_companion_party",
|
||||
"sell_workshop",
|
||||
"exchange_items",
|
||||
"create_rp_item",
|
||||
"hire_mercenary",
|
||||
"dismiss_mercenary",
|
||||
"offer_vassalage",
|
||||
"dismiss_vassal",
|
||||
"join_player_clan",
|
||||
"join_player_kingdom",
|
||||
"hire_mercenary_clan",
|
||||
"kick_from_clan",
|
||||
"dismiss_npc_mercenary",
|
||||
"release_npc_vassal",
|
||||
"record_recruitment_opportunity"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LocalDiplomacy.Contracts;
|
||||
|
||||
public sealed record CharacterSummary(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("clan_id")] string? ClanId = null,
|
||||
[property: JsonPropertyName("kingdom_id")] string? KingdomId = null,
|
||||
[property: JsonPropertyName("occupation")] string? Occupation = null,
|
||||
[property: JsonPropertyName("traits")] Dictionary<string, object>? Traits = null,
|
||||
[property: JsonPropertyName("relation_to_player")] int? RelationToPlayer = null);
|
||||
|
||||
public sealed record PlayerSummary(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("clan_id")] string? ClanId = null,
|
||||
[property: JsonPropertyName("kingdom_id")] string? KingdomId = null,
|
||||
[property: JsonPropertyName("occupation")] string? Occupation = null,
|
||||
[property: JsonPropertyName("traits")] Dictionary<string, object>? Traits = null,
|
||||
[property: JsonPropertyName("relation_to_player")] int? RelationToPlayer = null,
|
||||
[property: JsonPropertyName("gold")] int? Gold = null);
|
||||
|
||||
public sealed record SceneSummary(
|
||||
[property: JsonPropertyName("location_id")] string? LocationId = null,
|
||||
[property: JsonPropertyName("conversation_state")] string? ConversationState = null,
|
||||
[property: JsonPropertyName("player_is_prisoner")] bool PlayerIsPrisoner = false,
|
||||
[property: JsonPropertyName("npc_is_prisoner")] bool NpcIsPrisoner = false);
|
||||
|
||||
public sealed record ConversationRequest(
|
||||
[property: JsonPropertyName("campaign_id")] string CampaignId,
|
||||
[property: JsonPropertyName("save_id")] string SaveId,
|
||||
[property: JsonPropertyName("turn_id")] string TurnId,
|
||||
[property: JsonPropertyName("player_message")] string PlayerMessage,
|
||||
[property: JsonPropertyName("player")] PlayerSummary Player,
|
||||
[property: JsonPropertyName("npc")] CharacterSummary Npc,
|
||||
[property: JsonPropertyName("scene")] SceneSummary Scene,
|
||||
[property: JsonPropertyName("nearby_parties")] List<Dictionary<string, object>> NearbyParties,
|
||||
[property: JsonPropertyName("nearby_settlements")] List<Dictionary<string, object>> NearbySettlements,
|
||||
[property: JsonPropertyName("kingdom_state")] Dictionary<string, object> KingdomState,
|
||||
[property: JsonPropertyName("recent_events")] List<Dictionary<string, object>> RecentEvents);
|
||||
|
||||
public sealed record GameAction(
|
||||
[property: JsonPropertyName("action_id")] string? ActionId,
|
||||
[property: JsonPropertyName("action_type")] string ActionType,
|
||||
[property: JsonPropertyName("actor_id")] string ActorId,
|
||||
[property: JsonPropertyName("target_id")] string? TargetId,
|
||||
[property: JsonPropertyName("args")] Dictionary<string, object> Args,
|
||||
[property: JsonPropertyName("confidence")] double Confidence,
|
||||
[property: JsonPropertyName("reason")] string Reason,
|
||||
[property: JsonPropertyName("requires_player_confirmation")] bool RequiresPlayerConfirmation);
|
||||
|
||||
public sealed record ConversationResponse(
|
||||
[property: JsonPropertyName("turn_id")] string TurnId,
|
||||
[property: JsonPropertyName("assistant_text")] string AssistantText,
|
||||
[property: JsonPropertyName("mood")] string? Mood,
|
||||
[property: JsonPropertyName("tone")] string? Tone,
|
||||
[property: JsonPropertyName("visible_world_events")] List<Dictionary<string, object>> VisibleWorldEvents,
|
||||
[property: JsonPropertyName("game_actions")] List<GameAction> GameActions,
|
||||
[property: JsonPropertyName("warnings")] List<string> Warnings);
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace LocalDiplomacy.Diagnostics;
|
||||
|
||||
public static class LocalDiplomacyLog
|
||||
{
|
||||
private static readonly object LockObject = new();
|
||||
|
||||
public static string LogPath { get; } = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
|
||||
"Mount and Blade II Bannerlord",
|
||||
"Configs",
|
||||
"ModLogs",
|
||||
"LocalDiplomacy.log");
|
||||
|
||||
public static void Info(string message)
|
||||
{
|
||||
Write("INFO", message);
|
||||
}
|
||||
|
||||
public static void Error(string message)
|
||||
{
|
||||
Write("ERROR", message);
|
||||
}
|
||||
|
||||
private static void Write(string level, string message)
|
||||
{
|
||||
lock (LockObject)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(LogPath) ?? ".");
|
||||
File.AppendAllText(
|
||||
LogPath,
|
||||
$"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {level} {message}{Environment.NewLine}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using LocalDiplomacy.Contracts;
|
||||
using TaleWorlds.Library;
|
||||
|
||||
namespace LocalDiplomacy.Diplomacy;
|
||||
|
||||
public sealed class ActionExecutor
|
||||
{
|
||||
public void Execute(GameAction action)
|
||||
{
|
||||
// v1 scaffold: mutation-specific handlers will be implemented incrementally.
|
||||
InformationManager.DisplayMessage(new InformationMessage($"LocalDiplomacy action accepted: {action.ActionType}"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using LocalDiplomacy.Contracts;
|
||||
using LocalDiplomacy.Settings;
|
||||
|
||||
namespace LocalDiplomacy.Diplomacy;
|
||||
|
||||
public sealed record ActionValidationResult(bool IsValid, string? Reason = null);
|
||||
|
||||
public sealed class ActionValidator
|
||||
{
|
||||
private readonly LocalDiplomacySettings _settings;
|
||||
|
||||
public ActionValidator(LocalDiplomacySettings settings)
|
||||
{
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
public ActionValidationResult Validate(GameAction action)
|
||||
{
|
||||
if (!ActionCatalog.WhitelistedActions.Contains(action.ActionType))
|
||||
{
|
||||
return new ActionValidationResult(false, "action is not whitelisted");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(action.ActorId))
|
||||
{
|
||||
return new ActionValidationResult(false, "actor_id is required");
|
||||
}
|
||||
|
||||
if (action.Confidence < 0.35)
|
||||
{
|
||||
return new ActionValidationResult(false, "confidence is too low");
|
||||
}
|
||||
|
||||
if (RequiresConfirmation(action) && !_settings.AutoExecuteSafeActions)
|
||||
{
|
||||
return new ActionValidationResult(false, "action requires confirmation UI");
|
||||
}
|
||||
|
||||
return new ActionValidationResult(true);
|
||||
}
|
||||
|
||||
private bool RequiresConfirmation(GameAction action)
|
||||
{
|
||||
if (action.RequiresPlayerConfirmation)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return _settings.AlwaysConfirmDiplomacyActions && IsDiplomacyAction(action.ActionType)
|
||||
|| _settings.AlwaysConfirmHostileActions && IsHostileAction(action.ActionType);
|
||||
}
|
||||
|
||||
private static bool IsDiplomacyAction(string actionType)
|
||||
{
|
||||
return actionType.Contains("peace")
|
||||
|| actionType.Contains("alliance")
|
||||
|| actionType.Contains("war")
|
||||
|| actionType.Contains("tribute")
|
||||
|| actionType.Contains("reparations")
|
||||
|| actionType.Contains("territory")
|
||||
|| actionType.Contains("trade_agreement")
|
||||
|| actionType.Contains("diplomatic")
|
||||
|| actionType is "declare_war" or "expel_clan" or "pardon_clan" or "run_diplomacy_round";
|
||||
}
|
||||
|
||||
private static bool IsHostileAction(string actionType)
|
||||
{
|
||||
return actionType is "attack" or "attack_party" or "siege_settlement" or "raid_village";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net472</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>LocalDiplomacy</AssemblyName>
|
||||
<RootNamespace>LocalDiplomacy</RootNamespace>
|
||||
<OutputPath>..\..\module\LocalDiplomacy\bin\Win64_Shipping_Client\</OutputPath>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Text.Json" Version="8.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="System.Net.Http" />
|
||||
<Reference Include="TaleWorlds.Core" HintPath="$(BANNERLORD_REFERENCES)\TaleWorlds.Core.dll" Private="false" />
|
||||
<Reference Include="TaleWorlds.CampaignSystem" HintPath="$(BANNERLORD_REFERENCES)\TaleWorlds.CampaignSystem.dll" Private="false" />
|
||||
<Reference Include="TaleWorlds.Library" HintPath="$(BANNERLORD_REFERENCES)\TaleWorlds.Library.dll" Private="false" />
|
||||
<Reference Include="TaleWorlds.Localization" HintPath="$(BANNERLORD_REFERENCES)\TaleWorlds.Localization.dll" Private="false" />
|
||||
<Reference Include="TaleWorlds.MountAndBlade" HintPath="$(BANNERLORD_REFERENCES)\TaleWorlds.MountAndBlade.dll" Private="false" />
|
||||
<Reference Include="TaleWorlds.ObjectSystem" HintPath="$(BANNERLORD_REFERENCES)\TaleWorlds.ObjectSystem.dll" Private="false" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace LocalDiplomacy.Settings;
|
||||
|
||||
public sealed class LocalDiplomacySettings
|
||||
{
|
||||
public const string LocalAgentUrl = "http://127.0.0.1:8766";
|
||||
|
||||
public string AgentUrl { get; } = LocalAgentUrl;
|
||||
public int TimeoutSeconds { get; init; } = 120;
|
||||
public bool DebugLoggingEnabled { get; init; } = true;
|
||||
public bool StoreFullPrompts { get; init; }
|
||||
public bool AutoExecuteSafeActions { get; init; }
|
||||
public bool AlwaysConfirmDiplomacyActions { get; init; } = true;
|
||||
public bool AlwaysConfirmHostileActions { get; init; } = true;
|
||||
public float WorldTickIntervalHours { get; init; } = 6f;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using LocalDiplomacy.Campaign;
|
||||
using LocalDiplomacy.Diagnostics;
|
||||
using TaleWorlds.CampaignSystem;
|
||||
using TaleWorlds.Core;
|
||||
using TaleWorlds.Library;
|
||||
using TaleWorlds.MountAndBlade;
|
||||
using CampaignGameType = TaleWorlds.CampaignSystem.Campaign;
|
||||
|
||||
namespace LocalDiplomacy;
|
||||
|
||||
public sealed class SubModule : MBSubModuleBase
|
||||
{
|
||||
protected override void OnSubModuleLoad()
|
||||
{
|
||||
base.OnSubModuleLoad();
|
||||
LocalDiplomacyLog.Info("OnSubModuleLoad.");
|
||||
InformationManager.DisplayMessage(new InformationMessage("LocalDiplomacy loaded."));
|
||||
}
|
||||
|
||||
protected override void OnGameStart(Game game, IGameStarter gameStarterObject)
|
||||
{
|
||||
base.OnGameStart(game, gameStarterObject);
|
||||
|
||||
if (game.GameType is not CampaignGameType)
|
||||
{
|
||||
LocalDiplomacyLog.Info($"OnGameStart ignored game type {game.GameType?.GetType().FullName ?? "unknown"}.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (gameStarterObject is CampaignGameStarter campaignStarter)
|
||||
{
|
||||
LocalDiplomacyLog.Info("Adding LocalDiplomacyCampaignBehavior.");
|
||||
campaignStarter.AddBehavior(new LocalDiplomacyCampaignBehavior());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user