feature: webui, kobolcpp intergration memory, game module

This commit is contained in:
2026-04-30 02:43:23 -04:00
parent 9da4259df1
commit 00f1ac4bef
40 changed files with 5651 additions and 1 deletions
+22
View File
@@ -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
+29
View File
@@ -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
+7
View File
@@ -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.
+759
View File
@@ -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/
+25
View File
@@ -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>
+39 -1
View File
@@ -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
```
+373
View File
@@ -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 => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
}[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)))}
+25
View File
@@ -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"]
+1342
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -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"
};
}
+61
View File
@@ -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";
}
}
+25
View File
@@ -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;
}
+36
View File
@@ -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());
}
}
}