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