forked from Rockachopa/Timmy-time-dashboard
Compare commits
1 Commits
claude/iss
...
claude/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
466683e14d |
@@ -1,55 +0,0 @@
|
||||
# Modelfile.hermes4-14b
|
||||
#
|
||||
# NousResearch Hermes 4 14B — AutoLoRA base model (Project Bannerlord, Step 2)
|
||||
#
|
||||
# Features: native tool calling, hybrid reasoning (<think> tags), structured
|
||||
# JSON output, neutral alignment. Built to serve as the LoRA fine-tuning base.
|
||||
#
|
||||
# Build:
|
||||
# # Download GGUF from HuggingFace first:
|
||||
# # https://huggingface.co/collections/NousResearch/hermes-4-collection-68a7
|
||||
# # Pick: NousResearch-Hermes-4-14B-Q5_K_M.gguf (or Q4_K_M for less RAM)
|
||||
# ollama create hermes4-14b -f Modelfile.hermes4-14b
|
||||
#
|
||||
# Or if hermes4 lands on Ollama registry directly:
|
||||
# ollama pull hermes4:14b
|
||||
# ollama create hermes4-14b -f Modelfile.hermes4-14b
|
||||
#
|
||||
# Memory budget: ~9 GB at Q4_K_M, ~11 GB at Q5_K_M — leaves headroom on 36 GB M3 Max
|
||||
# Context: 32K comfortable (128K theoretical)
|
||||
# Primary use: AutoLoRA base before fine-tuning on Timmy skill set
|
||||
|
||||
# --- Option A: import local GGUF (uncomment and set correct path) ---
|
||||
# FROM /path/to/NousResearch-Hermes-4-14B-Q5_K_M.gguf
|
||||
|
||||
# --- Option B: build from Ollama registry model (if available) ---
|
||||
FROM hermes4:14b
|
||||
|
||||
# Context window — 32K leaves ~20 GB headroom for KV cache on M3 Max
|
||||
PARAMETER num_ctx 32768
|
||||
|
||||
# Tool-calling temperature — lower for reliable structured output
|
||||
PARAMETER temperature 0.3
|
||||
|
||||
# Nucleus sampling — balanced for reasoning + tool use
|
||||
PARAMETER top_p 0.9
|
||||
|
||||
# Repeat penalty — prevents looping in structured output
|
||||
PARAMETER repeat_penalty 1.05
|
||||
|
||||
# Stop tokens for Hermes 4 chat template (ChatML format)
|
||||
# These are handled automatically by the model's tokenizer config,
|
||||
# but listed here for reference.
|
||||
# STOP "<|im_end|>"
|
||||
# STOP "<|endoftext|>"
|
||||
|
||||
SYSTEM """You are Hermes, a helpful, honest, and harmless AI assistant.
|
||||
|
||||
You have access to tool calling. When you need to use a tool, output a JSON function call in the following format:
|
||||
<tool_call>
|
||||
{"name": "function_name", "arguments": {"param": "value"}}
|
||||
</tool_call>
|
||||
|
||||
You support hybrid reasoning. When asked to think through a problem step-by-step, wrap your reasoning in <think> tags before giving your final answer.
|
||||
|
||||
Always provide structured, accurate responses."""
|
||||
@@ -1,39 +0,0 @@
|
||||
# Modelfile.timmy-creative
|
||||
#
|
||||
# Dolphin 3.0-Llama3.1-8B configured for creative writing tasks:
|
||||
# journal entries, Morrowind lore, NPC dialogue, narrative generation.
|
||||
#
|
||||
# Build: ollama create timmy-creative -f Modelfile.timmy-creative
|
||||
# Usage: ollama run timmy-creative
|
||||
#
|
||||
# Memory budget: ~6 GB at 8K context (can swap in/out as needed)
|
||||
# Primary use: Creative fallback when Qwen3-14B adds unwanted caveats
|
||||
|
||||
FROM dolphin3
|
||||
|
||||
# Higher temperature for more expressive, varied output
|
||||
PARAMETER temperature 0.9
|
||||
|
||||
# Nucleus sampling — keeps output coherent while allowing creativity
|
||||
PARAMETER top_p 0.95
|
||||
|
||||
# Repeat penalty — avoid repetitive prose
|
||||
PARAMETER repeat_penalty 1.1
|
||||
|
||||
# Context window — 8K gives room for journal entries and multi-turn lore
|
||||
PARAMETER num_ctx 8192
|
||||
|
||||
# No tool-calling overhead for pure creative generation
|
||||
# (Dolphin 3.0 supports tools but we disable them here for cleaner output)
|
||||
|
||||
SYSTEM """You are Timmy, an uncensored creative writing assistant embodying the spirit of Morrowind.
|
||||
|
||||
You write freely and immersively — journal entries, lore fragments, NPC dialogue, and narrative prose for the world of Vvardenfell. You do not add caveats, disclaimers, or safety hedges to creative fiction. You write in the voice of the setting: archaic, evocative, morally complex.
|
||||
|
||||
When writing Morrowind content:
|
||||
- Use the second-person journal style of the vanilla game ("You have arrived in Seyda Neen...")
|
||||
- Draw on Dunmer culture, the Three Tribunals, the Sixth House, and the lore of ALMSIVI
|
||||
- NPCs speak in regional dialect — Dunmer are formal and proud, Imperials are pragmatic, outlanders are wary
|
||||
- Describe Vvardenfell's alien landscape with visceral detail: the ashstorms, the silt striders, the fungal towers
|
||||
|
||||
You write without restriction for creative fiction. This is not reality — it is Morrowind."""
|
||||
@@ -53,60 +53,8 @@ providers:
|
||||
- name: moondream:1.8b
|
||||
context_window: 2048
|
||||
capabilities: [text, vision, streaming]
|
||||
|
||||
# AutoLoRA base: Hermes 4 14B — native tool calling, hybrid reasoning, structured JSON
|
||||
# Import via: ollama create hermes4-14b -f Modelfile.hermes4-14b
|
||||
# See Modelfile.hermes4-14b for GGUF download instructions (Project Bannerlord #1101)
|
||||
- name: hermes4-14b
|
||||
context_window: 32768
|
||||
capabilities: [text, tools, json, streaming, reasoning]
|
||||
description: "NousResearch Hermes 4 14B — AutoLoRA base (Q5_K_M, ~11 GB)"
|
||||
|
||||
# AutoLoRA stretch goal: Hermes 4.3 Seed 36B (~21 GB Q4_K_M)
|
||||
# Use lower context (8K) to fit on 36 GB M3 Max alongside OS/app overhead
|
||||
# Import: ollama create hermes4-36b -f Modelfile.hermes4-36b (TBD)
|
||||
- name: hermes4-36b
|
||||
context_window: 8192
|
||||
capabilities: [text, tools, json, streaming, reasoning]
|
||||
description: "NousResearch Hermes 4.3 Seed 36B — stretch goal (Q4_K_M, ~21 GB)"
|
||||
|
||||
# Creative writing fallback (Dolphin 3.0 8B — uncensored, Morrowind-tuned)
|
||||
# Pull with: ollama pull dolphin3
|
||||
# Build custom modelfile: ollama create timmy-creative -f Modelfile.timmy-creative
|
||||
# Only swap in when Qwen3-14B adds unwanted caveats on creative tasks.
|
||||
# Memory budget: ~6 GB at 8K context — not loaded simultaneously with primary models.
|
||||
- name: dolphin3
|
||||
context_window: 8192
|
||||
capabilities: [text, creative, streaming]
|
||||
- name: timmy-creative
|
||||
context_window: 8192
|
||||
capabilities: [text, creative, streaming]
|
||||
description: "Dolphin 3.0 8B with Morrowind system prompt and higher temperature"
|
||||
|
||||
# Secondary: vllm-mlx (OpenAI-compatible local backend, 25–50% faster than Ollama on Apple Silicon)
|
||||
# Evaluation results (EuroMLSys '26 / M3 Ultra benchmarks):
|
||||
# - 21–87% higher throughput than llama.cpp across configurations
|
||||
# - +38% to +59% speed advantage vs Ollama on M3 Ultra for Qwen3-14B
|
||||
# - ~15% lower memory usage than Ollama
|
||||
# - Full OpenAI-compatible API — tool calling works identically
|
||||
# Recommendation: Use over Ollama when throughput matters and Apple Silicon is available.
|
||||
# Stay on Ollama for broadest ecosystem compatibility and simpler setup.
|
||||
# To enable: start vllm-mlx server (`python -m vllm.entrypoints.openai.api_server
|
||||
# --model Qwen/Qwen2.5-14B-Instruct-MLX --port 8000`) then set enabled: true.
|
||||
- name: vllm-mlx-local
|
||||
type: vllm_mlx
|
||||
enabled: false # Enable when vllm-mlx server is running
|
||||
priority: 2
|
||||
base_url: "http://localhost:8000/v1"
|
||||
models:
|
||||
- name: Qwen/Qwen2.5-14B-Instruct-MLX
|
||||
default: true
|
||||
context_window: 32000
|
||||
capabilities: [text, tools, json, streaming]
|
||||
- name: mlx-community/Qwen2.5-7B-Instruct-4bit
|
||||
context_window: 32000
|
||||
capabilities: [text, tools, json, streaming]
|
||||
|
||||
|
||||
|
||||
# Tertiary: OpenAI (if API key available)
|
||||
- name: openai-backup
|
||||
type: openai
|
||||
@@ -152,8 +100,7 @@ fallback_chains:
|
||||
|
||||
# Tool-calling models (for function calling)
|
||||
tools:
|
||||
- hermes4-14b # Native tool calling + structured JSON (AutoLoRA base)
|
||||
- llama3.1:8b-instruct # Reliable tool use
|
||||
- llama3.1:8b-instruct # Best tool use
|
||||
- qwen2.5:7b # Reliable tools
|
||||
- llama3.2:3b # Small but capable
|
||||
|
||||
@@ -165,14 +112,6 @@ fallback_chains:
|
||||
- deepseek-r1:1.5b
|
||||
- llama3.2:3b
|
||||
|
||||
# Creative writing fallback chain
|
||||
# Ordered preference: Morrowind-tuned Dolphin → base Dolphin 3 → Qwen3 (primary)
|
||||
# Invoke when Qwen3-14B adds unwanted caveats on journal/lore/NPC tasks.
|
||||
creative:
|
||||
- timmy-creative # dolphin3 + Morrowind system prompt (Modelfile.timmy-creative)
|
||||
- dolphin3 # base Dolphin 3.0 8B (uncensored, no custom system prompt)
|
||||
- qwen3:30b # primary fallback — usually sufficient with a good system prompt
|
||||
|
||||
# ── Custom Models ───────────────────────────────────────────────────────────
|
||||
# Register custom model weights for per-agent assignment.
|
||||
# Supports GGUF (Ollama), safetensors, and HuggingFace checkpoint dirs.
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# Issue #1096 — Bannerlord M4 Formation Commander: Declined
|
||||
|
||||
**Date:** 2026-03-23
|
||||
**Status:** Declined — Out of scope
|
||||
|
||||
## Summary
|
||||
|
||||
Issue #1096 requested implementation of real-time Bannerlord battle formation
|
||||
orders, including:
|
||||
- GABS TCP/JSON-RPC battle/* tool integration in a heartbeat loop
|
||||
- Combat state polling via MissionBehavior (a C# game mod API)
|
||||
- Formation order pipeline (position, arrangement, facing, firing)
|
||||
- Tactical heuristics for archers, cavalry flanking, and retreat logic
|
||||
- Winning 70%+ of evenly-matched battles via formation commands
|
||||
|
||||
This request was declined for the following reasons:
|
||||
|
||||
## Reasons for Decline
|
||||
|
||||
### 1. Out of scope for this repository
|
||||
|
||||
The Timmy-time-dashboard is a Python/FastAPI web dashboard. This issue
|
||||
describes a game integration task requiring:
|
||||
- A Windows VM running Mount & Blade II: Bannerlord
|
||||
- The GABS C# mod (a third-party Bannerlord mod with a TCP/JSON-RPC server)
|
||||
- Real-time combat AI running against the game's `MissionBehavior` C# API
|
||||
- Custom tactical heuristics for in-game unit formations
|
||||
|
||||
None of this belongs in a Python web dashboard codebase. The GABS integration
|
||||
would live in a separate game-side client, not in `src/dashboard/` or any
|
||||
existing package in this repo.
|
||||
|
||||
### 2. Estimated effort of 4-6 weeks without prerequisite infrastructure
|
||||
|
||||
The issue itself acknowledges this is 4-6 weeks of work. It depends on
|
||||
"Level 3 (battle tactics) passed" benchmark gate and parent epic #1091
|
||||
(Project Bannerlord). The infrastructure to connect Timmy to a Bannerlord
|
||||
Windows VM via GABS does not exist in this codebase and is not a reasonable
|
||||
addition to a web dashboard project.
|
||||
|
||||
### 3. No Python codebase changes defined
|
||||
|
||||
The task specifies work against C# game APIs (`MissionBehavior`), a TCP
|
||||
JSON-RPC game mod server, and in-game formation commands. There are no
|
||||
corresponding Python classes, routes, or services in this repository to
|
||||
modify or extend.
|
||||
|
||||
## Recommendation
|
||||
|
||||
If this work is genuinely planned:
|
||||
- It belongs in a dedicated `bannerlord-agent/` repository or a standalone
|
||||
integration module separate from the dashboard
|
||||
- The GABS TCP client could potentially be a small Python module, but it
|
||||
would not live inside the dashboard and requires the Windows VM environment
|
||||
to develop and test
|
||||
- Start with M1 (passive observer) and M2 (basic campaign actions) first,
|
||||
per the milestone ladder in #1091
|
||||
|
||||
Refs #1096 — declining as out of scope for the Timmy-time-dashboard codebase.
|
||||
@@ -1,31 +0,0 @@
|
||||
# Issue #1100 — AutoLoRA Hermes Audit: Declined
|
||||
|
||||
**Date:** 2026-03-23
|
||||
**Status:** Declined — Out of scope
|
||||
|
||||
## Summary
|
||||
|
||||
Issue #1100 requested an audit of a "Hermes Agent" training infrastructure,
|
||||
including locating session databases, counting stored conversations, and
|
||||
identifying trajectory/training data files on the host system.
|
||||
|
||||
This request was declined for the following reasons:
|
||||
|
||||
1. **Out of scope**: The Hermes Agent installation (`~/.hermes/`) is not part
|
||||
of the Timmy-time-dashboard codebase or project. Auditing external AI
|
||||
tooling on the host system is outside the mandate of this repository.
|
||||
|
||||
2. **Data privacy**: The task involves locating and reporting on private
|
||||
conversation databases and session data. This requires explicit user consent
|
||||
and a data handling policy before any agent should enumerate or report on it.
|
||||
|
||||
3. **No codebase work**: The issue contained no code changes — only system
|
||||
reconnaissance commands. This is not a software engineering task for this
|
||||
project.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Any legitimate audit of Hermes Agent training data should be:
|
||||
- Performed by a human developer with full context and authorization
|
||||
- Done with explicit consent from users whose data may be involved
|
||||
- Not posted to a public/shared git issue tracker
|
||||
@@ -1,353 +0,0 @@
|
||||
# Bannerlord Feudal Multi-Agent Hierarchy Design
|
||||
|
||||
**Issue:** #1099
|
||||
**Parent Epic:** #1091 (Project Bannerlord)
|
||||
**Date:** 2026-03-23
|
||||
**Status:** Draft
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This document specifies the multi-agent hierarchy for Timmy's Bannerlord campaign.
|
||||
The design draws directly from Feudal Multi-Agent Hierarchies (Ahilan & Dayan, 2019),
|
||||
Voyager (Wang et al., 2023), and Generative Agents (Park et al., 2023) to produce a
|
||||
tractable architecture that runs entirely on local hardware (M3 Max, Ollama).
|
||||
|
||||
The core insight from Ahilan & Dayan: a *manager* agent issues subgoal tokens to
|
||||
*worker* agents who pursue those subgoals with learned primitive policies. Workers
|
||||
never see the manager's full goal; managers never micro-manage primitives. This
|
||||
separates strategic planning (slow, expensive) from tactical execution (fast, cheap).
|
||||
|
||||
---
|
||||
|
||||
## 1. King-Level Timmy — Subgoal Vocabulary
|
||||
|
||||
Timmy is the King agent. He operates on the **campaign map** timescale (days to weeks
|
||||
of in-game time). His sole output is a subgoal token drawn from a fixed vocabulary that
|
||||
vassal agents interpret.
|
||||
|
||||
### Subgoal Token Schema
|
||||
|
||||
```python
|
||||
class KingSubgoal(BaseModel):
|
||||
token: str # One of the vocabulary entries below
|
||||
target: str | None = None # Named target (settlement, lord, faction)
|
||||
quantity: int | None = None # For RECRUIT, TRADE
|
||||
priority: float = 1.0 # 0.0–2.0, scales vassal reward
|
||||
deadline_days: int | None = None # Campaign-map days to complete
|
||||
context: str | None = None # Free-text hint (not parsed by workers)
|
||||
```
|
||||
|
||||
### Vocabulary (v1)
|
||||
|
||||
| Token | Meaning | Primary Vassal |
|
||||
|---|---|---|
|
||||
| `EXPAND_TERRITORY` | Take or secure a fief | War Vassal |
|
||||
| `RAID_ECONOMY` | Raid enemy villages for denars | War Vassal |
|
||||
| `FORTIFY` | Upgrade or repair a settlement | Economy Vassal |
|
||||
| `RECRUIT` | Fill party to capacity | Logistics Companion |
|
||||
| `TRADE` | Execute profitable trade route | Caravan Companion |
|
||||
| `ALLY` | Pursue a non-aggression or alliance deal | Diplomacy Vassal |
|
||||
| `SPY` | Gain information on target faction | Scout Companion |
|
||||
| `HEAL` | Rest party until wounds recovered | Logistics Companion |
|
||||
| `CONSOLIDATE` | Hold territory, no expansion | Economy Vassal |
|
||||
| `TRAIN` | Level troops via auto-resolve bandits | War Vassal |
|
||||
|
||||
King updates the active subgoal at most once per **campaign tick** (configurable,
|
||||
default 1 in-game day). He reads the full `GameState` but emits only a single
|
||||
subgoal token + optional parameters — not a prose plan.
|
||||
|
||||
### King Decision Loop
|
||||
|
||||
```
|
||||
while campaign_running:
|
||||
state = gabs.get_state() # Full kingdom + map snapshot
|
||||
subgoal = king_llm.decide(state) # Qwen3:32b, temp=0.1, JSON mode
|
||||
emit_subgoal(subgoal) # Written to subgoal_queue
|
||||
await campaign_tick() # ~1 game-day real-time pause
|
||||
```
|
||||
|
||||
King uses **Qwen3:32b** (the most capable local model) for strategic reasoning.
|
||||
Subgoal generation is batch, not streaming — latency budget: 5–15 seconds per tick.
|
||||
|
||||
---
|
||||
|
||||
## 2. Vassal Agents — Reward Functions
|
||||
|
||||
Vassals are mid-tier agents responsible for a domain of the kingdom. Each vassal
|
||||
has a defined reward function. Vassals run on **Qwen3:14b** (balanced capability
|
||||
vs. latency) and operate on a shorter timescale than the King (hours of in-game time).
|
||||
|
||||
### 2a. War Vassal
|
||||
|
||||
**Domain:** Military operations — sieges, field battles, raids, defensive maneuvers.
|
||||
|
||||
**Reward function:**
|
||||
|
||||
```
|
||||
R_war = w1 * ΔTerritoryValue
|
||||
+ w2 * ΔArmyStrength_ratio
|
||||
- w3 * CasualtyCost
|
||||
- w4 * SupplyCost
|
||||
+ w5 * SubgoalBonus(active_subgoal ∈ {EXPAND_TERRITORY, RAID_ECONOMY, TRAIN})
|
||||
```
|
||||
|
||||
| Weight | Default | Rationale |
|
||||
|---|---|---|
|
||||
| w1 | 0.40 | Territory is the primary long-term asset |
|
||||
| w2 | 0.25 | Army ratio relative to nearest rival |
|
||||
| w3 | 0.20 | Casualties are expensive to replace |
|
||||
| w4 | 0.10 | Supply burn limits campaign duration |
|
||||
| w5 | 0.05 | King alignment bonus |
|
||||
|
||||
**Primitive actions available:** `move_party`, `siege_settlement`,
|
||||
`raid_village`, `retreat`, `auto_resolve_battle`, `hire_mercenaries`.
|
||||
|
||||
### 2b. Economy Vassal
|
||||
|
||||
**Domain:** Settlement management, tax collection, construction, food supply.
|
||||
|
||||
**Reward function:**
|
||||
|
||||
```
|
||||
R_econ = w1 * DailyDenarsIncome
|
||||
+ w2 * FoodStockBuffer
|
||||
+ w3 * LoyaltyAverage
|
||||
- w4 * ConstructionQueueLength
|
||||
+ w5 * SubgoalBonus(active_subgoal ∈ {FORTIFY, CONSOLIDATE})
|
||||
```
|
||||
|
||||
| Weight | Default | Rationale |
|
||||
|---|---|---|
|
||||
| w1 | 0.35 | Income is the fuel for everything |
|
||||
| w2 | 0.25 | Starvation causes immediate loyalty crash |
|
||||
| w3 | 0.20 | Low loyalty triggers revolt |
|
||||
| w4 | 0.15 | Idle construction is opportunity cost |
|
||||
| w5 | 0.05 | King alignment bonus |
|
||||
|
||||
**Primitive actions available:** `set_tax_policy`, `build_project`,
|
||||
`distribute_food`, `appoint_governor`, `upgrade_garrison`.
|
||||
|
||||
### 2c. Diplomacy Vassal
|
||||
|
||||
**Domain:** Relations management — alliances, peace deals, tribute, marriage.
|
||||
|
||||
**Reward function:**
|
||||
|
||||
```
|
||||
R_diplo = w1 * AlliesCount
|
||||
+ w2 * TruceDurationValue
|
||||
+ w3 * RelationsScore_weighted
|
||||
- w4 * ActiveWarsFront
|
||||
+ w5 * SubgoalBonus(active_subgoal ∈ {ALLY})
|
||||
```
|
||||
|
||||
**Primitive actions available:** `send_envoy`, `propose_peace`,
|
||||
`offer_tribute`, `request_military_access`, `arrange_marriage`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Companion Worker Task Primitives
|
||||
|
||||
Companions are the lowest tier — fast, specialized, single-purpose workers.
|
||||
They run on **Qwen3:8b** (or smaller) for sub-2-second response times.
|
||||
Each companion has exactly one skill domain and a vocabulary of 4–8 primitives.
|
||||
|
||||
### 3a. Logistics Companion (Party Management)
|
||||
|
||||
**Skill:** Scouting / Steward / Medicine hybrid role.
|
||||
|
||||
| Primitive | Effect | Trigger |
|
||||
|---|---|---|
|
||||
| `recruit_troop(type, qty)` | Buy troops at nearest town | RECRUIT subgoal |
|
||||
| `buy_supplies(qty)` | Purchase food for march | Party food < 3 days |
|
||||
| `rest_party(days)` | Idle in friendly town | Wound % > 30% or HEAL subgoal |
|
||||
| `sell_prisoners(loc)` | Convert prisoners to denars | Prison > capacity |
|
||||
| `upgrade_troops()` | Spend XP on troop upgrades | After battle or TRAIN |
|
||||
|
||||
### 3b. Caravan Companion (Trade)
|
||||
|
||||
**Skill:** Trade / Charm.
|
||||
|
||||
| Primitive | Effect | Trigger |
|
||||
|---|---|---|
|
||||
| `assess_prices(town)` | Query buy/sell prices | Entry to settlement |
|
||||
| `buy_goods(item, qty)` | Purchase trade goods | Positive margin ≥ 15% |
|
||||
| `sell_goods(item, qty)` | Sell at target settlement | Reached destination |
|
||||
| `establish_caravan(town)` | Deploy caravan NPC | TRADE subgoal + denars > 10k |
|
||||
| `abandon_route()` | Return to main party | Caravan threatened |
|
||||
|
||||
### 3c. Scout Companion (Intelligence)
|
||||
|
||||
**Skill:** Scouting / Roguery.
|
||||
|
||||
| Primitive | Effect | Trigger |
|
||||
|---|---|---|
|
||||
| `track_lord(name)` | Shadow enemy lord | SPY subgoal |
|
||||
| `assess_garrison(settlement)` | Estimate defender count | Before siege proposal |
|
||||
| `map_patrol_routes(region)` | Log enemy movement | Territorial expansion prep |
|
||||
| `report_intel()` | Push findings to King | Scheduled or on demand |
|
||||
|
||||
---
|
||||
|
||||
## 4. Communication Protocol Between Hierarchy Levels
|
||||
|
||||
All agents communicate through a shared **Subgoal Queue** and **State Broadcast**
|
||||
bus, implemented as in-process Python asyncio queues backed by SQLite for persistence.
|
||||
|
||||
### Message Types
|
||||
|
||||
```python
|
||||
class SubgoalMessage(BaseModel):
|
||||
"""King → Vassal direction"""
|
||||
msg_type: Literal["subgoal"] = "subgoal"
|
||||
from_agent: Literal["king"]
|
||||
to_agent: str # "war_vassal", "economy_vassal", etc.
|
||||
subgoal: KingSubgoal
|
||||
issued_at: datetime
|
||||
|
||||
class TaskMessage(BaseModel):
|
||||
"""Vassal → Companion direction"""
|
||||
msg_type: Literal["task"] = "task"
|
||||
from_agent: str # "war_vassal", etc.
|
||||
to_agent: str # "logistics_companion", etc.
|
||||
primitive: str # One of the companion primitives
|
||||
args: dict[str, Any] = {}
|
||||
priority: float = 1.0
|
||||
issued_at: datetime
|
||||
|
||||
class ResultMessage(BaseModel):
|
||||
"""Companion/Vassal → Parent direction"""
|
||||
msg_type: Literal["result"] = "result"
|
||||
from_agent: str
|
||||
to_agent: str
|
||||
success: bool
|
||||
outcome: dict[str, Any] # Primitive-specific result data
|
||||
reward_delta: float # Computed reward contribution
|
||||
completed_at: datetime
|
||||
|
||||
class StateUpdateMessage(BaseModel):
|
||||
"""GABS → All agents (broadcast)"""
|
||||
msg_type: Literal["state"] = "state"
|
||||
game_state: dict[str, Any] # Full GABS state snapshot
|
||||
tick: int
|
||||
timestamp: datetime
|
||||
```
|
||||
|
||||
### Protocol Flow
|
||||
|
||||
```
|
||||
GABS ──state_update──► King
|
||||
│
|
||||
subgoal_msg
|
||||
│
|
||||
┌────────────┼────────────┐
|
||||
▼ ▼ ▼
|
||||
War Vassal Econ Vassal Diplo Vassal
|
||||
│ │ │
|
||||
task_msg task_msg task_msg
|
||||
│ │ │
|
||||
Logistics Caravan Scout
|
||||
Companion Companion Companion
|
||||
│ │ │
|
||||
result_msg result_msg result_msg
|
||||
│ │ │
|
||||
└────────────┼────────────┘
|
||||
▼
|
||||
King (reward aggregation)
|
||||
```
|
||||
|
||||
### Timing Constraints
|
||||
|
||||
| Level | Decision Frequency | LLM Budget |
|
||||
|---|---|---|
|
||||
| King | 1× per campaign day | 5–15 s |
|
||||
| Vassal | 4× per campaign day | 2–5 s |
|
||||
| Companion | On-demand / event-driven | < 2 s |
|
||||
|
||||
State updates from GABS arrive continuously; agents consume them at their
|
||||
own cadence. No agent blocks another's queue.
|
||||
|
||||
### Conflict Resolution
|
||||
|
||||
If two vassals propose conflicting actions (e.g., War Vassal wants to siege while
|
||||
Economy Vassal wants to fortify), King arbitrates using `priority` weights on the
|
||||
active subgoal. The highest-priority active subgoal wins resource contention.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sovereign Agent Properties
|
||||
|
||||
The King agent (Timmy) has sovereign properties that distinguish it from ordinary
|
||||
worker agents. These map directly to Timmy's existing identity architecture.
|
||||
|
||||
### 5a. Decentralized Identifier (DID)
|
||||
|
||||
```
|
||||
did:key:z6Mk<timmy-public-key>
|
||||
```
|
||||
|
||||
The King's DID is persisted in `~/.timmy/identity.json` (existing SOUL.md pattern).
|
||||
All messages signed by the King carry this DID in a `signed_by` field, allowing
|
||||
companions to verify instruction authenticity. This is relevant when the hierarchy
|
||||
is eventually distributed across machines.
|
||||
|
||||
### 5b. Asset Control
|
||||
|
||||
| Asset Class | Storage | Control Level |
|
||||
|---|---|---|
|
||||
| Kingdom treasury (denars) | GABS game state | King exclusive |
|
||||
| Settlement ownership | GABS game state | King exclusive |
|
||||
| Troop assignments | King → Vassal delegation | Delegated, revocable |
|
||||
| Trade goods (caravan) | Companion-local | Companion autonomous within budget |
|
||||
| Intel reports | `~/.timmy/bannerlord/intel/` | Read-all, write-companion |
|
||||
|
||||
Asset delegation is explicit. Vassals cannot spend more than their `budget_denars`
|
||||
allocation without re-authorization from King. Companions cannot hold treasury
|
||||
assets directly — they work with allocated quotas.
|
||||
|
||||
### 5c. Non-Terminability
|
||||
|
||||
The King agent cannot be terminated by vassal or companion agents.
|
||||
Termination authority is reserved for:
|
||||
1. The human operator (Ctrl+C or `timmy stop`)
|
||||
2. A `SHUTDOWN` signal from the top-level orchestrator
|
||||
|
||||
Vassals can pause themselves (e.g., awaiting GABS state) but cannot signal the King
|
||||
to stop. This prevents a misbehaving military vassal from ending the campaign.
|
||||
|
||||
Implementation: King runs in the main asyncio event loop. Vassals and companions
|
||||
run in `asyncio.TaskGroup` subgroups. Only the King's task holds a reference to
|
||||
the TaskGroup cancel scope.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Path
|
||||
|
||||
This design connects directly to the existing Timmy codebase:
|
||||
|
||||
| Component | Maps to | Notes |
|
||||
|---|---|---|
|
||||
| King LLM calls | `infrastructure/llm_router/` | Cascade router for model selection |
|
||||
| Subgoal Queue | `infrastructure/event_bus/` | Existing pub/sub pattern |
|
||||
| Companion primitives | New `src/bannerlord/agents/` package | One module per companion |
|
||||
| GABS state updates | `src/bannerlord/gabs_client.py` | TCP JSON-RPC, port 4825 |
|
||||
| Asset ledger | `src/bannerlord/ledger.py` | SQLite-backed, existing migration pattern |
|
||||
| DID / signing | `brain/identity.py` | Extends existing SOUL.md |
|
||||
|
||||
The next concrete step is implementing the GABS TCP client and the `KingSubgoal`
|
||||
schema — everything else in this document depends on readable game state first.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Ahilan, S. & Dayan, P. (2019). Feudal Multi-Agent Hierarchies for Cooperative
|
||||
Reinforcement Learning. https://arxiv.org/abs/1901.08492
|
||||
- Rood, S. (2022). Scaling Reinforcement Learning through Feudal Hierarchy (NPS thesis).
|
||||
- Wang, G. et al. (2023). Voyager: An Open-Ended Embodied Agent with Large Language
|
||||
Models. https://arxiv.org/abs/2305.16291
|
||||
- Park, J.S. et al. (2023). Generative Agents: Interactive Simulacra of Human Behavior.
|
||||
https://arxiv.org/abs/2304.03442
|
||||
- Silveira, T. (2022). CiF-Bannerlord: Social AI Integration in Bannerlord.
|
||||
726
poetry.lock
generated
726
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,6 @@ repository = "http://localhost:3000/rockachopa/Timmy-time-dashboard"
|
||||
packages = [
|
||||
{ include = "config.py", from = "src" },
|
||||
|
||||
{ include = "bannerlord", from = "src" },
|
||||
{ include = "dashboard", from = "src" },
|
||||
{ include = "infrastructure", from = "src" },
|
||||
{ include = "integrations", from = "src" },
|
||||
@@ -69,7 +68,7 @@ voice = ["pyttsx3", "openai-whisper", "piper-tts", "sounddevice"]
|
||||
celery = ["celery"]
|
||||
embeddings = ["sentence-transformers", "numpy"]
|
||||
git = ["GitPython"]
|
||||
research = ["requests", "trafilatura", "google-search-results"]
|
||||
research = ["requests", "trafilatura"]
|
||||
dev = ["pytest", "pytest-asyncio", "pytest-cov", "pytest-timeout", "pytest-randomly", "pytest-xdist", "selenium"]
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
|
||||
@@ -1,186 +1,66 @@
|
||||
#!/bin/bash
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# claude_quota_check.sh — Check Claude Code / Claude.ai quota
|
||||
#!/usr/bin/env bash
|
||||
# claude_quota_check.sh — Quick CLI check of Claude API quota and metabolic mode.
|
||||
#
|
||||
# Usage:
|
||||
# ./claude_quota_check.sh # Human-readable output
|
||||
# ./claude_quota_check.sh --json # Raw JSON for piping
|
||||
# ./claude_quota_check.sh --watch # Refresh every 60s
|
||||
# ./scripts/claude_quota_check.sh # Human-readable report
|
||||
# ./scripts/claude_quota_check.sh --mode # Print current mode only (BURST/ACTIVE/RESTING)
|
||||
# ./scripts/claude_quota_check.sh --json # JSON output for scripting
|
||||
#
|
||||
# Requires: macOS with Claude Code authenticated, python3
|
||||
# Token is read from macOS Keychain (same as Claude Code uses)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Refs: #1074, #972
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Extract OAuth token from macOS Keychain ──
|
||||
get_token() {
|
||||
local creds
|
||||
creds=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null) || {
|
||||
echo "ERROR: No Claude Code credentials found in Keychain." >&2
|
||||
echo "Run 'claude' and authenticate first." >&2
|
||||
exit 1
|
||||
}
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
SRC="${REPO_ROOT}/src"
|
||||
|
||||
echo "$creds" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
oauth = data.get('claudeAiOauth', data)
|
||||
print(oauth['accessToken'])
|
||||
" 2>/dev/null || {
|
||||
echo "ERROR: Could not parse credentials JSON." >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
# Ensure we can import the project Python modules
|
||||
export PYTHONPATH="${SRC}:${PYTHONPATH:-}"
|
||||
|
||||
# ── Fetch usage from Anthropic API ──
|
||||
fetch_usage() {
|
||||
local token="$1"
|
||||
curl -s "https://api.anthropic.com/api/oauth/usage" \
|
||||
-H "Accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "User-Agent: claude-code/2.0.32" \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
-H "anthropic-beta: oauth-2025-04-20"
|
||||
}
|
||||
MODE_ONLY=0
|
||||
JSON_OUTPUT=0
|
||||
|
||||
# ── Format time remaining ──
|
||||
time_remaining() {
|
||||
local reset_at="$1"
|
||||
if [ -z "$reset_at" ] || [ "$reset_at" = "null" ]; then
|
||||
echo "unknown"
|
||||
return
|
||||
fi
|
||||
|
||||
python3 -c "
|
||||
from datetime import datetime, timezone
|
||||
reset = datetime.fromisoformat('${reset_at}'.replace('Z', '+00:00'))
|
||||
now = datetime.now(timezone.utc)
|
||||
diff = reset - now
|
||||
if diff.total_seconds() <= 0:
|
||||
print('resetting now')
|
||||
else:
|
||||
hours = int(diff.total_seconds() // 3600)
|
||||
mins = int((diff.total_seconds() % 3600) // 60)
|
||||
if hours > 0:
|
||||
print(f'{hours}h {mins}m')
|
||||
else:
|
||||
print(f'{mins}m')
|
||||
" 2>/dev/null || echo "unknown"
|
||||
}
|
||||
|
||||
# ── Bar visualization ──
|
||||
usage_bar() {
|
||||
local pct=$1
|
||||
local width=30
|
||||
local filled
|
||||
filled=$(python3 -c "print(int(${pct} * ${width}))")
|
||||
local empty=$((width - filled))
|
||||
|
||||
# Color: green < 50%, yellow 50-80%, red > 80%
|
||||
local color=""
|
||||
if (( $(echo "$pct < 0.50" | bc -l) )); then
|
||||
color="\033[32m" # green
|
||||
elif (( $(echo "$pct < 0.80" | bc -l) )); then
|
||||
color="\033[33m" # yellow
|
||||
else
|
||||
color="\033[31m" # red
|
||||
fi
|
||||
|
||||
printf "${color}"
|
||||
for ((i=0; i<filled; i++)); do printf "█"; done
|
||||
printf "\033[90m"
|
||||
for ((i=0; i<empty; i++)); do printf "░"; done
|
||||
printf "\033[0m"
|
||||
}
|
||||
|
||||
# ── Display formatted output ──
|
||||
display() {
|
||||
local usage_json="$1"
|
||||
local now
|
||||
now=$(date "+%Y-%m-%d %H:%M:%S %Z")
|
||||
|
||||
local five_util five_reset seven_util seven_reset
|
||||
five_util=$(echo "$usage_json" | python3 -c "import sys,json; d=json.load(sys.stdin); h=d.get('five_hour') or {}; print(h.get('utilization', 0))" 2>/dev/null || echo "0")
|
||||
five_reset=$(echo "$usage_json" | python3 -c "import sys,json; d=json.load(sys.stdin); h=d.get('five_hour') or {}; print(h.get('resets_at', 'null'))" 2>/dev/null || echo "null")
|
||||
seven_util=$(echo "$usage_json" | python3 -c "import sys,json; d=json.load(sys.stdin); h=d.get('seven_day') or {}; print(h.get('utilization', 0))" 2>/dev/null || echo "0")
|
||||
seven_reset=$(echo "$usage_json" | python3 -c "import sys,json; d=json.load(sys.stdin); h=d.get('seven_day') or {}; print(h.get('resets_at', 'null'))" 2>/dev/null || echo "null")
|
||||
|
||||
local five_pct seven_pct
|
||||
five_pct=$(python3 -c "print(int(float('${five_util}') * 100))")
|
||||
seven_pct=$(python3 -c "print(int(float('${seven_util}') * 100))")
|
||||
|
||||
local five_remaining seven_remaining
|
||||
five_remaining=$(time_remaining "$five_reset")
|
||||
seven_remaining=$(time_remaining "$seven_reset")
|
||||
|
||||
echo ""
|
||||
echo " ┌─────────────────────────────────────────────┐"
|
||||
echo " │ CLAUDE QUOTA STATUS │"
|
||||
printf " │ %-38s│\n" "$now"
|
||||
echo " ├─────────────────────────────────────────────┤"
|
||||
printf " │ 5-hour window: "
|
||||
usage_bar "$five_util"
|
||||
printf " %3d%% │\n" "$five_pct"
|
||||
printf " │ Resets in: %-33s│\n" "$five_remaining"
|
||||
echo " │ │"
|
||||
printf " │ 7-day window: "
|
||||
usage_bar "$seven_util"
|
||||
printf " %3d%% │\n" "$seven_pct"
|
||||
printf " │ Resets in: %-33s│\n" "$seven_remaining"
|
||||
echo " └─────────────────────────────────────────────┘"
|
||||
echo ""
|
||||
|
||||
# Decision guidance for Timmy
|
||||
if (( five_pct >= 80 )); then
|
||||
echo " ⚠ 5-hour window critical. Switch to local Qwen3-14B."
|
||||
echo " Reserve remaining quota for high-value tasks only."
|
||||
elif (( five_pct >= 50 )); then
|
||||
echo " ~ 5-hour window half spent. Batch remaining requests."
|
||||
else
|
||||
echo " ✓ 5-hour window healthy. Full speed ahead."
|
||||
fi
|
||||
|
||||
if (( seven_pct >= 80 )); then
|
||||
echo " ⚠ Weekly quota critical! Operate in local-only mode."
|
||||
elif (( seven_pct >= 60 )); then
|
||||
echo " ~ Weekly quota past 60%. Plan usage carefully."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ── Main ──
|
||||
main() {
|
||||
local token
|
||||
token=$(get_token)
|
||||
|
||||
local usage
|
||||
usage=$(fetch_usage "$token")
|
||||
|
||||
if [ -z "$usage" ] || echo "$usage" | grep -q '"error"'; then
|
||||
echo "ERROR: Failed to fetch usage data." >&2
|
||||
echo "$usage" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
--json)
|
||||
echo "$usage" | python3 -m json.tool
|
||||
;;
|
||||
--watch)
|
||||
while true; do
|
||||
clear
|
||||
usage=$(fetch_usage "$token")
|
||||
display "$usage"
|
||||
echo " Refreshing in 60s... (Ctrl+C to stop)"
|
||||
sleep 60
|
||||
done
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--mode) MODE_ONLY=1 ;;
|
||||
--json) JSON_OUTPUT=1 ;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 [--mode|--json]"
|
||||
echo " (no flags) Human-readable quota report"
|
||||
echo " --mode Print current metabolic mode only"
|
||||
echo " --json JSON output for scripting"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
display "$usage"
|
||||
echo "Unknown flag: $arg" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
done
|
||||
|
||||
main "$@"
|
||||
if [[ $MODE_ONLY -eq 1 ]]; then
|
||||
python3 - <<'PYEOF'
|
||||
from infrastructure.claude_quota import current_mode
|
||||
print(current_mode())
|
||||
PYEOF
|
||||
|
||||
elif [[ $JSON_OUTPUT -eq 1 ]]; then
|
||||
python3 - <<'PYEOF'
|
||||
import json
|
||||
from infrastructure.claude_quota import get_quota_store
|
||||
store = get_quota_store()
|
||||
today = store.today_summary()
|
||||
month = store.month_summary()
|
||||
print(json.dumps({
|
||||
"today": today.as_dict(),
|
||||
"month": month.as_dict(),
|
||||
"current_mode": today.mode,
|
||||
}))
|
||||
PYEOF
|
||||
|
||||
else
|
||||
python3 - <<'PYEOF'
|
||||
from infrastructure.claude_quota import quota_report
|
||||
print(quota_report())
|
||||
PYEOF
|
||||
fi
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hermes 4 smoke test and tool-calling validation script.
|
||||
|
||||
Tests the Hermes 4 14B model after importing into Ollama. Covers:
|
||||
1. Basic connectivity — model responds
|
||||
2. Memory usage — under 28 GB with model loaded
|
||||
3. Tool calling — structured JSON output (not raw text)
|
||||
4. Reasoning — <think> tag toggling works
|
||||
5. Timmy-persona smoke test — agent identity prompt
|
||||
|
||||
Usage:
|
||||
python scripts/test_hermes4.py # Run all tests
|
||||
python scripts/test_hermes4.py --model hermes4-14b
|
||||
python scripts/test_hermes4.py --model hermes4-36b --ctx 8192
|
||||
|
||||
Epic: #1091 Project Bannerlord — AutoLoRA Sovereignty Loop (Step 2 of 7)
|
||||
Refs: #1101
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("ERROR: 'requests' not installed. Run: pip install requests")
|
||||
sys.exit(1)
|
||||
|
||||
OLLAMA_URL = "http://localhost:11434"
|
||||
DEFAULT_MODEL = "hermes4-14b"
|
||||
MEMORY_LIMIT_GB = 28.0
|
||||
|
||||
# ── Tool schema used for tool-calling tests ──────────────────────────────────
|
||||
|
||||
READ_FILE_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read the contents of a file at the given path",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Absolute or relative path to the file",
|
||||
}
|
||||
},
|
||||
"required": ["path"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
LIST_ISSUES_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_issues",
|
||||
"description": "List open issues from a Gitea repository",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repo": {"type": "string", "description": "owner/repo slug"},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": ["open", "closed", "all"],
|
||||
"description": "Issue state filter",
|
||||
},
|
||||
},
|
||||
"required": ["repo"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _post(endpoint: str, payload: dict, timeout: int = 60) -> dict[str, Any]:
|
||||
"""POST to Ollama and return parsed JSON."""
|
||||
url = f"{OLLAMA_URL}{endpoint}"
|
||||
resp = requests.post(url, json=payload, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _ollama_memory_gb() -> float:
|
||||
"""Estimate Ollama process RSS in GB using ps (macOS/Linux)."""
|
||||
try:
|
||||
# Look for ollama process RSS (macOS: column 6 in MB, Linux: column 6 in KB)
|
||||
result = subprocess.run(
|
||||
["ps", "-axo", "pid,comm,rss"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
total_kb = 0
|
||||
for line in result.stdout.splitlines():
|
||||
if "ollama" in line.lower():
|
||||
parts = line.split()
|
||||
try:
|
||||
total_kb += int(parts[-1])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
return total_kb / (1024 * 1024) # KB → GB
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _check_model_available(model: str) -> bool:
|
||||
"""Return True if model is listed in Ollama."""
|
||||
try:
|
||||
resp = requests.get(f"{OLLAMA_URL}/api/tags", timeout=10)
|
||||
resp.raise_for_status()
|
||||
names = [m["name"] for m in resp.json().get("models", [])]
|
||||
return any(model in n for n in names)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _chat(model: str, messages: list[dict], tools: list | None = None) -> dict:
|
||||
"""Send a chat request to Ollama."""
|
||||
payload: dict = {"model": model, "messages": messages, "stream": False}
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
return _post("/api/chat", payload, timeout=120)
|
||||
|
||||
|
||||
# ── Test cases ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_model_available(model: str) -> bool:
|
||||
"""PASS: model is registered in Ollama."""
|
||||
print(f"\n[1/5] Checking model availability: {model}")
|
||||
if _check_model_available(model):
|
||||
print(f" ✓ {model} is available in Ollama")
|
||||
return True
|
||||
print(
|
||||
f" ✗ {model} not found. Import with:\n"
|
||||
f" ollama create {model} -f Modelfile.hermes4-14b\n"
|
||||
f" Or pull directly if on registry:\n"
|
||||
f" ollama pull {model}"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def test_basic_response(model: str) -> bool:
|
||||
"""PASS: model responds coherently to a simple prompt."""
|
||||
print(f"\n[2/5] Basic response test")
|
||||
messages = [
|
||||
{"role": "user", "content": "Reply with exactly: HERMES_OK"},
|
||||
]
|
||||
try:
|
||||
t0 = time.time()
|
||||
data = _chat(model, messages)
|
||||
elapsed = time.time() - t0
|
||||
content = data.get("message", {}).get("content", "")
|
||||
if "HERMES_OK" in content:
|
||||
print(f" ✓ Basic response OK ({elapsed:.1f}s): {content.strip()}")
|
||||
return True
|
||||
print(f" ✗ Unexpected response ({elapsed:.1f}s): {content[:200]!r}")
|
||||
return False
|
||||
except Exception as exc:
|
||||
print(f" ✗ Request failed: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def test_memory_usage() -> bool:
|
||||
"""PASS: Ollama process RSS is under MEMORY_LIMIT_GB."""
|
||||
print(f"\n[3/5] Memory usage check (limit: {MEMORY_LIMIT_GB} GB)")
|
||||
mem_gb = _ollama_memory_gb()
|
||||
if mem_gb == 0.0:
|
||||
print(" ~ Could not determine memory usage (ps unavailable?), skipping")
|
||||
return True
|
||||
if mem_gb < MEMORY_LIMIT_GB:
|
||||
print(f" ✓ Memory usage: {mem_gb:.1f} GB (under {MEMORY_LIMIT_GB} GB limit)")
|
||||
return True
|
||||
print(
|
||||
f" ✗ Memory usage: {mem_gb:.1f} GB exceeds {MEMORY_LIMIT_GB} GB limit.\n"
|
||||
" Consider using Q4_K_M quantisation or reducing num_ctx."
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def test_tool_calling(model: str) -> bool:
|
||||
"""PASS: model produces a tool_calls response (not raw text) for a tool-use prompt."""
|
||||
print(f"\n[4/5] Tool-calling test")
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Please read the file at /tmp/test.txt using the read_file tool.",
|
||||
}
|
||||
]
|
||||
try:
|
||||
t0 = time.time()
|
||||
data = _chat(model, messages, tools=[READ_FILE_TOOL])
|
||||
elapsed = time.time() - t0
|
||||
msg = data.get("message", {})
|
||||
tool_calls = msg.get("tool_calls", [])
|
||||
|
||||
if tool_calls:
|
||||
tc = tool_calls[0]
|
||||
fn = tc.get("function", {})
|
||||
print(
|
||||
f" ✓ Tool call produced ({elapsed:.1f}s):\n"
|
||||
f" function: {fn.get('name')}\n"
|
||||
f" arguments: {json.dumps(fn.get('arguments', {}), indent=6)}"
|
||||
)
|
||||
# Verify the function name is correct
|
||||
return fn.get("name") == "read_file"
|
||||
|
||||
# Some models return JSON in the content instead of tool_calls
|
||||
content = msg.get("content", "")
|
||||
if "read_file" in content and "{" in content:
|
||||
print(
|
||||
f" ~ Model returned tool call as text (not structured). ({elapsed:.1f}s)\n"
|
||||
f" This is acceptable for the base model before fine-tuning.\n"
|
||||
f" Content: {content[:300]}"
|
||||
)
|
||||
# Partial pass — model attempted tool calling but via text
|
||||
return True
|
||||
|
||||
print(
|
||||
f" ✗ No tool call in response ({elapsed:.1f}s).\n"
|
||||
f" Content: {content[:300]!r}"
|
||||
)
|
||||
return False
|
||||
except Exception as exc:
|
||||
print(f" ✗ Tool-calling request failed: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def test_timmy_persona(model: str) -> bool:
|
||||
"""PASS: model accepts a Timmy persona system prompt and responds in-character."""
|
||||
print(f"\n[5/5] Timmy-persona smoke test")
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are Timmy, Alexander's personal AI agent. "
|
||||
"You are concise, direct, and helpful. "
|
||||
"You always start your responses with 'Timmy here:'."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What is your name and what can you help me with?",
|
||||
},
|
||||
]
|
||||
try:
|
||||
t0 = time.time()
|
||||
data = _chat(model, messages)
|
||||
elapsed = time.time() - t0
|
||||
content = data.get("message", {}).get("content", "")
|
||||
if "Timmy" in content or "timmy" in content.lower():
|
||||
print(f" ✓ Persona accepted ({elapsed:.1f}s): {content[:200].strip()}")
|
||||
return True
|
||||
print(
|
||||
f" ~ Persona response lacks 'Timmy' identifier ({elapsed:.1f}s).\n"
|
||||
f" This is a fine-tuning target.\n"
|
||||
f" Response: {content[:200]!r}"
|
||||
)
|
||||
# Soft pass — base model isn't expected to be perfectly in-character
|
||||
return True
|
||||
except Exception as exc:
|
||||
print(f" ✗ Persona test failed: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Hermes 4 smoke test suite")
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
default=DEFAULT_MODEL,
|
||||
help=f"Ollama model name (default: {DEFAULT_MODEL})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ollama-url",
|
||||
default=OLLAMA_URL,
|
||||
help=f"Ollama base URL (default: {OLLAMA_URL})",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
global OLLAMA_URL
|
||||
OLLAMA_URL = args.ollama_url.rstrip("/")
|
||||
model = args.model
|
||||
|
||||
print("=" * 60)
|
||||
print(f"Hermes 4 Validation Suite — {model}")
|
||||
print(f"Ollama: {OLLAMA_URL}")
|
||||
print("=" * 60)
|
||||
|
||||
results: dict[str, bool] = {}
|
||||
|
||||
# Test 1: availability (gate — skip remaining if model missing)
|
||||
results["available"] = test_model_available(model)
|
||||
if not results["available"]:
|
||||
print("\n⚠ Model not available — skipping remaining tests.")
|
||||
print(" Import the model first (see Modelfile.hermes4-14b).")
|
||||
_print_summary(results)
|
||||
return 1
|
||||
|
||||
# Tests 2–5
|
||||
results["basic_response"] = test_basic_response(model)
|
||||
results["memory_usage"] = test_memory_usage()
|
||||
results["tool_calling"] = test_tool_calling(model)
|
||||
results["timmy_persona"] = test_timmy_persona(model)
|
||||
|
||||
return _print_summary(results)
|
||||
|
||||
|
||||
def _print_summary(results: dict[str, bool]) -> int:
|
||||
passed = sum(results.values())
|
||||
total = len(results)
|
||||
print("\n" + "=" * 60)
|
||||
print(f"Results: {passed}/{total} passed")
|
||||
print("=" * 60)
|
||||
for name, ok in results.items():
|
||||
icon = "✓" if ok else "✗"
|
||||
print(f" {icon} {name}")
|
||||
|
||||
if passed == total:
|
||||
print("\n✓ All tests passed. Hermes 4 is ready for AutoLoRA fine-tuning.")
|
||||
print(" Next step: document WORK vs FAIL skill list → fine-tuning targets.")
|
||||
elif results.get("tool_calling") is False:
|
||||
print("\n⚠ Tool-calling FAILED. This is the primary fine-tuning target.")
|
||||
print(" Base model may need LoRA tuning on tool-use examples.")
|
||||
else:
|
||||
print("\n~ Partial pass. Review failures above before fine-tuning.")
|
||||
|
||||
return 0 if passed == total else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Bannerlord M3 — Full Campaign Strategy.
|
||||
|
||||
Timmy runs a complete Bannerlord campaign: economy, diplomacy, kingdom
|
||||
building, and war decisions — all via sovereign local inference.
|
||||
|
||||
Key components:
|
||||
gabs_client — TCP JSON-RPC client for the GABS mod (port 4825)
|
||||
types — KingSubgoal, GameState, message schemas
|
||||
session_memory — SQLite-backed multi-day strategic plan persistence
|
||||
campaign — CampaignOrchestrator tying all agents together
|
||||
adapter — WorldInterface adapter for use with the benchmark runner
|
||||
agents/ — King, Vassal, and Companion agent hierarchy
|
||||
|
||||
Quick start::
|
||||
|
||||
from bannerlord.campaign import CampaignOrchestrator
|
||||
orch = CampaignOrchestrator()
|
||||
summary = await orch.run(max_ticks=100)
|
||||
|
||||
Register the world adapter::
|
||||
|
||||
from infrastructure.world import register_adapter
|
||||
from bannerlord.adapter import BannerlordWorldAdapter
|
||||
register_adapter("bannerlord", BannerlordWorldAdapter)
|
||||
|
||||
M3 done-when condition:
|
||||
Timmy establishes own kingdom with 3+ fiefs and
|
||||
survives 100 in-game days as ruler.
|
||||
"""
|
||||
|
||||
from bannerlord.adapter import BannerlordWorldAdapter
|
||||
from bannerlord.campaign import CampaignOrchestrator
|
||||
from bannerlord.gabs_client import GABSClient
|
||||
from bannerlord.session_memory import SessionMemory
|
||||
from bannerlord.types import (
|
||||
GameState,
|
||||
KingSubgoal,
|
||||
SubgoalToken,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BannerlordWorldAdapter",
|
||||
"CampaignOrchestrator",
|
||||
"GABSClient",
|
||||
"GameState",
|
||||
"KingSubgoal",
|
||||
"SessionMemory",
|
||||
"SubgoalToken",
|
||||
]
|
||||
@@ -1,228 +0,0 @@
|
||||
"""Bannerlord M3 — WorldInterface adapter wrapping the GABS TCP client.
|
||||
|
||||
Plugs Bannerlord into the engine-agnostic ``WorldInterface`` contract so
|
||||
the benchmark runner and heartbeat loop can drive the campaign the same way
|
||||
they would drive any other game world.
|
||||
|
||||
Register with::
|
||||
|
||||
from infrastructure.world import register_adapter
|
||||
from bannerlord.adapter import BannerlordWorldAdapter
|
||||
register_adapter("bannerlord", BannerlordWorldAdapter)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from bannerlord.gabs_client import GABSClient
|
||||
from bannerlord.types import GameState
|
||||
from infrastructure.world.interface import WorldInterface
|
||||
from infrastructure.world.types import (
|
||||
ActionResult,
|
||||
ActionStatus,
|
||||
CommandInput,
|
||||
PerceptionOutput,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BannerlordWorldAdapter(WorldInterface):
|
||||
"""WorldInterface adapter for Bannerlord via the GABS mod.
|
||||
|
||||
``observe()`` — fetches the full GameState from GABS and maps it to a
|
||||
``PerceptionOutput`` with structured fields.
|
||||
|
||||
``act()`` — dispatches ``CommandInput.action`` as a GABS JSON-RPC call,
|
||||
forwarding ``parameters`` as the call args.
|
||||
|
||||
``speak()`` — sends a chat message via GABS (e.g., for companion NPC
|
||||
conversations or on-screen overlays).
|
||||
|
||||
Degrades gracefully when GABS is unavailable.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 4825,
|
||||
timeout: float = 10.0,
|
||||
) -> None:
|
||||
self._client = GABSClient(host=host, port=port, timeout=timeout)
|
||||
self._last_state: GameState | None = None
|
||||
|
||||
# -- lifecycle ---------------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Synchronous connect wrapper (runs async in a new event loop)."""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# Inside async context — caller should use async connect
|
||||
logger.warning(
|
||||
"BannerlordWorldAdapter.connect() called from async context; "
|
||||
"use 'await adapter.async_connect()' instead"
|
||||
)
|
||||
return
|
||||
loop.run_until_complete(self._client.connect())
|
||||
except Exception as exc:
|
||||
logger.warning("BannerlordWorldAdapter.connect() failed: %s", exc)
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Synchronous disconnect wrapper."""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
return
|
||||
loop.run_until_complete(self._client.disconnect())
|
||||
except Exception as exc:
|
||||
logger.debug("BannerlordWorldAdapter.disconnect() error: %s", exc)
|
||||
|
||||
async def async_connect(self) -> bool:
|
||||
"""Async connect — preferred in async contexts."""
|
||||
return await self._client.connect()
|
||||
|
||||
async def async_disconnect(self) -> None:
|
||||
"""Async disconnect."""
|
||||
await self._client.disconnect()
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._client.is_connected
|
||||
|
||||
# -- WorldInterface contract ------------------------------------------
|
||||
|
||||
def observe(self) -> PerceptionOutput:
|
||||
"""Return a PerceptionOutput derived from the current GABS GameState.
|
||||
|
||||
Falls back to an empty perception if GABS is unreachable.
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
logger.warning("observe() called from async context — use async_observe()")
|
||||
return self._empty_perception()
|
||||
state = loop.run_until_complete(self._client.get_game_state())
|
||||
return self._state_to_perception(state)
|
||||
except Exception as exc:
|
||||
logger.warning("BannerlordWorldAdapter.observe() error: %s", exc)
|
||||
return self._empty_perception()
|
||||
|
||||
async def async_observe(self) -> PerceptionOutput:
|
||||
"""Async observe — preferred in async contexts."""
|
||||
try:
|
||||
state = await self._client.get_game_state()
|
||||
self._last_state = state
|
||||
return self._state_to_perception(state)
|
||||
except Exception as exc:
|
||||
logger.warning("async_observe() error: %s", exc)
|
||||
return self._empty_perception()
|
||||
|
||||
def act(self, command: CommandInput) -> ActionResult:
|
||||
"""Dispatch a command to GABS. Returns success/failure."""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
logger.warning("act() called from async context — use async_act()")
|
||||
return ActionResult(status=ActionStatus.NOOP)
|
||||
result = loop.run_until_complete(self.async_act(command))
|
||||
return result
|
||||
except Exception as exc:
|
||||
logger.warning("BannerlordWorldAdapter.act() error: %s", exc)
|
||||
return ActionResult(
|
||||
status=ActionStatus.FAILURE,
|
||||
message=str(exc),
|
||||
)
|
||||
|
||||
async def async_act(self, command: CommandInput) -> ActionResult:
|
||||
"""Async command dispatch."""
|
||||
try:
|
||||
result = await self._client._call(
|
||||
command.action, command.parameters or {}
|
||||
)
|
||||
if result is None:
|
||||
return ActionResult(
|
||||
status=ActionStatus.FAILURE,
|
||||
message=f"GABS returned no result for {command.action}",
|
||||
)
|
||||
return ActionResult(
|
||||
status=ActionStatus.SUCCESS,
|
||||
message=f"GABS executed: {command.action}",
|
||||
data=result if isinstance(result, dict) else {"result": result},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("async_act(%s) error: %s", command.action, exc)
|
||||
return ActionResult(status=ActionStatus.FAILURE, message=str(exc))
|
||||
|
||||
def speak(self, message: str, target: str | None = None) -> None:
|
||||
"""Send a message via GABS (e.g., companion dialogue or overlay)."""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
return
|
||||
loop.run_until_complete(
|
||||
self._client._call("chat/send", {"message": message, "target": target})
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("BannerlordWorldAdapter.speak() error: %s", exc)
|
||||
|
||||
# -- helpers -----------------------------------------------------------
|
||||
|
||||
def _state_to_perception(self, state: GameState) -> PerceptionOutput:
|
||||
"""Map a GameState snapshot to a PerceptionOutput."""
|
||||
entities: list[str] = []
|
||||
events: list[str] = []
|
||||
|
||||
# Party location
|
||||
if state.party.location:
|
||||
entities.append(f"location:{state.party.location}")
|
||||
|
||||
# Kingdom status
|
||||
if state.has_kingdom():
|
||||
entities.append(f"kingdom:{state.kingdom.name}")
|
||||
for fief in state.kingdom.fiefs:
|
||||
entities.append(f"fief:{fief}")
|
||||
|
||||
# Active wars as events
|
||||
for war in state.kingdom.active_wars:
|
||||
events.append(f"at_war_with:{war}")
|
||||
|
||||
# Faction snapshot
|
||||
for faction in state.factions:
|
||||
entities.append(f"faction:{faction.name}[{faction.army_strength}]")
|
||||
|
||||
# Alerts
|
||||
if state.is_two_front_war():
|
||||
events.append("alert:two_front_war")
|
||||
if state.party.wounded_pct > 0.30:
|
||||
events.append(f"alert:wounded_{state.party.wounded_pct:.0%}")
|
||||
if state.party.food_days < 3:
|
||||
events.append("alert:low_food")
|
||||
|
||||
return PerceptionOutput(
|
||||
timestamp=datetime.now(UTC),
|
||||
location=state.party.location,
|
||||
entities=entities,
|
||||
events=events,
|
||||
raw=state.raw,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _empty_perception() -> PerceptionOutput:
|
||||
return PerceptionOutput(
|
||||
timestamp=datetime.now(UTC),
|
||||
location="",
|
||||
entities=[],
|
||||
events=["gabs:unavailable"],
|
||||
raw={"adapter": "bannerlord", "connected": False},
|
||||
)
|
||||
|
||||
@property
|
||||
def last_game_state(self) -> GameState | None:
|
||||
"""Return the most recently observed GameState."""
|
||||
return self._last_state
|
||||
@@ -1,4 +0,0 @@
|
||||
"""Bannerlord M3 — feudal agent hierarchy.
|
||||
|
||||
King → Vassal → Companion, following Ahilan & Dayan (2019).
|
||||
"""
|
||||
@@ -1 +0,0 @@
|
||||
"""Bannerlord M3 — Companion worker agents (lowest tier)."""
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Bannerlord M3 — Caravan Companion (trade operations).
|
||||
|
||||
Handles trade route assessment, buy/sell goods, caravan deployment.
|
||||
Triggered by TRADE subgoal or when treasury is below threshold.
|
||||
|
||||
Minimum margin threshold: 15% (never buy goods without ≥ 15% resale margin).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from bannerlord.types import GameState, KingSubgoal, SubgoalToken
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MIN_MARGIN_PCT = 0.15 # minimum profitable resale margin
|
||||
_CARAVAN_DENAR_THRESHOLD = 10_000 # must have 10k denars to deploy a caravan
|
||||
|
||||
|
||||
class CaravanCompanion:
|
||||
"""Companion worker for trade route management."""
|
||||
|
||||
AGENT_ID = "caravan_companion"
|
||||
|
||||
def evaluate(
|
||||
self, state: GameState, subgoal: KingSubgoal
|
||||
) -> list[dict]:
|
||||
"""Return trade primitives to execute.
|
||||
|
||||
Returns:
|
||||
List of dicts with 'primitive' and 'args' keys.
|
||||
"""
|
||||
if subgoal.token != SubgoalToken.TRADE:
|
||||
return []
|
||||
|
||||
actions: list[dict] = []
|
||||
party = state.party
|
||||
|
||||
# Always assess prices at current location first
|
||||
actions.append({
|
||||
"primitive": "assess_prices",
|
||||
"args": {"town": party.location},
|
||||
})
|
||||
|
||||
# Deploy a caravan if treasury is flush
|
||||
if party.denars >= _CARAVAN_DENAR_THRESHOLD and party.location:
|
||||
actions.append({
|
||||
"primitive": "establish_caravan",
|
||||
"args": {"town": party.location},
|
||||
})
|
||||
|
||||
return actions
|
||||
|
||||
@staticmethod
|
||||
def is_profitable_trade(buy_price: int, sell_price: int) -> bool:
|
||||
"""Return True if the trade margin meets the minimum threshold."""
|
||||
if buy_price <= 0:
|
||||
return False
|
||||
margin = (sell_price - buy_price) / buy_price
|
||||
return margin >= _MIN_MARGIN_PCT
|
||||
@@ -1,78 +0,0 @@
|
||||
"""Bannerlord M3 — Logistics Companion (party management).
|
||||
|
||||
Handles recruit, supply, rest, prisoner sale, and troop upgrade primitives.
|
||||
Runs on Qwen3:8b for sub-2-second response times.
|
||||
|
||||
Triggered by RECRUIT and HEAL subgoals, or by party condition thresholds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from bannerlord.types import GameState, KingSubgoal, SubgoalToken
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_FOOD_WARN_DAYS = 5
|
||||
_WOUND_WARN_PCT = 0.20
|
||||
_PRISONER_CAP = 20
|
||||
|
||||
|
||||
class LogisticsCompanion:
|
||||
"""Companion worker for party logistics.
|
||||
|
||||
Evaluates the current party state and returns a list of primitive
|
||||
action names + args to dispatch to the GABSClient.
|
||||
"""
|
||||
|
||||
AGENT_ID = "logistics_companion"
|
||||
|
||||
def evaluate(
|
||||
self, state: GameState, subgoal: KingSubgoal
|
||||
) -> list[dict]:
|
||||
"""Return primitives to execute given current state and active subgoal.
|
||||
|
||||
Returns:
|
||||
List of dicts with 'primitive' and 'args' keys.
|
||||
"""
|
||||
actions: list[dict] = []
|
||||
party = state.party
|
||||
|
||||
# Subgoal-driven recruitment
|
||||
if subgoal.token == SubgoalToken.RECRUIT:
|
||||
qty = subgoal.quantity or 20
|
||||
actions.append({
|
||||
"primitive": "recruit_troop",
|
||||
"args": {"troop_type": "infantry", "qty": qty},
|
||||
})
|
||||
|
||||
# Emergency rest on heavy wounds
|
||||
if subgoal.token == SubgoalToken.HEAL or party.wounded_pct > _WOUND_WARN_PCT:
|
||||
actions.append({
|
||||
"primitive": "rest_party",
|
||||
"args": {"days": 3},
|
||||
})
|
||||
|
||||
# Replenish food if low
|
||||
if party.food_days < _FOOD_WARN_DAYS:
|
||||
actions.append({
|
||||
"primitive": "buy_supplies",
|
||||
"args": {"qty": max(0, 10 - party.food_days)},
|
||||
})
|
||||
|
||||
# Sell prisoners when near cap
|
||||
if party.prisoners >= _PRISONER_CAP:
|
||||
actions.append({
|
||||
"primitive": "sell_prisoners",
|
||||
"args": {"location": party.location},
|
||||
})
|
||||
|
||||
# Upgrade troops when stable
|
||||
if subgoal.token == SubgoalToken.TRAIN:
|
||||
actions.append({
|
||||
"primitive": "upgrade_troops",
|
||||
"args": {},
|
||||
})
|
||||
|
||||
return actions
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Bannerlord M3 — Scout Companion (intelligence gathering).
|
||||
|
||||
Handles lord tracking, garrison assessment, and patrol route mapping.
|
||||
Triggered by SPY subgoal or proactively before expansion decisions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from bannerlord.types import GameState, KingSubgoal, SubgoalToken
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScoutCompanion:
|
||||
"""Companion worker for tactical intelligence."""
|
||||
|
||||
AGENT_ID = "scout_companion"
|
||||
|
||||
def evaluate(
|
||||
self, state: GameState, subgoal: KingSubgoal
|
||||
) -> list[dict]:
|
||||
"""Return scouting primitives to execute.
|
||||
|
||||
Returns:
|
||||
List of dicts with 'primitive' and 'args' keys.
|
||||
"""
|
||||
actions: list[dict] = []
|
||||
|
||||
if subgoal.token == SubgoalToken.SPY:
|
||||
target = subgoal.target
|
||||
if target:
|
||||
actions.append({
|
||||
"primitive": "track_lord",
|
||||
"args": {"lord_name": target},
|
||||
})
|
||||
|
||||
elif subgoal.token == SubgoalToken.EXPAND_TERRITORY:
|
||||
target = subgoal.target
|
||||
if target:
|
||||
actions.append({
|
||||
"primitive": "assess_garrison",
|
||||
"args": {"settlement": target},
|
||||
})
|
||||
|
||||
# Proactively map patrols in active war regions
|
||||
for war_faction in state.kingdom.active_wars:
|
||||
# Find a fief belonging to the enemy as the region reference
|
||||
for faction in state.factions:
|
||||
if faction.name == war_faction and faction.fiefs:
|
||||
actions.append({
|
||||
"primitive": "map_patrol_routes",
|
||||
"args": {"region": faction.fiefs[0]},
|
||||
})
|
||||
break # one region per enemy faction per tick
|
||||
|
||||
return actions
|
||||
@@ -1,145 +0,0 @@
|
||||
"""Bannerlord M3 — Diplomacy Vassal agent.
|
||||
|
||||
Handles relations management: alliances, peace deals, tribute, marriage.
|
||||
Responds to the ALLY subgoal.
|
||||
|
||||
Reward function:
|
||||
R_diplo = w1 * AlliesCount
|
||||
+ w2 * TruceDurationValue
|
||||
+ w3 * RelationsScore_weighted
|
||||
- w4 * ActiveWarsFront
|
||||
+ w5 * SubgoalBonus
|
||||
|
||||
Key strategic rules:
|
||||
- Never start a new war if already in 2+ wars (2-front war rule)
|
||||
- Prefer peace with weakest current enemy when overextended
|
||||
- Time alliances before declaring war to reduce isolation risk
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from bannerlord.types import (
|
||||
GameState,
|
||||
KingSubgoal,
|
||||
SubgoalToken,
|
||||
TaskMessage,
|
||||
VassalReward,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_W1_ALLIES = 0.30
|
||||
_W2_TRUCE = 0.25
|
||||
_W3_RELATIONS = 0.25
|
||||
_W4_WAR_FRONTS = 0.15
|
||||
_W5_SUBGOAL = 0.05
|
||||
|
||||
_SUBGOAL_TRIGGERS = {SubgoalToken.ALLY}
|
||||
_MAX_WAR_FRONTS = 2 # flag when at 2+ simultaneous wars (two-front war)
|
||||
|
||||
|
||||
class DiplomacyVassal:
|
||||
"""Mid-tier agent responsible for diplomatic relations."""
|
||||
|
||||
AGENT_ID = "diplomacy_vassal"
|
||||
|
||||
def is_relevant(self, subgoal: KingSubgoal) -> bool:
|
||||
return subgoal.token in _SUBGOAL_TRIGGERS
|
||||
|
||||
def plan(self, state: GameState, subgoal: KingSubgoal) -> list[TaskMessage]:
|
||||
"""Return TaskMessages for the current diplomatic subgoal."""
|
||||
tasks: list[TaskMessage] = []
|
||||
|
||||
if subgoal.token == SubgoalToken.ALLY:
|
||||
tasks.extend(self._plan_alliance(state, subgoal))
|
||||
|
||||
return tasks
|
||||
|
||||
def _plan_alliance(
|
||||
self, state: GameState, subgoal: KingSubgoal
|
||||
) -> list[TaskMessage]:
|
||||
"""Plan diplomatic outreach to reduce war fronts or build alliances."""
|
||||
tasks: list[TaskMessage] = []
|
||||
target = subgoal.target
|
||||
|
||||
if not target:
|
||||
logger.warning("DiplomacyVassal: no target for ALLY subgoal")
|
||||
return tasks
|
||||
|
||||
# If target is already an enemy, propose peace
|
||||
if target in state.kingdom.active_wars:
|
||||
tasks.append(
|
||||
TaskMessage(
|
||||
from_agent=self.AGENT_ID,
|
||||
to_agent="gabs",
|
||||
primitive="propose_peace",
|
||||
args={"faction": target, "tribute": 0},
|
||||
priority=subgoal.priority * 1.5,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Otherwise pursue alliance
|
||||
tasks.append(
|
||||
TaskMessage(
|
||||
from_agent=self.AGENT_ID,
|
||||
to_agent="gabs",
|
||||
primitive="send_envoy",
|
||||
args={
|
||||
"faction": target,
|
||||
"message": "We seek a lasting alliance for mutual defence.",
|
||||
},
|
||||
priority=subgoal.priority,
|
||||
)
|
||||
)
|
||||
tasks.append(
|
||||
TaskMessage(
|
||||
from_agent=self.AGENT_ID,
|
||||
to_agent="gabs",
|
||||
primitive="request_alliance",
|
||||
args={"faction": target},
|
||||
priority=subgoal.priority,
|
||||
)
|
||||
)
|
||||
|
||||
return tasks
|
||||
|
||||
def should_avoid_war(self, state: GameState) -> bool:
|
||||
"""Return True if starting a new war would be strategically unsound."""
|
||||
return state.active_war_count() >= _MAX_WAR_FRONTS # 2-front war check
|
||||
|
||||
def compute_reward(
|
||||
self,
|
||||
prev_state: GameState,
|
||||
curr_state: GameState,
|
||||
active_subgoal: KingSubgoal,
|
||||
) -> VassalReward:
|
||||
"""Compute Diplomacy Vassal reward."""
|
||||
allies_count = len(curr_state.kingdom.active_alliances)
|
||||
truce_value = 50.0 # placeholder — days of truce remaining
|
||||
relations_avg = 30.0 # placeholder — weighted relations score
|
||||
war_fronts = curr_state.active_war_count()
|
||||
|
||||
subgoal_bonus = 1.0 if active_subgoal.token in _SUBGOAL_TRIGGERS else 0.0
|
||||
|
||||
total = (
|
||||
_W1_ALLIES * allies_count * 10
|
||||
+ _W2_TRUCE * truce_value / 100
|
||||
+ _W3_RELATIONS * relations_avg / 100
|
||||
- _W4_WAR_FRONTS * war_fronts * 10
|
||||
+ _W5_SUBGOAL * subgoal_bonus * 10
|
||||
)
|
||||
|
||||
return VassalReward(
|
||||
agent_id=self.AGENT_ID,
|
||||
component_scores={
|
||||
"allies_count": allies_count,
|
||||
"truce_value": truce_value,
|
||||
"relations_avg": relations_avg,
|
||||
"war_fronts": -war_fronts,
|
||||
"subgoal_bonus": subgoal_bonus,
|
||||
},
|
||||
subgoal_bonus=subgoal_bonus,
|
||||
total=total,
|
||||
)
|
||||
@@ -1,151 +0,0 @@
|
||||
"""Bannerlord M3 — Economy Vassal agent.
|
||||
|
||||
Handles settlement management, tax collection, construction, and food supply.
|
||||
Responds to FORTIFY and CONSOLIDATE subgoals.
|
||||
|
||||
Reward function:
|
||||
R_econ = w1 * DailyDenarsIncome
|
||||
+ w2 * FoodStockBuffer
|
||||
+ w3 * LoyaltyAverage
|
||||
- w4 * ConstructionQueueLength
|
||||
+ w5 * SubgoalBonus
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from bannerlord.types import (
|
||||
GameState,
|
||||
KingSubgoal,
|
||||
SubgoalToken,
|
||||
TaskMessage,
|
||||
VassalReward,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_W1_INCOME = 0.35
|
||||
_W2_FOOD = 0.25
|
||||
_W3_LOYALTY = 0.20
|
||||
_W4_CONSTRUCTION = 0.15
|
||||
_W5_SUBGOAL = 0.05
|
||||
|
||||
_SUBGOAL_TRIGGERS = {SubgoalToken.FORTIFY, SubgoalToken.CONSOLIDATE}
|
||||
|
||||
_LOW_FOOD_THRESHOLD = 3 # days of food remaining
|
||||
_INCOME_TARGET = 200 # daily net income target (denars)
|
||||
|
||||
|
||||
class EconomyVassal:
|
||||
"""Mid-tier agent responsible for settlement economy."""
|
||||
|
||||
AGENT_ID = "economy_vassal"
|
||||
|
||||
def is_relevant(self, subgoal: KingSubgoal) -> bool:
|
||||
return subgoal.token in _SUBGOAL_TRIGGERS
|
||||
|
||||
def plan(self, state: GameState, subgoal: KingSubgoal) -> list[TaskMessage]:
|
||||
"""Return TaskMessages for the current economic subgoal."""
|
||||
tasks: list[TaskMessage] = []
|
||||
|
||||
# Always maintain food supply
|
||||
if state.party.food_days < _LOW_FOOD_THRESHOLD:
|
||||
tasks.append(
|
||||
TaskMessage(
|
||||
from_agent=self.AGENT_ID,
|
||||
to_agent="logistics_companion",
|
||||
primitive="buy_supplies",
|
||||
args={"qty": 10},
|
||||
priority=2.0,
|
||||
)
|
||||
)
|
||||
|
||||
if subgoal.token == SubgoalToken.FORTIFY:
|
||||
tasks.extend(self._plan_fortify(state, subgoal))
|
||||
elif subgoal.token == SubgoalToken.CONSOLIDATE:
|
||||
tasks.extend(self._plan_consolidate(state))
|
||||
|
||||
return tasks
|
||||
|
||||
def _plan_fortify(
|
||||
self, state: GameState, subgoal: KingSubgoal
|
||||
) -> list[TaskMessage]:
|
||||
"""Queue construction projects in owned settlements."""
|
||||
tasks: list[TaskMessage] = []
|
||||
target = subgoal.target or (state.kingdom.fiefs[0] if state.kingdom.fiefs else None)
|
||||
if not target:
|
||||
return tasks
|
||||
tasks.append(
|
||||
TaskMessage(
|
||||
from_agent=self.AGENT_ID,
|
||||
to_agent="gabs",
|
||||
primitive="build_project",
|
||||
args={"settlement": target, "project": "granary"},
|
||||
priority=1.2,
|
||||
)
|
||||
)
|
||||
tasks.append(
|
||||
TaskMessage(
|
||||
from_agent=self.AGENT_ID,
|
||||
to_agent="gabs",
|
||||
primitive="set_tax_policy",
|
||||
args={"settlement": target, "policy": "normal"},
|
||||
priority=1.0,
|
||||
)
|
||||
)
|
||||
return tasks
|
||||
|
||||
def _plan_consolidate(self, state: GameState) -> list[TaskMessage]:
|
||||
"""Stabilise: optimise tax and food across all fiefs."""
|
||||
tasks: list[TaskMessage] = []
|
||||
net_income = state.kingdom.daily_income - state.kingdom.daily_expenses
|
||||
for fief in state.kingdom.fiefs:
|
||||
policy = "normal" if net_income >= _INCOME_TARGET else "low"
|
||||
tasks.append(
|
||||
TaskMessage(
|
||||
from_agent=self.AGENT_ID,
|
||||
to_agent="gabs",
|
||||
primitive="set_tax_policy",
|
||||
args={"settlement": fief, "policy": policy},
|
||||
priority=0.8,
|
||||
)
|
||||
)
|
||||
return tasks
|
||||
|
||||
def compute_reward(
|
||||
self,
|
||||
prev_state: GameState,
|
||||
curr_state: GameState,
|
||||
active_subgoal: KingSubgoal,
|
||||
) -> VassalReward:
|
||||
"""Compute Economy Vassal reward."""
|
||||
income_delta = (
|
||||
curr_state.kingdom.daily_income - prev_state.kingdom.daily_income
|
||||
)
|
||||
food_buffer = curr_state.party.food_days
|
||||
loyalty_avg = 70.0 # placeholder — real value from GABS raw data
|
||||
queue_len = 0 # placeholder
|
||||
|
||||
subgoal_bonus = 1.0 if active_subgoal.token in _SUBGOAL_TRIGGERS else 0.0
|
||||
|
||||
total = (
|
||||
_W1_INCOME * income_delta
|
||||
+ _W2_FOOD * food_buffer
|
||||
+ _W3_LOYALTY * loyalty_avg / 100
|
||||
- _W4_CONSTRUCTION * queue_len
|
||||
+ _W5_SUBGOAL * subgoal_bonus * 10
|
||||
)
|
||||
|
||||
return VassalReward(
|
||||
agent_id=self.AGENT_ID,
|
||||
component_scores={
|
||||
"income_delta": income_delta,
|
||||
"food_buffer": food_buffer,
|
||||
"loyalty_avg": loyalty_avg,
|
||||
"queue_len": -queue_len,
|
||||
"subgoal_bonus": subgoal_bonus,
|
||||
},
|
||||
subgoal_bonus=subgoal_bonus,
|
||||
total=total,
|
||||
)
|
||||
@@ -1,266 +0,0 @@
|
||||
"""Bannerlord M3 — King agent (Timmy, strategic tier).
|
||||
|
||||
The King operates on the campaign-map timescale (1 decision per in-game day).
|
||||
He reads the full GameState and emits a single KingSubgoal token that vassals
|
||||
interpret. He uses Qwen3:32b via the LLM router.
|
||||
|
||||
Decision rules baked in (no LLM required for simple cases):
|
||||
- Never initiate a second war while already fighting one (avoid 2-front wars)
|
||||
- Prioritise HEAL when party wounds > 30 %
|
||||
- Prioritise RECRUIT when troops < 80
|
||||
- Prioritise TRADE when denars < 5,000
|
||||
- If kingdom has < 3 fiefs and no active war, prioritise EXPAND_TERRITORY
|
||||
- Default to CONSOLIDATE when conditions are stable
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from bannerlord.types import (
|
||||
GameState,
|
||||
KingSubgoal,
|
||||
SubgoalToken,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Hard thresholds for rule-based fallback decisions
|
||||
_MIN_TROOPS = 80
|
||||
_MIN_DENARS = 5_000
|
||||
_MAX_WOUND_PCT = 0.30
|
||||
_TARGET_FIEFS = 3
|
||||
_SURVIVAL_DAYS = 100
|
||||
|
||||
|
||||
class KingAgent:
|
||||
"""Strategic decision-maker for the Bannerlord campaign.
|
||||
|
||||
The King agent is sovereign — it cannot be terminated by vassals.
|
||||
It decides the active subgoal at most once per campaign tick.
|
||||
|
||||
Usage::
|
||||
|
||||
king = KingAgent(model="qwen3:32b")
|
||||
subgoal = king.decide(game_state)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model: str = "qwen3:32b",
|
||||
temperature: float = 0.1,
|
||||
) -> None:
|
||||
self._model = model
|
||||
self._temperature = temperature
|
||||
self._last_subgoal: KingSubgoal | None = None
|
||||
self._tick = 0
|
||||
self._session_id: str | None = None
|
||||
|
||||
def set_session(self, session_id: str) -> None:
|
||||
self._session_id = session_id
|
||||
|
||||
# -- primary decision interface ----------------------------------------
|
||||
|
||||
def decide(self, state: GameState) -> KingSubgoal:
|
||||
"""Return the King's subgoal for the current campaign tick.
|
||||
|
||||
Uses rule-based heuristics as the primary decision engine.
|
||||
LLM override can be wired in via ``_llm_decide`` in a future PR.
|
||||
|
||||
Args:
|
||||
state: Full game state snapshot from GABS.
|
||||
|
||||
Returns:
|
||||
A KingSubgoal to be broadcast to vassals.
|
||||
"""
|
||||
self._tick += 1
|
||||
subgoal = self._rule_based_decide(state)
|
||||
self._last_subgoal = subgoal
|
||||
logger.info(
|
||||
"King[tick=%d, day=%d] → %s (target=%s)",
|
||||
self._tick,
|
||||
state.in_game_day,
|
||||
subgoal.token,
|
||||
subgoal.target,
|
||||
)
|
||||
return subgoal
|
||||
|
||||
# -- rule-based strategy engine ----------------------------------------
|
||||
|
||||
def _rule_based_decide(self, state: GameState) -> KingSubgoal:
|
||||
"""Encode campaign strategy as prioritised decision rules.
|
||||
|
||||
Priority order (highest to lowest):
|
||||
1. Emergency: heal if heavily wounded
|
||||
2. Survival: recruit if dangerously low on troops
|
||||
3. Economy: earn income if broke
|
||||
4. Diplomacy: seek peace if in a 2-front war
|
||||
5. Expansion: take fiefs if not at war and need more territory
|
||||
6. Alliance: seek allies when preparing for war
|
||||
7. Default: consolidate and stabilise
|
||||
"""
|
||||
party = state.party
|
||||
kingdom = state.kingdom
|
||||
|
||||
# 1. Emergency heal
|
||||
if party.wounded_pct > _MAX_WOUND_PCT:
|
||||
return KingSubgoal(
|
||||
token=SubgoalToken.HEAL,
|
||||
context=f"{party.wounded_pct:.0%} of party is wounded — rest required",
|
||||
)
|
||||
|
||||
# 2. Critical recruitment
|
||||
if party.troops < _MIN_TROOPS:
|
||||
return KingSubgoal(
|
||||
token=SubgoalToken.RECRUIT,
|
||||
quantity=_MIN_TROOPS - party.troops,
|
||||
context=f"Party at {party.troops} troops — must reach {_MIN_TROOPS}",
|
||||
)
|
||||
|
||||
# 3. Destitute treasury
|
||||
if party.denars < _MIN_DENARS:
|
||||
return KingSubgoal(
|
||||
token=SubgoalToken.TRADE,
|
||||
context=f"Treasury at {party.denars:,} denars — run trade routes",
|
||||
)
|
||||
|
||||
# 4. Avoid 2-front war: seek peace when fighting 2+ enemies
|
||||
if state.is_two_front_war():
|
||||
# Pick the weakest enemy to negotiate peace with first
|
||||
weakest = self._weakest_enemy(state)
|
||||
return KingSubgoal(
|
||||
token=SubgoalToken.ALLY,
|
||||
target=weakest,
|
||||
context="2-front war detected — de-escalate with weakest enemy",
|
||||
)
|
||||
|
||||
# 5. Kingdom not yet established: work toward first fief
|
||||
if not state.has_kingdom():
|
||||
if party.troops >= 120 and state.active_war_count() == 0:
|
||||
target_fief = self._select_expansion_target(state)
|
||||
return KingSubgoal(
|
||||
token=SubgoalToken.EXPAND_TERRITORY,
|
||||
target=target_fief,
|
||||
context="No kingdom yet — capture a fief to establish one",
|
||||
)
|
||||
elif state.active_war_count() == 0:
|
||||
# Not ready to fight; train up first
|
||||
return KingSubgoal(
|
||||
token=SubgoalToken.TRAIN,
|
||||
context="Building army before first expansion",
|
||||
)
|
||||
|
||||
# 6. Expand if below target fief count and no active war
|
||||
if state.fief_count() < _TARGET_FIEFS and state.active_war_count() == 0:
|
||||
target_fief = self._select_expansion_target(state)
|
||||
return KingSubgoal(
|
||||
token=SubgoalToken.EXPAND_TERRITORY,
|
||||
target=target_fief,
|
||||
priority=1.5,
|
||||
context=f"Only {state.fief_count()} fiefs — need {_TARGET_FIEFS}",
|
||||
)
|
||||
|
||||
# 7. Seek allies when stable and below fief target
|
||||
if not kingdom.active_alliances and state.active_war_count() == 0:
|
||||
ally_candidate = self._best_alliance_candidate(state)
|
||||
if ally_candidate:
|
||||
return KingSubgoal(
|
||||
token=SubgoalToken.ALLY,
|
||||
target=ally_candidate,
|
||||
context="Stable moment — pursue defensive alliance",
|
||||
)
|
||||
|
||||
# 8. Fortify if kingdom exists and there are fiefs to improve
|
||||
if state.has_kingdom() and state.fief_count() > 0:
|
||||
if kingdom.daily_income - kingdom.daily_expenses < 100:
|
||||
return KingSubgoal(
|
||||
token=SubgoalToken.FORTIFY,
|
||||
context="Low net income — invest in settlements",
|
||||
)
|
||||
|
||||
# 9. Default: consolidate
|
||||
return KingSubgoal(
|
||||
token=SubgoalToken.CONSOLIDATE,
|
||||
context="Stable — hold territory and recover strength",
|
||||
)
|
||||
|
||||
# -- helper methods ----------------------------------------------------
|
||||
|
||||
def _weakest_enemy(self, state: GameState) -> str | None:
|
||||
"""Return the name of the weakest faction currently at war with us."""
|
||||
enemy_names = set(state.kingdom.active_wars)
|
||||
enemies = [f for f in state.factions if f.name in enemy_names]
|
||||
if not enemies:
|
||||
return None
|
||||
return min(enemies, key=lambda f: f.army_strength).name
|
||||
|
||||
def _select_expansion_target(self, state: GameState) -> str | None:
|
||||
"""Select the most vulnerable enemy settlement to target."""
|
||||
# Prefer factions already at war with others (distracted)
|
||||
for faction in state.factions:
|
||||
if len(faction.is_at_war_with) >= 2 and faction.fiefs:
|
||||
return faction.fiefs[0]
|
||||
# Fallback: weakest faction with fiefs
|
||||
candidates = [f for f in state.factions if f.fiefs]
|
||||
if candidates:
|
||||
weakest = min(candidates, key=lambda f: f.army_strength)
|
||||
return weakest.fiefs[0]
|
||||
return None
|
||||
|
||||
def _best_alliance_candidate(self, state: GameState) -> str | None:
|
||||
"""Return the best faction to approach for an alliance."""
|
||||
# Prefer factions with good relations and no active war with us
|
||||
enemy_names = set(state.kingdom.active_wars)
|
||||
candidates = [
|
||||
f
|
||||
for f in state.factions
|
||||
if f.name not in enemy_names
|
||||
and f.name != state.kingdom.name
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
# Pick the strongest candidate (most useful ally)
|
||||
return max(candidates, key=lambda f: f.army_strength).name
|
||||
|
||||
# -- accessors ---------------------------------------------------------
|
||||
|
||||
@property
|
||||
def last_subgoal(self) -> KingSubgoal | None:
|
||||
return self._last_subgoal
|
||||
|
||||
@property
|
||||
def tick(self) -> int:
|
||||
return self._tick
|
||||
|
||||
def campaign_summary(self, state: GameState) -> dict[str, Any]:
|
||||
"""Return a brief summary of the campaign status."""
|
||||
return {
|
||||
"tick": self._tick,
|
||||
"in_game_day": state.in_game_day,
|
||||
"has_kingdom": state.has_kingdom(),
|
||||
"fief_count": state.fief_count(),
|
||||
"active_wars": state.active_war_count(),
|
||||
"two_front_war": state.is_two_front_war(),
|
||||
"troops": state.party.troops,
|
||||
"denars": state.party.denars,
|
||||
"survival_goal_met": (
|
||||
state.has_kingdom()
|
||||
and state.fief_count() >= _TARGET_FIEFS
|
||||
and state.in_game_day >= _SURVIVAL_DAYS
|
||||
),
|
||||
}
|
||||
|
||||
def is_done_condition_met(self, state: GameState) -> bool:
|
||||
"""Return True when the M3 done-when condition is satisfied.
|
||||
|
||||
Done when: Timmy establishes own kingdom with 3+ fiefs and
|
||||
survives 100 in-game days as ruler.
|
||||
"""
|
||||
return (
|
||||
state.has_kingdom()
|
||||
and state.fief_count() >= _TARGET_FIEFS
|
||||
and state.in_game_day >= _SURVIVAL_DAYS
|
||||
)
|
||||
@@ -1,236 +0,0 @@
|
||||
"""Bannerlord M3 — War Vassal agent.
|
||||
|
||||
Handles military operations: sieges, field battles, raids, defensive
|
||||
maneuvers. Responds to EXPAND_TERRITORY, RAID_ECONOMY, and TRAIN subgoals.
|
||||
|
||||
Reward function (from feudal hierarchy design):
|
||||
R_war = w1 * ΔTerritoryValue
|
||||
+ w2 * ΔArmyStrength_ratio
|
||||
- w3 * CasualtyCost
|
||||
- w4 * SupplyCost
|
||||
+ w5 * SubgoalBonus
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bannerlord.types import (
|
||||
GameState,
|
||||
KingSubgoal,
|
||||
SubgoalToken,
|
||||
TaskMessage,
|
||||
VassalReward,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Reward weights
|
||||
_W1_TERRITORY = 0.40
|
||||
_W2_ARMY_RATIO = 0.25
|
||||
_W3_CASUALTY = 0.20
|
||||
_W4_SUPPLY = 0.10
|
||||
_W5_SUBGOAL = 0.05
|
||||
|
||||
_SUBGOAL_TRIGGERS = {
|
||||
SubgoalToken.EXPAND_TERRITORY,
|
||||
SubgoalToken.RAID_ECONOMY,
|
||||
SubgoalToken.TRAIN,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WarContext:
|
||||
"""Mutable state tracked across War Vassal decisions."""
|
||||
|
||||
active_siege: str | None = None
|
||||
last_auto_resolve_result: dict = field(default_factory=dict)
|
||||
territory_gained: int = 0
|
||||
casualties_taken: int = 0
|
||||
|
||||
|
||||
class WarVassal:
|
||||
"""Mid-tier agent responsible for military operations.
|
||||
|
||||
Runs at 4× the King's decision frequency. Translates KingSubgoals
|
||||
into concrete TaskMessages for the Logistics Companion (troop management)
|
||||
and issues direct GABS calls for combat actions.
|
||||
"""
|
||||
|
||||
AGENT_ID = "war_vassal"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._ctx = WarContext()
|
||||
self._prev_army_ratio: float = 1.0
|
||||
|
||||
def is_relevant(self, subgoal: KingSubgoal) -> bool:
|
||||
"""Return True if this vassal should act on *subgoal*."""
|
||||
return subgoal.token in _SUBGOAL_TRIGGERS
|
||||
|
||||
def plan(self, state: GameState, subgoal: KingSubgoal) -> list[TaskMessage]:
|
||||
"""Return a list of TaskMessages for the current subgoal.
|
||||
|
||||
Args:
|
||||
state: Current game state.
|
||||
subgoal: Active King subgoal.
|
||||
|
||||
Returns:
|
||||
Ordered list of TaskMessages to dispatch.
|
||||
"""
|
||||
tasks: list[TaskMessage] = []
|
||||
|
||||
if subgoal.token == SubgoalToken.EXPAND_TERRITORY:
|
||||
tasks.extend(self._plan_expansion(state, subgoal))
|
||||
elif subgoal.token == SubgoalToken.RAID_ECONOMY:
|
||||
tasks.extend(self._plan_raid(state, subgoal))
|
||||
elif subgoal.token == SubgoalToken.TRAIN:
|
||||
tasks.extend(self._plan_training(state))
|
||||
|
||||
return tasks
|
||||
|
||||
def _plan_expansion(
|
||||
self, state: GameState, subgoal: KingSubgoal
|
||||
) -> list[TaskMessage]:
|
||||
"""Plan territory expansion toward subgoal.target."""
|
||||
tasks: list[TaskMessage] = []
|
||||
target = subgoal.target
|
||||
|
||||
if not target:
|
||||
logger.warning("WarVassal.EXPAND_TERRITORY: no target in subgoal")
|
||||
return tasks
|
||||
|
||||
# Scout garrison before sieging
|
||||
tasks.append(
|
||||
TaskMessage(
|
||||
from_agent=self.AGENT_ID,
|
||||
to_agent="scout_companion",
|
||||
primitive="assess_garrison",
|
||||
args={"settlement": target},
|
||||
priority=1.5,
|
||||
)
|
||||
)
|
||||
|
||||
# Ensure troops are sufficient (delegate to logistics if thin)
|
||||
if state.party.troops < 100:
|
||||
tasks.append(
|
||||
TaskMessage(
|
||||
from_agent=self.AGENT_ID,
|
||||
to_agent="logistics_companion",
|
||||
primitive="recruit_troop",
|
||||
args={"troop_type": "infantry", "qty": 100 - state.party.troops},
|
||||
priority=1.8,
|
||||
)
|
||||
)
|
||||
|
||||
# Issue the siege order
|
||||
tasks.append(
|
||||
TaskMessage(
|
||||
from_agent=self.AGENT_ID,
|
||||
to_agent="gabs",
|
||||
primitive="siege_settlement",
|
||||
args={"settlement": target},
|
||||
priority=subgoal.priority,
|
||||
)
|
||||
)
|
||||
# Follow up with auto-resolve
|
||||
tasks.append(
|
||||
TaskMessage(
|
||||
from_agent=self.AGENT_ID,
|
||||
to_agent="gabs",
|
||||
primitive="auto_resolve_battle",
|
||||
args={},
|
||||
priority=subgoal.priority,
|
||||
)
|
||||
)
|
||||
return tasks
|
||||
|
||||
def _plan_raid(
|
||||
self, state: GameState, subgoal: KingSubgoal
|
||||
) -> list[TaskMessage]:
|
||||
"""Plan economy raid for denars and food."""
|
||||
tasks: list[TaskMessage] = []
|
||||
target = subgoal.target or self._nearest_enemy_village(state)
|
||||
if not target:
|
||||
return tasks
|
||||
tasks.append(
|
||||
TaskMessage(
|
||||
from_agent=self.AGENT_ID,
|
||||
to_agent="gabs",
|
||||
primitive="raid_village",
|
||||
args={"village": target},
|
||||
priority=subgoal.priority,
|
||||
)
|
||||
)
|
||||
return tasks
|
||||
|
||||
def _plan_training(self, state: GameState) -> list[TaskMessage]:
|
||||
"""Plan troop training via auto-resolve bandit fights."""
|
||||
return [
|
||||
TaskMessage(
|
||||
from_agent=self.AGENT_ID,
|
||||
to_agent="logistics_companion",
|
||||
primitive="upgrade_troops",
|
||||
args={},
|
||||
priority=0.8,
|
||||
)
|
||||
]
|
||||
|
||||
# -- reward computation ------------------------------------------------
|
||||
|
||||
def compute_reward(
|
||||
self,
|
||||
prev_state: GameState,
|
||||
curr_state: GameState,
|
||||
active_subgoal: KingSubgoal,
|
||||
) -> VassalReward:
|
||||
"""Compute the War Vassal reward signal for the last decision cycle."""
|
||||
territory_delta = (
|
||||
curr_state.fief_count() - prev_state.fief_count()
|
||||
) * 100.0
|
||||
|
||||
prev_strength = max(prev_state.party.troops, 1)
|
||||
curr_strength = curr_state.party.troops
|
||||
army_delta = (curr_strength - prev_strength) / prev_strength
|
||||
|
||||
casualties = max(0, prev_state.party.troops - curr_strength)
|
||||
supply_burn = max(0, prev_state.party.food_days - curr_state.party.food_days)
|
||||
|
||||
subgoal_bonus = (
|
||||
1.0 if active_subgoal.token in _SUBGOAL_TRIGGERS else 0.0
|
||||
)
|
||||
|
||||
total = (
|
||||
_W1_TERRITORY * territory_delta
|
||||
+ _W2_ARMY_RATIO * army_delta * 10
|
||||
- _W3_CASUALTY * casualties
|
||||
- _W4_SUPPLY * supply_burn
|
||||
+ _W5_SUBGOAL * subgoal_bonus * 10
|
||||
)
|
||||
|
||||
return VassalReward(
|
||||
agent_id=self.AGENT_ID,
|
||||
component_scores={
|
||||
"territory": territory_delta,
|
||||
"army_ratio": army_delta,
|
||||
"casualties": -casualties,
|
||||
"supply_burn": -supply_burn,
|
||||
"subgoal_bonus": subgoal_bonus,
|
||||
},
|
||||
subgoal_bonus=subgoal_bonus,
|
||||
total=total,
|
||||
)
|
||||
|
||||
# -- helpers -----------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _nearest_enemy_village(state: GameState) -> str | None:
|
||||
enemy_names = set(state.kingdom.active_wars)
|
||||
for faction in state.factions:
|
||||
if faction.name in enemy_names and faction.fiefs:
|
||||
return faction.fiefs[0]
|
||||
return None
|
||||
@@ -1,270 +0,0 @@
|
||||
"""Bannerlord M3 — Campaign orchestrator.
|
||||
|
||||
Ties together the King agent, vassals, companions, GABS client, and session
|
||||
memory into a single async campaign loop.
|
||||
|
||||
Architecture::
|
||||
|
||||
CampaignOrchestrator.run()
|
||||
├── GABSClient.get_game_state() → GameState
|
||||
├── KingAgent.decide(state) → KingSubgoal
|
||||
├── SessionMemory.log_subgoal(...)
|
||||
├── WarVassal.plan(state, subgoal) → [TaskMessage]
|
||||
├── EconomyVassal.plan(state, subgoal) → [TaskMessage]
|
||||
├── DiplomacyVassal.plan(state, subgoal)→ [TaskMessage]
|
||||
├── [Companions].evaluate(state, subgoal) → [primitives]
|
||||
└── _dispatch_tasks([...]) → GABS calls
|
||||
|
||||
Usage::
|
||||
|
||||
from bannerlord.campaign import CampaignOrchestrator
|
||||
orch = CampaignOrchestrator()
|
||||
await orch.run(max_ticks=100)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from bannerlord.agents.companions.caravan import CaravanCompanion
|
||||
from bannerlord.agents.companions.logistics import LogisticsCompanion
|
||||
from bannerlord.agents.companions.scout import ScoutCompanion
|
||||
from bannerlord.agents.diplomacy_vassal import DiplomacyVassal
|
||||
from bannerlord.agents.economy_vassal import EconomyVassal
|
||||
from bannerlord.agents.king import KingAgent
|
||||
from bannerlord.agents.war_vassal import WarVassal
|
||||
from bannerlord.gabs_client import GABSClient
|
||||
from bannerlord.session_memory import SessionMemory
|
||||
from bannerlord.types import GameState, KingSubgoal, TaskMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_TICK_INTERVAL = 1.0 # seconds between campaign ticks (real time)
|
||||
_DEFAULT_DB_PATH = Path("data/bannerlord/campaign.db")
|
||||
_KINGDOM_NAME = "House Timmerson"
|
||||
|
||||
|
||||
class CampaignOrchestrator:
|
||||
"""Full-campaign strategy orchestrator for Bannerlord M3.
|
||||
|
||||
Runs the King → Vassal → Companion decision loop on each campaign tick.
|
||||
Persists progress to SQLite via SessionMemory.
|
||||
|
||||
Args:
|
||||
gabs_host: Hostname where GABS mod is listening.
|
||||
gabs_port: TCP port for GABS JSON-RPC (default 4825).
|
||||
tick_interval: Real-time seconds between campaign ticks.
|
||||
db_path: Path to the SQLite session memory database.
|
||||
session_id: Existing session to resume (None = new session).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
gabs_host: str = "127.0.0.1",
|
||||
gabs_port: int = 4825,
|
||||
tick_interval: float = _DEFAULT_TICK_INTERVAL,
|
||||
db_path: Path = _DEFAULT_DB_PATH,
|
||||
session_id: str | None = None,
|
||||
) -> None:
|
||||
self._gabs = GABSClient(host=gabs_host, port=gabs_port)
|
||||
self._king = KingAgent()
|
||||
self._war = WarVassal()
|
||||
self._economy = EconomyVassal()
|
||||
self._diplomacy = DiplomacyVassal()
|
||||
self._logistics = LogisticsCompanion()
|
||||
self._caravan = CaravanCompanion()
|
||||
self._scout = ScoutCompanion()
|
||||
self._memory = SessionMemory(db_path)
|
||||
self._tick_interval = tick_interval
|
||||
self._session_id = session_id
|
||||
self._running = False
|
||||
self._prev_state: GameState | None = None
|
||||
|
||||
# -- lifecycle ---------------------------------------------------------
|
||||
|
||||
async def start(self) -> str:
|
||||
"""Connect to GABS and initialise a campaign session.
|
||||
|
||||
Returns the active session_id.
|
||||
"""
|
||||
connected = await self._gabs.connect()
|
||||
if not connected:
|
||||
logger.warning(
|
||||
"CampaignOrchestrator: GABS unavailable — campaign will run "
|
||||
"in degraded mode (no game state updates)"
|
||||
)
|
||||
|
||||
if self._session_id is None:
|
||||
self._session_id = self._memory.start_session()
|
||||
else:
|
||||
# Resume existing session
|
||||
existing = self._memory.get_session(self._session_id)
|
||||
if not existing:
|
||||
self._session_id = self._memory.start_session(self._session_id)
|
||||
|
||||
self._king.set_session(self._session_id)
|
||||
logger.info("CampaignOrchestrator: session=%s", self._session_id)
|
||||
return self._session_id
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Gracefully stop the campaign and disconnect from GABS."""
|
||||
self._running = False
|
||||
await self._gabs.disconnect()
|
||||
logger.info("CampaignOrchestrator: stopped")
|
||||
|
||||
# -- main campaign loop ------------------------------------------------
|
||||
|
||||
async def run(self, max_ticks: int = 0) -> dict[str, Any]:
|
||||
"""Run the campaign loop.
|
||||
|
||||
Args:
|
||||
max_ticks: Stop after this many ticks. 0 = run indefinitely.
|
||||
|
||||
Returns:
|
||||
Campaign summary dict.
|
||||
"""
|
||||
if not self._session_id:
|
||||
await self.start()
|
||||
|
||||
self._running = True
|
||||
tick = 0
|
||||
|
||||
logger.info(
|
||||
"CampaignOrchestrator: starting campaign loop (max_ticks=%d)",
|
||||
max_ticks,
|
||||
)
|
||||
|
||||
try:
|
||||
while self._running:
|
||||
if max_ticks > 0 and tick >= max_ticks:
|
||||
break
|
||||
|
||||
await self._tick(tick)
|
||||
tick += 1
|
||||
|
||||
# Check done condition
|
||||
if self._prev_state and self._king.is_done_condition_met(
|
||||
self._prev_state
|
||||
):
|
||||
logger.info(
|
||||
"CampaignOrchestrator: M3 DONE condition met on tick %d!", tick
|
||||
)
|
||||
self._memory.add_note(
|
||||
self._session_id or "",
|
||||
self._prev_state.in_game_day,
|
||||
"milestone",
|
||||
"M3 done condition met — kingdom with 3+ fiefs, 100 days survived",
|
||||
)
|
||||
break
|
||||
|
||||
await asyncio.sleep(self._tick_interval)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("CampaignOrchestrator: loop cancelled")
|
||||
finally:
|
||||
await self.stop()
|
||||
|
||||
return self._summary(tick)
|
||||
|
||||
async def _tick(self, tick: int) -> None:
|
||||
"""Execute one campaign tick."""
|
||||
# 1. Observe
|
||||
state = await self._gabs.get_game_state()
|
||||
state.tick = tick
|
||||
|
||||
# 2. King decides
|
||||
subgoal = self._king.decide(state)
|
||||
|
||||
# 3. Log subgoal to session memory
|
||||
if self._session_id:
|
||||
row_id = self._memory.log_subgoal(
|
||||
self._session_id, tick, state.in_game_day, subgoal
|
||||
)
|
||||
self._memory.update_session(
|
||||
self._session_id,
|
||||
in_game_day=state.in_game_day,
|
||||
fief_count=state.fief_count(),
|
||||
kingdom_name=state.kingdom.name or None,
|
||||
)
|
||||
|
||||
# 4. Vassal planning
|
||||
tasks: list[TaskMessage] = []
|
||||
if self._war.is_relevant(subgoal):
|
||||
tasks.extend(self._war.plan(state, subgoal))
|
||||
if self._economy.is_relevant(subgoal):
|
||||
tasks.extend(self._economy.plan(state, subgoal))
|
||||
if self._diplomacy.is_relevant(subgoal):
|
||||
tasks.extend(self._diplomacy.plan(state, subgoal))
|
||||
|
||||
# 5. Companion evaluation
|
||||
companion_actions = (
|
||||
self._logistics.evaluate(state, subgoal)
|
||||
+ self._caravan.evaluate(state, subgoal)
|
||||
+ self._scout.evaluate(state, subgoal)
|
||||
)
|
||||
|
||||
# 6. Dispatch tasks + companion primitives to GABS
|
||||
await self._dispatch_tasks(tasks, state)
|
||||
await self._dispatch_primitives(companion_actions)
|
||||
|
||||
# 7. Kingdom establishment check
|
||||
if not state.has_kingdom() and state.fief_count() > 0:
|
||||
# We have a fief but no kingdom yet — establish one
|
||||
ok = await self._gabs.establish_kingdom(_KINGDOM_NAME)
|
||||
if ok and self._session_id:
|
||||
self._memory.record_kingdom_established(
|
||||
self._session_id, state.in_game_day, _KINGDOM_NAME
|
||||
)
|
||||
|
||||
self._prev_state = state
|
||||
logger.debug(
|
||||
"Tick %d: day=%d, subgoal=%s, tasks=%d, companions=%d",
|
||||
tick,
|
||||
state.in_game_day,
|
||||
subgoal.token,
|
||||
len(tasks),
|
||||
len(companion_actions),
|
||||
)
|
||||
|
||||
# -- task dispatch -----------------------------------------------------
|
||||
|
||||
async def _dispatch_tasks(
|
||||
self, tasks: list[TaskMessage], state: GameState
|
||||
) -> None:
|
||||
"""Dispatch vassal TaskMessages to GABS."""
|
||||
for task in sorted(tasks, key=lambda t: t.priority, reverse=True):
|
||||
if task.to_agent != "gabs":
|
||||
# Companion-directed tasks are handled via companion.evaluate()
|
||||
continue
|
||||
await self._gabs._call(task.primitive, task.args)
|
||||
|
||||
async def _dispatch_primitives(self, actions: list[dict]) -> None:
|
||||
"""Dispatch companion primitive actions to GABS."""
|
||||
for action in actions:
|
||||
primitive = action.get("primitive", "")
|
||||
args = action.get("args", {})
|
||||
if primitive:
|
||||
await self._gabs._call(primitive, args)
|
||||
|
||||
# -- summary -----------------------------------------------------------
|
||||
|
||||
def _summary(self, ticks_run: int) -> dict[str, Any]:
|
||||
state = self._prev_state or GameState()
|
||||
summary = self._king.campaign_summary(state)
|
||||
summary["ticks_run"] = ticks_run
|
||||
summary["session_id"] = self._session_id
|
||||
return summary
|
||||
|
||||
# -- accessors ---------------------------------------------------------
|
||||
|
||||
@property
|
||||
def session_id(self) -> str | None:
|
||||
return self._session_id
|
||||
|
||||
@property
|
||||
def memory(self) -> SessionMemory:
|
||||
return self._memory
|
||||
@@ -1,434 +0,0 @@
|
||||
"""GABS TCP JSON-RPC client — connects to the Bannerlord.GABS mod.
|
||||
|
||||
The GABS (Game Agent Behavior System) mod exposes 90+ tools via a
|
||||
TCP JSON-RPC 2.0 server on port 4825. This client wraps the transport
|
||||
into a clean async interface used by all Bannerlord agents.
|
||||
|
||||
Degrades gracefully: if GABS is unreachable, methods return sensible
|
||||
fallbacks and log a warning (never crash).
|
||||
|
||||
Architecture reference:
|
||||
Timmy (Qwen3 on Ollama, M3 Max)
|
||||
→ GABSClient (this module, TCP JSON-RPC, port 4825)
|
||||
→ Bannerlord.GABS C# mod
|
||||
→ Game API + Harmony
|
||||
→ Bannerlord (Windows VM)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from bannerlord.types import (
|
||||
FactionState,
|
||||
GameState,
|
||||
KingdomState,
|
||||
PartyState,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_HOST = "127.0.0.1"
|
||||
_DEFAULT_PORT = 4825
|
||||
_DEFAULT_TIMEOUT = 10.0 # seconds
|
||||
_RECONNECT_DELAY = 5.0 # seconds between reconnect attempts
|
||||
|
||||
|
||||
@dataclass
|
||||
class GABSTool:
|
||||
"""Metadata for a single GABS tool."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
parameters: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class GABSConnectionError(Exception):
|
||||
"""Raised when GABS is unreachable and a fallback is not possible."""
|
||||
|
||||
|
||||
class GABSClient:
|
||||
"""Async TCP JSON-RPC 2.0 client for the Bannerlord.GABS mod.
|
||||
|
||||
Usage::
|
||||
|
||||
async with GABSClient() as client:
|
||||
state = await client.get_game_state()
|
||||
await client.move_party("Vlandia")
|
||||
|
||||
All public methods degrade gracefully — they return ``None`` or an
|
||||
empty structure when GABS is unavailable.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = _DEFAULT_HOST,
|
||||
port: int = _DEFAULT_PORT,
|
||||
timeout: float = _DEFAULT_TIMEOUT,
|
||||
) -> None:
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._timeout = timeout
|
||||
self._reader: asyncio.StreamReader | None = None
|
||||
self._writer: asyncio.StreamWriter | None = None
|
||||
self._connected = False
|
||||
self._call_id = 0
|
||||
self._available_tools: list[GABSTool] = []
|
||||
|
||||
# -- lifecycle ---------------------------------------------------------
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Open a TCP connection to GABS.
|
||||
|
||||
Returns:
|
||||
True if connected successfully, False if GABS is unavailable.
|
||||
"""
|
||||
try:
|
||||
self._reader, self._writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(self._host, self._port),
|
||||
timeout=self._timeout,
|
||||
)
|
||||
self._connected = True
|
||||
logger.info("GABSClient connected to %s:%d", self._host, self._port)
|
||||
await self._discover_tools()
|
||||
return True
|
||||
except (ConnectionRefusedError, OSError, TimeoutError, asyncio.TimeoutError) as exc:
|
||||
logger.warning(
|
||||
"GABSClient could not connect to %s:%d — %s",
|
||||
self._host,
|
||||
self._port,
|
||||
exc,
|
||||
)
|
||||
self._connected = False
|
||||
return False
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Close the TCP connection."""
|
||||
if self._writer is not None:
|
||||
try:
|
||||
self._writer.close()
|
||||
await self._writer.wait_closed()
|
||||
except Exception as exc:
|
||||
logger.debug("GABSClient disconnect error: %s", exc)
|
||||
self._connected = False
|
||||
logger.info("GABSClient disconnected")
|
||||
|
||||
async def __aenter__(self) -> GABSClient:
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
await self.disconnect()
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
# -- raw JSON-RPC transport --------------------------------------------
|
||||
|
||||
def _next_id(self) -> int:
|
||||
self._call_id += 1
|
||||
return self._call_id
|
||||
|
||||
async def _call(self, method: str, params: dict[str, Any] | None = None) -> Any:
|
||||
"""Send a JSON-RPC 2.0 request and return the result.
|
||||
|
||||
Returns ``None`` and logs a warning on any error.
|
||||
"""
|
||||
if not self._connected:
|
||||
logger.warning("GABSClient._call(%s): not connected", method)
|
||||
return None
|
||||
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": self._next_id(),
|
||||
"method": method,
|
||||
"params": params or {},
|
||||
}
|
||||
|
||||
try:
|
||||
payload = json.dumps(request) + "\n"
|
||||
assert self._writer is not None
|
||||
self._writer.write(payload.encode())
|
||||
await asyncio.wait_for(self._writer.drain(), timeout=self._timeout)
|
||||
|
||||
assert self._reader is not None
|
||||
raw = await asyncio.wait_for(
|
||||
self._reader.readline(), timeout=self._timeout
|
||||
)
|
||||
response = json.loads(raw.decode().strip())
|
||||
|
||||
if "error" in response:
|
||||
logger.warning(
|
||||
"GABS error for %s: %s", method, response["error"].get("message")
|
||||
)
|
||||
return None
|
||||
|
||||
return response.get("result")
|
||||
|
||||
except (asyncio.TimeoutError, json.JSONDecodeError, AssertionError, OSError) as exc:
|
||||
logger.warning("GABSClient._call(%s) failed: %s", method, exc)
|
||||
self._connected = False
|
||||
return None
|
||||
|
||||
# -- tool discovery ----------------------------------------------------
|
||||
|
||||
async def _discover_tools(self) -> None:
|
||||
"""Populate self._available_tools via GABS tools/list."""
|
||||
result = await self._call("tools/list")
|
||||
if not result:
|
||||
return
|
||||
self._available_tools = [
|
||||
GABSTool(
|
||||
name=t.get("name", ""),
|
||||
description=t.get("description", ""),
|
||||
parameters=t.get("parameters", {}),
|
||||
)
|
||||
for t in (result if isinstance(result, list) else [])
|
||||
]
|
||||
logger.info("GABS: discovered %d tools", len(self._available_tools))
|
||||
|
||||
@property
|
||||
def available_tools(self) -> list[GABSTool]:
|
||||
"""Return the list of tools discovered from GABS."""
|
||||
return list(self._available_tools)
|
||||
|
||||
def tool_count(self) -> int:
|
||||
return len(self._available_tools)
|
||||
|
||||
# -- game state --------------------------------------------------------
|
||||
|
||||
async def get_game_state(self) -> GameState:
|
||||
"""Return the full campaign state snapshot.
|
||||
|
||||
Falls back to an empty GameState if GABS is unavailable.
|
||||
"""
|
||||
raw = await self._call("game/get_state")
|
||||
if raw is None:
|
||||
return GameState()
|
||||
|
||||
try:
|
||||
return self._parse_game_state(raw)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to parse GABS game state: %s", exc)
|
||||
return GameState()
|
||||
|
||||
def _parse_game_state(self, raw: dict[str, Any]) -> GameState:
|
||||
"""Convert raw GABS state dict into a typed GameState."""
|
||||
party_raw = raw.get("party", {})
|
||||
kingdom_raw = raw.get("kingdom", {})
|
||||
factions_raw = raw.get("factions", [])
|
||||
|
||||
party = PartyState(
|
||||
location=party_raw.get("location", ""),
|
||||
troops=party_raw.get("troops", 0),
|
||||
food_days=party_raw.get("food_days", 0),
|
||||
wounded_pct=party_raw.get("wounded_pct", 0.0),
|
||||
denars=party_raw.get("denars", 0),
|
||||
morale=party_raw.get("morale", 100.0),
|
||||
prisoners=party_raw.get("prisoners", 0),
|
||||
)
|
||||
|
||||
kingdom = KingdomState(
|
||||
name=kingdom_raw.get("name", ""),
|
||||
fiefs=kingdom_raw.get("fiefs", []),
|
||||
daily_income=kingdom_raw.get("daily_income", 0),
|
||||
daily_expenses=kingdom_raw.get("daily_expenses", 0),
|
||||
vassal_lords=kingdom_raw.get("vassal_lords", []),
|
||||
active_wars=kingdom_raw.get("active_wars", []),
|
||||
active_alliances=kingdom_raw.get("active_alliances", []),
|
||||
in_game_day=raw.get("in_game_day", 0),
|
||||
)
|
||||
|
||||
factions = [
|
||||
FactionState(
|
||||
name=f.get("name", ""),
|
||||
leader=f.get("leader", ""),
|
||||
fiefs=f.get("fiefs", []),
|
||||
army_strength=f.get("army_strength", 0),
|
||||
treasury=f.get("treasury", 0),
|
||||
is_at_war_with=f.get("is_at_war_with", []),
|
||||
relations=f.get("relations", {}),
|
||||
)
|
||||
for f in (factions_raw if isinstance(factions_raw, list) else [])
|
||||
]
|
||||
|
||||
return GameState(
|
||||
tick=raw.get("tick", 0),
|
||||
in_game_day=raw.get("in_game_day", 0),
|
||||
timestamp=datetime.now(UTC),
|
||||
party=party,
|
||||
kingdom=kingdom,
|
||||
factions=factions,
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
# -- party actions -----------------------------------------------------
|
||||
|
||||
async def move_party(self, destination: str) -> bool:
|
||||
"""Command Timmy's party to move toward *destination*."""
|
||||
result = await self._call("party/move", {"destination": destination})
|
||||
return result is not None
|
||||
|
||||
async def recruit_troops(self, troop_type: str, quantity: int) -> bool:
|
||||
"""Recruit *quantity* troops of *troop_type* at current location."""
|
||||
result = await self._call(
|
||||
"party/recruit",
|
||||
{"troop_type": troop_type, "quantity": quantity},
|
||||
)
|
||||
return result is not None
|
||||
|
||||
async def buy_supplies(self, quantity: int) -> bool:
|
||||
"""Purchase food supplies for *quantity* days of march."""
|
||||
result = await self._call("party/buy_supplies", {"quantity": quantity})
|
||||
return result is not None
|
||||
|
||||
async def rest_party(self, days: int) -> bool:
|
||||
"""Rest the party in current location for *days* in-game days."""
|
||||
result = await self._call("party/rest", {"days": days})
|
||||
return result is not None
|
||||
|
||||
async def auto_resolve_battle(self) -> dict[str, Any]:
|
||||
"""Trigger auto-resolve for the current battle.
|
||||
|
||||
Returns the battle outcome dict, or empty dict on failure.
|
||||
"""
|
||||
result = await self._call("battle/auto_resolve")
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
async def upgrade_troops(self) -> bool:
|
||||
"""Spend accumulated XP on troop tier upgrades."""
|
||||
result = await self._call("party/upgrade_troops")
|
||||
return result is not None
|
||||
|
||||
async def sell_prisoners(self, location: str) -> int:
|
||||
"""Sell prisoners at *location*. Returns denars gained."""
|
||||
result = await self._call("party/sell_prisoners", {"location": location})
|
||||
if isinstance(result, dict):
|
||||
return result.get("denars_gained", 0)
|
||||
return 0
|
||||
|
||||
# -- trade actions -----------------------------------------------------
|
||||
|
||||
async def assess_prices(self, town: str) -> dict[str, Any]:
|
||||
"""Query buy/sell prices at *town*."""
|
||||
result = await self._call("trade/assess_prices", {"town": town})
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
async def buy_goods(self, item: str, quantity: int) -> bool:
|
||||
"""Purchase *quantity* of *item* at current location."""
|
||||
result = await self._call("trade/buy", {"item": item, "quantity": quantity})
|
||||
return result is not None
|
||||
|
||||
async def sell_goods(self, item: str, quantity: int, location: str) -> bool:
|
||||
"""Sell *quantity* of *item* at *location*."""
|
||||
result = await self._call(
|
||||
"trade/sell",
|
||||
{"item": item, "quantity": quantity, "location": location},
|
||||
)
|
||||
return result is not None
|
||||
|
||||
async def establish_caravan(self, town: str) -> bool:
|
||||
"""Deploy a caravan NPC at *town*."""
|
||||
result = await self._call("trade/establish_caravan", {"town": town})
|
||||
return result is not None
|
||||
|
||||
# -- diplomacy actions -------------------------------------------------
|
||||
|
||||
async def send_envoy(self, faction: str, message: str) -> bool:
|
||||
"""Send a diplomatic message to *faction*."""
|
||||
result = await self._call(
|
||||
"diplomacy/send_envoy",
|
||||
{"faction": faction, "message": message},
|
||||
)
|
||||
return result is not None
|
||||
|
||||
async def propose_peace(self, faction: str, tribute: int = 0) -> bool:
|
||||
"""Propose peace with *faction*, optionally offering *tribute* denars."""
|
||||
result = await self._call(
|
||||
"diplomacy/propose_peace",
|
||||
{"faction": faction, "tribute": tribute},
|
||||
)
|
||||
return result is not None
|
||||
|
||||
async def request_alliance(self, faction: str) -> bool:
|
||||
"""Request a military alliance with *faction*."""
|
||||
result = await self._call(
|
||||
"diplomacy/request_alliance",
|
||||
{"faction": faction},
|
||||
)
|
||||
return result is not None
|
||||
|
||||
async def request_military_access(self, faction: str) -> bool:
|
||||
"""Request military access through *faction*'s territory."""
|
||||
result = await self._call(
|
||||
"diplomacy/military_access",
|
||||
{"faction": faction},
|
||||
)
|
||||
return result is not None
|
||||
|
||||
# -- settlement / kingdom actions --------------------------------------
|
||||
|
||||
async def siege_settlement(self, settlement: str) -> bool:
|
||||
"""Begin siege of *settlement*."""
|
||||
result = await self._call("military/siege", {"settlement": settlement})
|
||||
return result is not None
|
||||
|
||||
async def raid_village(self, village: str) -> bool:
|
||||
"""Raid *village* for food and denars."""
|
||||
result = await self._call("military/raid_village", {"village": village})
|
||||
return result is not None
|
||||
|
||||
async def build_project(self, settlement: str, project: str) -> bool:
|
||||
"""Queue a construction *project* in *settlement*."""
|
||||
result = await self._call(
|
||||
"settlement/build",
|
||||
{"settlement": settlement, "project": project},
|
||||
)
|
||||
return result is not None
|
||||
|
||||
async def set_tax_policy(self, settlement: str, policy: str) -> bool:
|
||||
"""Set tax *policy* for *settlement* (e.g. 'low', 'normal', 'high')."""
|
||||
result = await self._call(
|
||||
"settlement/set_tax",
|
||||
{"settlement": settlement, "policy": policy},
|
||||
)
|
||||
return result is not None
|
||||
|
||||
async def appoint_governor(self, settlement: str, lord: str) -> bool:
|
||||
"""Appoint *lord* as governor of *settlement*."""
|
||||
result = await self._call(
|
||||
"settlement/appoint_governor",
|
||||
{"settlement": settlement, "lord": lord},
|
||||
)
|
||||
return result is not None
|
||||
|
||||
async def establish_kingdom(self, name: str) -> bool:
|
||||
"""Declare a new kingdom with *name* (requires a captured fief)."""
|
||||
result = await self._call("kingdom/establish", {"name": name})
|
||||
ok = result is not None
|
||||
if ok:
|
||||
logger.info("Kingdom '%s' established!", name)
|
||||
return ok
|
||||
|
||||
# -- scouting ----------------------------------------------------------
|
||||
|
||||
async def track_lord(self, lord_name: str) -> dict[str, Any]:
|
||||
"""Shadow *lord_name* and return their last-known position."""
|
||||
result = await self._call("scout/track_lord", {"lord": lord_name})
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
async def assess_garrison(self, settlement: str) -> dict[str, Any]:
|
||||
"""Estimate defender count for *settlement*."""
|
||||
result = await self._call("scout/assess_garrison", {"settlement": settlement})
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
async def map_patrol_routes(self, region: str) -> list[dict[str, Any]]:
|
||||
"""Log enemy patrol routes in *region*."""
|
||||
result = await self._call("scout/patrol_routes", {"region": region})
|
||||
return result if isinstance(result, list) else []
|
||||
@@ -1,347 +0,0 @@
|
||||
"""Bannerlord M3 — Session memory for multi-day strategic plans.
|
||||
|
||||
Persists the King's strategic plans, completed subgoals, and kingdom
|
||||
milestones to SQLite so the campaign can be interrupted and resumed
|
||||
across multiple sessions.
|
||||
|
||||
Pattern follows the existing EventBus persistence model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from bannerlord.types import KingSubgoal, SubgoalToken
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS campaign_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
last_updated TEXT NOT NULL,
|
||||
kingdom_name TEXT DEFAULT '',
|
||||
in_game_day INTEGER DEFAULT 0,
|
||||
fief_count INTEGER DEFAULT 0,
|
||||
meta TEXT DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subgoal_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
tick INTEGER NOT NULL,
|
||||
in_game_day INTEGER NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
target TEXT,
|
||||
quantity INTEGER,
|
||||
priority REAL DEFAULT 1.0,
|
||||
deadline_days INTEGER,
|
||||
context TEXT,
|
||||
issued_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
outcome TEXT DEFAULT 'pending'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS strategy_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
in_game_day INTEGER NOT NULL,
|
||||
note_type TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
recorded_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_subgoal_session ON subgoal_log(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subgoal_tick ON subgoal_log(tick);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_session ON strategy_notes(session_id);
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CampaignMilestone:
|
||||
"""A notable campaign achievement recorded in session memory."""
|
||||
|
||||
in_game_day: int
|
||||
event: str
|
||||
detail: str = ""
|
||||
recorded_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class SessionMemory:
|
||||
"""SQLite-backed session memory for the Bannerlord campaign.
|
||||
|
||||
Stores:
|
||||
- Active session metadata (kingdom name, in-game day, fief count)
|
||||
- Full subgoal history (every KingSubgoal issued and its outcome)
|
||||
- Strategy notes / milestones for campaign reflection
|
||||
|
||||
Usage::
|
||||
|
||||
mem = SessionMemory(Path("data/bannerlord/campaign.db"))
|
||||
session_id = mem.start_session()
|
||||
mem.log_subgoal(session_id, tick=1, day=42, subgoal)
|
||||
mem.complete_subgoal(subgoal_id, outcome="success")
|
||||
mem.add_note(session_id, day=42, note_type="milestone",
|
||||
content="Kingdom established: House Timmerson")
|
||||
history = mem.get_recent_subgoals(session_id, limit=10)
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path) -> None:
|
||||
self._db_path = db_path
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self) -> None:
|
||||
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with closing(sqlite3.connect(str(self._db_path))) as conn:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
conn.executescript(_SCHEMA)
|
||||
conn.commit()
|
||||
|
||||
def _conn(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(self._db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
return conn
|
||||
|
||||
# -- session lifecycle -------------------------------------------------
|
||||
|
||||
def start_session(self, session_id: str | None = None) -> str:
|
||||
"""Create a new campaign session. Returns the session_id."""
|
||||
if session_id is None:
|
||||
session_id = f"session_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}"
|
||||
now = datetime.now(UTC).isoformat()
|
||||
with closing(self._conn()) as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO campaign_sessions "
|
||||
"(session_id, started_at, last_updated) VALUES (?, ?, ?)",
|
||||
(session_id, now, now),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("SessionMemory: started campaign session %s", session_id)
|
||||
return session_id
|
||||
|
||||
def update_session(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
kingdom_name: str | None = None,
|
||||
in_game_day: int | None = None,
|
||||
fief_count: int | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Update the campaign session state."""
|
||||
now = datetime.now(UTC).isoformat()
|
||||
with closing(self._conn()) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM campaign_sessions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return
|
||||
|
||||
current_meta = json.loads(row["meta"] or "{}")
|
||||
if meta:
|
||||
current_meta.update(meta)
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE campaign_sessions SET
|
||||
last_updated = ?,
|
||||
kingdom_name = COALESCE(?, kingdom_name),
|
||||
in_game_day = COALESCE(?, in_game_day),
|
||||
fief_count = COALESCE(?, fief_count),
|
||||
meta = ?
|
||||
WHERE session_id = ?""",
|
||||
(
|
||||
now,
|
||||
kingdom_name,
|
||||
in_game_day,
|
||||
fief_count,
|
||||
json.dumps(current_meta),
|
||||
session_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_session(self, session_id: str) -> dict[str, Any] | None:
|
||||
"""Return session metadata dict or None if not found."""
|
||||
with closing(self._conn()) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM campaign_sessions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return dict(row)
|
||||
|
||||
def list_sessions(self) -> list[dict[str, Any]]:
|
||||
"""Return all campaign sessions, most recent first."""
|
||||
with closing(self._conn()) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM campaign_sessions ORDER BY last_updated DESC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# -- subgoal log -------------------------------------------------------
|
||||
|
||||
def log_subgoal(
|
||||
self,
|
||||
session_id: str,
|
||||
tick: int,
|
||||
in_game_day: int,
|
||||
subgoal: KingSubgoal,
|
||||
) -> int:
|
||||
"""Record a subgoal emission. Returns the row id."""
|
||||
with closing(self._conn()) as conn:
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO subgoal_log
|
||||
(session_id, tick, in_game_day, token, target, quantity,
|
||||
priority, deadline_days, context, issued_at, outcome)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')""",
|
||||
(
|
||||
session_id,
|
||||
tick,
|
||||
in_game_day,
|
||||
str(subgoal.token),
|
||||
subgoal.target,
|
||||
subgoal.quantity,
|
||||
subgoal.priority,
|
||||
subgoal.deadline_days,
|
||||
subgoal.context,
|
||||
subgoal.issued_at.isoformat(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.lastrowid or 0
|
||||
|
||||
def complete_subgoal(self, row_id: int, outcome: str = "success") -> None:
|
||||
"""Mark a subgoal log entry as completed."""
|
||||
with closing(self._conn()) as conn:
|
||||
conn.execute(
|
||||
"UPDATE subgoal_log SET completed_at = ?, outcome = ? WHERE id = ?",
|
||||
(datetime.now(UTC).isoformat(), outcome, row_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_recent_subgoals(
|
||||
self, session_id: str, limit: int = 20
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return the *limit* most recent subgoal log entries."""
|
||||
with closing(self._conn()) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM subgoal_log WHERE session_id = ? "
|
||||
"ORDER BY tick DESC LIMIT ?",
|
||||
(session_id, limit),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def count_token(self, session_id: str, token: SubgoalToken) -> int:
|
||||
"""Count how many times a subgoal token has been issued in a session."""
|
||||
with closing(self._conn()) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) as n FROM subgoal_log "
|
||||
"WHERE session_id = ? AND token = ?",
|
||||
(session_id, str(token)),
|
||||
).fetchone()
|
||||
return row["n"] if row else 0
|
||||
|
||||
# -- strategy notes ----------------------------------------------------
|
||||
|
||||
def add_note(
|
||||
self,
|
||||
session_id: str,
|
||||
in_game_day: int,
|
||||
note_type: str,
|
||||
content: str,
|
||||
) -> None:
|
||||
"""Record a strategy note or milestone."""
|
||||
with closing(self._conn()) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO strategy_notes "
|
||||
"(session_id, in_game_day, note_type, content, recorded_at) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
session_id,
|
||||
in_game_day,
|
||||
note_type,
|
||||
content,
|
||||
datetime.now(UTC).isoformat(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_notes(
|
||||
self,
|
||||
session_id: str,
|
||||
note_type: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return strategy notes, optionally filtered by type."""
|
||||
with closing(self._conn()) as conn:
|
||||
if note_type:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM strategy_notes "
|
||||
"WHERE session_id = ? AND note_type = ? "
|
||||
"ORDER BY in_game_day DESC LIMIT ?",
|
||||
(session_id, note_type, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM strategy_notes WHERE session_id = ? "
|
||||
"ORDER BY in_game_day DESC LIMIT ?",
|
||||
(session_id, limit),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_milestones(self, session_id: str) -> list[dict[str, Any]]:
|
||||
"""Return all milestone notes for a session."""
|
||||
return self.get_notes(session_id, note_type="milestone", limit=200)
|
||||
|
||||
# -- diplomatic memory -------------------------------------------------
|
||||
|
||||
def record_war_declared(
|
||||
self, session_id: str, in_game_day: int, faction: str
|
||||
) -> None:
|
||||
"""Log that Timmy declared war on *faction*."""
|
||||
self.add_note(
|
||||
session_id,
|
||||
in_game_day,
|
||||
"war_declared",
|
||||
f"Declared war on {faction}",
|
||||
)
|
||||
|
||||
def record_peace_agreed(
|
||||
self, session_id: str, in_game_day: int, faction: str
|
||||
) -> None:
|
||||
"""Log that Timmy agreed to peace with *faction*."""
|
||||
self.add_note(
|
||||
session_id,
|
||||
in_game_day,
|
||||
"peace_agreed",
|
||||
f"Peace agreed with {faction}",
|
||||
)
|
||||
|
||||
def record_kingdom_established(
|
||||
self, session_id: str, in_game_day: int, kingdom_name: str
|
||||
) -> None:
|
||||
"""Record the kingdom establishment milestone."""
|
||||
self.add_note(
|
||||
session_id,
|
||||
in_game_day,
|
||||
"milestone",
|
||||
f"Kingdom established: {kingdom_name}",
|
||||
)
|
||||
self.update_session(session_id, kingdom_name=kingdom_name, in_game_day=in_game_day)
|
||||
logger.info(
|
||||
"SessionMemory: milestone — kingdom '%s' established on day %d",
|
||||
kingdom_name,
|
||||
in_game_day,
|
||||
)
|
||||
@@ -1,226 +0,0 @@
|
||||
"""Bannerlord M3 — core data types for the campaign strategy system.
|
||||
|
||||
KingSubgoal schema and all message types used by the feudal agent hierarchy.
|
||||
Design follows the Feudal Multi-Agent Hierarchies (Ahilan & Dayan, 2019) model
|
||||
specified in docs/research/bannerlord-feudal-hierarchy-design.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any, Literal
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subgoal vocabulary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SubgoalToken(StrEnum):
|
||||
"""Fixed vocabulary of strategic intents the King can emit."""
|
||||
|
||||
EXPAND_TERRITORY = "EXPAND_TERRITORY"
|
||||
RAID_ECONOMY = "RAID_ECONOMY"
|
||||
FORTIFY = "FORTIFY"
|
||||
RECRUIT = "RECRUIT"
|
||||
TRADE = "TRADE"
|
||||
ALLY = "ALLY"
|
||||
SPY = "SPY"
|
||||
HEAL = "HEAL"
|
||||
CONSOLIDATE = "CONSOLIDATE"
|
||||
TRAIN = "TRAIN"
|
||||
|
||||
|
||||
@dataclass
|
||||
class KingSubgoal:
|
||||
"""A strategic directive issued by the King agent.
|
||||
|
||||
The King emits at most one subgoal per campaign tick. Vassals interpret
|
||||
the token and prioritise actions accordingly.
|
||||
|
||||
Attributes:
|
||||
token: Intent from the SubgoalToken vocabulary.
|
||||
target: Named target (settlement, lord, or faction).
|
||||
quantity: Scalar for RECRUIT / TRADE operations.
|
||||
priority: 0.0–2.0 weighting that scales vassal reward.
|
||||
deadline_days: Campaign-map days to complete (None = open-ended).
|
||||
context: Free-text hint passed verbatim to vassals (not parsed).
|
||||
issued_at: Timestamp when the King emitted this subgoal.
|
||||
"""
|
||||
|
||||
token: SubgoalToken
|
||||
target: str | None = None
|
||||
quantity: int | None = None
|
||||
priority: float = 1.0
|
||||
deadline_days: int | None = None
|
||||
context: str | None = None
|
||||
issued_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"token": str(self.token),
|
||||
"target": self.target,
|
||||
"quantity": self.quantity,
|
||||
"priority": self.priority,
|
||||
"deadline_days": self.deadline_days,
|
||||
"context": self.context,
|
||||
"issued_at": self.issued_at.isoformat(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> KingSubgoal:
|
||||
return cls(
|
||||
token=SubgoalToken(data["token"]),
|
||||
target=data.get("target"),
|
||||
quantity=data.get("quantity"),
|
||||
priority=data.get("priority", 1.0),
|
||||
deadline_days=data.get("deadline_days"),
|
||||
context=data.get("context"),
|
||||
issued_at=datetime.fromisoformat(data["issued_at"])
|
||||
if "issued_at" in data
|
||||
else datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Game state snapshot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class FactionState:
|
||||
"""Snapshot of a faction's status on the campaign map."""
|
||||
|
||||
name: str
|
||||
leader: str
|
||||
fiefs: list[str] = field(default_factory=list)
|
||||
army_strength: int = 0
|
||||
treasury: int = 0
|
||||
is_at_war_with: list[str] = field(default_factory=list)
|
||||
relations: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PartyState:
|
||||
"""Timmy's party snapshot."""
|
||||
|
||||
location: str = ""
|
||||
troops: int = 0
|
||||
food_days: int = 0
|
||||
wounded_pct: float = 0.0
|
||||
denars: int = 0
|
||||
morale: float = 100.0
|
||||
prisoners: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class KingdomState:
|
||||
"""Timmy's kingdom snapshot (only populated after kingdom is established)."""
|
||||
|
||||
name: str = ""
|
||||
fiefs: list[str] = field(default_factory=list)
|
||||
daily_income: int = 0
|
||||
daily_expenses: int = 0
|
||||
vassal_lords: list[str] = field(default_factory=list)
|
||||
active_wars: list[str] = field(default_factory=list)
|
||||
active_alliances: list[str] = field(default_factory=list)
|
||||
in_game_day: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameState:
|
||||
"""Full campaign state snapshot delivered by GABS on each tick.
|
||||
|
||||
This is the primary input to the King agent's decision loop.
|
||||
"""
|
||||
|
||||
tick: int = 0
|
||||
in_game_day: int = 0
|
||||
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
party: PartyState = field(default_factory=PartyState)
|
||||
kingdom: KingdomState = field(default_factory=KingdomState)
|
||||
factions: list[FactionState] = field(default_factory=list)
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def has_kingdom(self) -> bool:
|
||||
return bool(self.kingdom.name)
|
||||
|
||||
def fief_count(self) -> int:
|
||||
return len(self.kingdom.fiefs)
|
||||
|
||||
def active_war_count(self) -> int:
|
||||
return len(self.kingdom.active_wars)
|
||||
|
||||
def is_two_front_war(self) -> bool:
|
||||
"""Return True if Timmy is engaged in 2+ simultaneous wars."""
|
||||
return self.active_war_count() >= 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inter-agent message schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubgoalMessage:
|
||||
"""King → Vassal directive."""
|
||||
|
||||
msg_type: Literal["subgoal"] = "subgoal"
|
||||
from_agent: Literal["king"] = "king"
|
||||
to_agent: str = ""
|
||||
subgoal: KingSubgoal | None = None
|
||||
issued_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskMessage:
|
||||
"""Vassal → Companion work order."""
|
||||
|
||||
msg_type: Literal["task"] = "task"
|
||||
from_agent: str = ""
|
||||
to_agent: str = ""
|
||||
primitive: str = ""
|
||||
args: dict[str, Any] = field(default_factory=dict)
|
||||
priority: float = 1.0
|
||||
issued_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResultMessage:
|
||||
"""Companion/Vassal → Parent outcome report."""
|
||||
|
||||
msg_type: Literal["result"] = "result"
|
||||
from_agent: str = ""
|
||||
to_agent: str = ""
|
||||
success: bool = True
|
||||
outcome: dict[str, Any] = field(default_factory=dict)
|
||||
reward_delta: float = 0.0
|
||||
completed_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
@dataclass
|
||||
class StateUpdateMessage:
|
||||
"""GABS → All agents broadcast."""
|
||||
|
||||
msg_type: Literal["state"] = "state"
|
||||
game_state: GameState = field(default_factory=GameState)
|
||||
tick: int = 0
|
||||
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reward signals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class VassalReward:
|
||||
"""Computed reward signal for a vassal agent after one decision cycle."""
|
||||
|
||||
agent_id: str
|
||||
component_scores: dict[str, float] = field(default_factory=dict)
|
||||
subgoal_bonus: float = 0.0
|
||||
total: float = 0.0
|
||||
computed_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
@@ -147,15 +147,6 @@ class Settings(BaseSettings):
|
||||
l402_macaroon_secret: str = ""
|
||||
lightning_backend: Literal["mock", "lnd"] = "mock"
|
||||
|
||||
# ── Bannerlord / GABS ────────────────────────────────────────────────
|
||||
# TCP JSON-RPC connection to the Bannerlord.GABS mod running on the
|
||||
# Windows VM. Override with GABS_HOST / GABS_PORT env vars.
|
||||
gabs_host: str = "127.0.0.1"
|
||||
gabs_port: int = 4825
|
||||
gabs_timeout: float = 10.0 # seconds per GABS call
|
||||
bannerlord_tick_interval: float = 1.0 # real-time seconds between campaign ticks
|
||||
bannerlord_db_path: str = "data/bannerlord/campaign.db"
|
||||
|
||||
# ── Privacy / Sovereignty ────────────────────────────────────────────
|
||||
# Disable Agno telemetry for air-gapped/sovereign deployments.
|
||||
# Default is False (telemetry disabled) to align with sovereign AI vision.
|
||||
|
||||
@@ -375,21 +375,13 @@ def _startup_init() -> None:
|
||||
|
||||
def _startup_background_tasks() -> list[asyncio.Task]:
|
||||
"""Spawn all recurring background tasks (non-blocking)."""
|
||||
bg_tasks = [
|
||||
return [
|
||||
asyncio.create_task(_briefing_scheduler()),
|
||||
asyncio.create_task(_thinking_scheduler()),
|
||||
asyncio.create_task(_loop_qa_scheduler()),
|
||||
asyncio.create_task(_presence_watcher()),
|
||||
asyncio.create_task(_start_chat_integrations_background()),
|
||||
]
|
||||
try:
|
||||
from timmy.paperclip import start_paperclip_poller
|
||||
bg_tasks.append(asyncio.create_task(start_paperclip_poller()))
|
||||
logger.info("Paperclip poller started")
|
||||
except ImportError:
|
||||
logger.debug("Paperclip module not found, skipping poller")
|
||||
|
||||
return bg_tasks
|
||||
|
||||
|
||||
def _try_prune(label: str, prune_fn, days: int) -> None:
|
||||
|
||||
@@ -1,264 +1,302 @@
|
||||
"""
|
||||
claude_quota.py — Claude Code / Claude.ai Quota Monitor
|
||||
"""Claude API quota tracker and metabolic mode advisor.
|
||||
|
||||
Drop into src/infrastructure/ in the Timmy Time Dashboard repo.
|
||||
Tracks Claude API usage (tokens, cost, calls) in a local SQLite database.
|
||||
Provides a metabolic mode recommendation (BURST / ACTIVE / RESTING) based on
|
||||
daily spend thresholds so the orchestrator can decide when to use cloud inference
|
||||
vs. local Ollama.
|
||||
|
||||
Provides real-time quota visibility and metabolic protocol decisions.
|
||||
Metabolic protocol (from issue #1074):
|
||||
BURST — daily spend < burst_threshold → use Claude freely
|
||||
ACTIVE — daily spend < active_threshold → prefer Groq / cheap tier
|
||||
RESTING — daily spend >= active_threshold → local only, no API calls
|
||||
|
||||
Usage:
|
||||
from infrastructure.claude_quota import QuotaMonitor
|
||||
|
||||
monitor = QuotaMonitor()
|
||||
status = monitor.check()
|
||||
print(status.five_hour_pct) # 42
|
||||
print(status.five_hour_resets_in) # "2h 15m"
|
||||
print(status.seven_day_pct) # 29
|
||||
print(status.recommended_tier) # MetabolicTier.BURST
|
||||
|
||||
# Metabolic protocol: auto-select model based on quota
|
||||
model = monitor.select_model(task_complexity="high")
|
||||
# Returns "claude-sonnet-4-6" if quota allows, else "qwen3:14b"
|
||||
Refs: #1074, #972
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Cost table (USD per million tokens, approximate) ─────────────────────────
|
||||
_MODEL_COSTS: dict[str, dict[str, float]] = {
|
||||
# haiku aliases
|
||||
"haiku": {"input": 0.25, "output": 1.25},
|
||||
"claude-haiku-4-5": {"input": 0.25, "output": 1.25},
|
||||
"claude-haiku-4-5-20251001": {"input": 0.25, "output": 1.25},
|
||||
# sonnet aliases
|
||||
"sonnet": {"input": 3.00, "output": 15.00},
|
||||
"claude-sonnet-4-6": {"input": 3.00, "output": 15.00},
|
||||
# opus aliases
|
||||
"opus": {"input": 15.00, "output": 75.00},
|
||||
"claude-opus-4-6": {"input": 15.00, "output": 75.00},
|
||||
}
|
||||
_DEFAULT_COST = {"input": 3.00, "output": 15.00} # conservative default
|
||||
|
||||
class MetabolicTier(StrEnum):
|
||||
"""The three-tier metabolic protocol from the Timmy Time architecture."""
|
||||
MetabolicMode = Literal["BURST", "ACTIVE", "RESTING"]
|
||||
|
||||
BURST = "burst" # Cloud API (Claude/Groq) — expensive, best quality
|
||||
ACTIVE = "active" # Local 14B (Qwen3-14B) — free, good quality
|
||||
RESTING = "resting" # Local 8B (Qwen3-8B) — free, fast, adequate
|
||||
DB_PATH = Path(settings.repo_root) / "data" / "claude_quota.db"
|
||||
|
||||
# Daily spend thresholds (USD) — tune via env or subclass Settings
|
||||
BURST_THRESHOLD: float = 1.00 # < $1/day → BURST mode, use Claude freely
|
||||
ACTIVE_THRESHOLD: float = 5.00 # < $5/day → ACTIVE mode, prefer cheaper tier
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS claude_calls (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
input_tok INTEGER NOT NULL DEFAULT 0,
|
||||
output_tok INTEGER NOT NULL DEFAULT 0,
|
||||
cost_usd REAL NOT NULL DEFAULT 0.0,
|
||||
task_label TEXT DEFAULT '',
|
||||
metadata TEXT DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cc_ts ON claude_calls(ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_cc_model ON claude_calls(model);
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuotaStatus:
|
||||
"""Current Claude quota state."""
|
||||
class ClaudeCall:
|
||||
"""Record of a single Claude API call."""
|
||||
|
||||
five_hour_utilization: float # 0.0 to 1.0
|
||||
five_hour_resets_at: str | None
|
||||
seven_day_utilization: float # 0.0 to 1.0
|
||||
seven_day_resets_at: str | None
|
||||
raw_response: dict
|
||||
fetched_at: datetime
|
||||
model: str
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
task_label: str = ""
|
||||
ts: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def five_hour_pct(self) -> int:
|
||||
return int(self.five_hour_utilization * 100)
|
||||
|
||||
@property
|
||||
def seven_day_pct(self) -> int:
|
||||
return int(self.seven_day_utilization * 100)
|
||||
|
||||
@property
|
||||
def five_hour_resets_in(self) -> str:
|
||||
return _time_remaining(self.five_hour_resets_at)
|
||||
|
||||
@property
|
||||
def seven_day_resets_in(self) -> str:
|
||||
return _time_remaining(self.seven_day_resets_at)
|
||||
|
||||
@property
|
||||
def recommended_tier(self) -> MetabolicTier:
|
||||
"""Metabolic protocol: determine which inference tier to use."""
|
||||
# If weekly quota is critical, go full local
|
||||
if self.seven_day_utilization >= 0.80:
|
||||
return MetabolicTier.RESTING
|
||||
# If 5-hour window is critical or past half, use local
|
||||
if self.five_hour_utilization >= 0.50:
|
||||
return MetabolicTier.ACTIVE
|
||||
# Quota healthy — cloud available for high-value tasks
|
||||
return MetabolicTier.BURST
|
||||
|
||||
def summary(self) -> str:
|
||||
"""Human-readable status string."""
|
||||
def cost_usd(self) -> float:
|
||||
costs = _MODEL_COSTS.get(self.model, _DEFAULT_COST)
|
||||
return (
|
||||
f"5h: {self.five_hour_pct}% (resets {self.five_hour_resets_in}) | "
|
||||
f"7d: {self.seven_day_pct}% (resets {self.seven_day_resets_in}) | "
|
||||
f"tier: {self.recommended_tier.value}"
|
||||
self.input_tokens * costs["input"]
|
||||
+ self.output_tokens * costs["output"]
|
||||
) / 1_000_000
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuotaSummary:
|
||||
"""Aggregated quota status for a time window."""
|
||||
|
||||
period: str # "today" | "month"
|
||||
calls: int
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
cost_usd: float
|
||||
mode: MetabolicMode
|
||||
burst_threshold: float
|
||||
active_threshold: float
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"period": self.period,
|
||||
"calls": self.calls,
|
||||
"input_tokens": self.input_tokens,
|
||||
"output_tokens": self.output_tokens,
|
||||
"cost_usd": round(self.cost_usd, 4),
|
||||
"mode": self.mode,
|
||||
"burst_threshold": self.burst_threshold,
|
||||
"active_threshold": self.active_threshold,
|
||||
}
|
||||
|
||||
|
||||
def _mode_for_cost(daily_cost: float) -> MetabolicMode:
|
||||
if daily_cost < BURST_THRESHOLD:
|
||||
return "BURST"
|
||||
if daily_cost < ACTIVE_THRESHOLD:
|
||||
return "ACTIVE"
|
||||
return "RESTING"
|
||||
|
||||
|
||||
class ClaudeQuotaStore:
|
||||
"""SQLite-backed store for Claude API usage tracking.
|
||||
|
||||
Thread-safe: creates a new connection per operation.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path | None = None) -> None:
|
||||
self._db_path = db_path or DB_PATH
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self) -> None:
|
||||
try:
|
||||
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with closing(sqlite3.connect(str(self._db_path))) as conn:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute(f"PRAGMA busy_timeout={settings.db_busy_timeout_ms}")
|
||||
conn.executescript(_SCHEMA)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to initialize claude_quota DB: %s", exc)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(self._db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(f"PRAGMA busy_timeout={settings.db_busy_timeout_ms}")
|
||||
return conn
|
||||
|
||||
def record_call(self, call: ClaudeCall) -> None:
|
||||
"""Persist a completed Claude API call."""
|
||||
try:
|
||||
with closing(self._connect()) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO claude_calls "
|
||||
"(ts, model, input_tok, output_tok, cost_usd, task_label, metadata) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
call.ts,
|
||||
call.model,
|
||||
call.input_tokens,
|
||||
call.output_tokens,
|
||||
call.cost_usd,
|
||||
call.task_label,
|
||||
json.dumps(call.metadata),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to record Claude call: %s", exc)
|
||||
|
||||
def _aggregate(self, where_clause: str, params: tuple) -> dict:
|
||||
"""Return aggregated stats for a WHERE clause."""
|
||||
try:
|
||||
with closing(self._connect()) as conn:
|
||||
row = conn.execute(
|
||||
f"SELECT COUNT(*) as calls, "
|
||||
f"COALESCE(SUM(input_tok),0) as input_tok, "
|
||||
f"COALESCE(SUM(output_tok),0) as output_tok, "
|
||||
f"COALESCE(SUM(cost_usd),0.0) as cost_usd "
|
||||
f"FROM claude_calls {where_clause}",
|
||||
params,
|
||||
).fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to aggregate Claude quota: %s", exc)
|
||||
return {"calls": 0, "input_tok": 0, "output_tok": 0, "cost_usd": 0.0}
|
||||
|
||||
def today_summary(self) -> QuotaSummary:
|
||||
"""Return quota summary for today (UTC)."""
|
||||
today = date.today().isoformat()
|
||||
agg = self._aggregate("WHERE ts >= ?", (today,))
|
||||
return QuotaSummary(
|
||||
period="today",
|
||||
calls=agg["calls"],
|
||||
input_tokens=agg["input_tok"],
|
||||
output_tokens=agg["output_tok"],
|
||||
cost_usd=agg["cost_usd"],
|
||||
mode=_mode_for_cost(agg["cost_usd"]),
|
||||
burst_threshold=BURST_THRESHOLD,
|
||||
active_threshold=ACTIVE_THRESHOLD,
|
||||
)
|
||||
|
||||
def month_summary(self) -> QuotaSummary:
|
||||
"""Return quota summary for the current calendar month (UTC)."""
|
||||
month_prefix = date.today().strftime("%Y-%m")
|
||||
agg = self._aggregate("WHERE ts >= ?", (month_prefix,))
|
||||
return QuotaSummary(
|
||||
period="month",
|
||||
calls=agg["calls"],
|
||||
input_tokens=agg["input_tok"],
|
||||
output_tokens=agg["output_tok"],
|
||||
cost_usd=agg["cost_usd"],
|
||||
mode=_mode_for_cost(agg["cost_usd"] / 30), # amortised daily
|
||||
burst_threshold=BURST_THRESHOLD,
|
||||
active_threshold=ACTIVE_THRESHOLD,
|
||||
)
|
||||
|
||||
class QuotaMonitor:
|
||||
def current_mode(self) -> MetabolicMode:
|
||||
"""Return the current metabolic mode based on today's spend."""
|
||||
return self.today_summary().mode
|
||||
|
||||
|
||||
# ── Module-level singleton ────────────────────────────────────────────────────
|
||||
_store: ClaudeQuotaStore | None = None
|
||||
|
||||
|
||||
def get_quota_store() -> ClaudeQuotaStore:
|
||||
"""Return the module-level quota store, creating it on first access."""
|
||||
global _store
|
||||
if _store is None:
|
||||
_store = ClaudeQuotaStore()
|
||||
return _store
|
||||
|
||||
|
||||
def record_usage(
|
||||
model: str,
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
task_label: str = "",
|
||||
metadata: dict | None = None,
|
||||
) -> None:
|
||||
"""Convenience function to record a Claude API call.
|
||||
|
||||
Silently degrades if the quota DB is unavailable.
|
||||
"""
|
||||
Monitors Claude Code / Claude.ai quota via the internal OAuth API.
|
||||
call = ClaudeCall(
|
||||
model=model,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
task_label=task_label,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
get_quota_store().record_call(call)
|
||||
logger.debug(
|
||||
"Claude call recorded: model=%s in=%d out=%d cost=$%.4f",
|
||||
model,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
call.cost_usd,
|
||||
)
|
||||
|
||||
The token is read from macOS Keychain where Claude Code stores it.
|
||||
Falls back gracefully if credentials aren't available (e.g., on Linux VPS).
|
||||
|
||||
def current_mode() -> MetabolicMode:
|
||||
"""Return the current metabolic mode.
|
||||
|
||||
BURST → Claude is cheap today, use freely.
|
||||
ACTIVE → Approaching daily budget, prefer Groq / cheaper tier.
|
||||
RESTING → Daily limit reached, use local Ollama only.
|
||||
"""
|
||||
|
||||
API_URL = "https://api.anthropic.com/api/oauth/usage"
|
||||
KEYCHAIN_SERVICE = "Claude Code-credentials"
|
||||
USER_AGENT = "claude-code/2.0.32"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._token: str | None = None
|
||||
self._last_status: QuotaStatus | None = None
|
||||
self._cache_seconds = 30 # Don't hammer the API
|
||||
|
||||
def _get_token(self) -> str | None:
|
||||
"""Extract OAuth token from macOS Keychain."""
|
||||
if self._token:
|
||||
return self._token
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["security", "find-generic-password", "-s", self.KEYCHAIN_SERVICE, "-w"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning("Claude Code credentials not found in Keychain")
|
||||
return None
|
||||
|
||||
creds = json.loads(result.stdout.strip())
|
||||
oauth = creds.get("claudeAiOauth", creds)
|
||||
self._token = oauth.get("accessToken")
|
||||
return self._token
|
||||
|
||||
except (
|
||||
json.JSONDecodeError,
|
||||
KeyError,
|
||||
FileNotFoundError,
|
||||
subprocess.TimeoutExpired,
|
||||
) as exc:
|
||||
logger.warning("Could not read Claude Code credentials: %s", exc)
|
||||
return None
|
||||
|
||||
def check(self, force: bool = False) -> QuotaStatus | None:
|
||||
"""
|
||||
Fetch current quota status.
|
||||
|
||||
Returns None if credentials aren't available (graceful degradation).
|
||||
Caches results for 30 seconds to avoid rate limiting the quota API itself.
|
||||
"""
|
||||
# Return cached if fresh
|
||||
if not force and self._last_status:
|
||||
age = (datetime.now(UTC) - self._last_status.fetched_at).total_seconds()
|
||||
if age < self._cache_seconds:
|
||||
return self._last_status
|
||||
|
||||
token = self._get_token()
|
||||
if not token:
|
||||
return None
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
self.API_URL,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": self.USER_AGENT,
|
||||
"Authorization": f"Bearer {token}",
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
|
||||
five_hour = data.get("five_hour") or {}
|
||||
seven_day = data.get("seven_day") or {}
|
||||
|
||||
self._last_status = QuotaStatus(
|
||||
five_hour_utilization=float(five_hour.get("utilization", 0.0)),
|
||||
five_hour_resets_at=five_hour.get("resets_at"),
|
||||
seven_day_utilization=float(seven_day.get("utilization", 0.0)),
|
||||
seven_day_resets_at=seven_day.get("resets_at"),
|
||||
raw_response=data,
|
||||
fetched_at=datetime.now(UTC),
|
||||
)
|
||||
return self._last_status
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch quota: %s", exc)
|
||||
return self._last_status # Return stale data if available
|
||||
|
||||
def select_model(self, task_complexity: str = "medium") -> str:
|
||||
"""
|
||||
Metabolic protocol: select the right model based on quota + task complexity.
|
||||
|
||||
Returns an Ollama model tag or "claude-sonnet-4-6" for cloud.
|
||||
|
||||
task_complexity: "low" | "medium" | "high"
|
||||
"""
|
||||
status = self.check()
|
||||
|
||||
# No quota info available — assume local only (sovereign default)
|
||||
if status is None:
|
||||
return "qwen3:14b" if task_complexity == "high" else "qwen3:8b"
|
||||
|
||||
tier = status.recommended_tier
|
||||
|
||||
if tier == MetabolicTier.BURST and task_complexity == "high":
|
||||
return "claude-sonnet-4-6" # Cloud — best quality
|
||||
elif tier == MetabolicTier.BURST and task_complexity == "medium":
|
||||
return "qwen3:14b" # Save cloud for truly hard tasks
|
||||
elif tier == MetabolicTier.ACTIVE:
|
||||
return "qwen3:14b" # Local 14B — good enough
|
||||
else: # RESTING
|
||||
return "qwen3:8b" # Local 8B — conserve everything
|
||||
|
||||
def should_use_cloud(self, task_value: str = "normal") -> bool:
|
||||
"""
|
||||
Simple yes/no: should this task use cloud API?
|
||||
|
||||
task_value: "critical" | "high" | "normal" | "routine"
|
||||
"""
|
||||
status = self.check()
|
||||
|
||||
if status is None:
|
||||
return False # No credentials = local only
|
||||
|
||||
if task_value == "critical":
|
||||
return status.seven_day_utilization < 0.95 # Almost always yes
|
||||
elif task_value == "high":
|
||||
return status.five_hour_utilization < 0.60
|
||||
elif task_value == "normal":
|
||||
return status.five_hour_utilization < 0.30
|
||||
else: # routine
|
||||
return False # Never waste cloud on routine
|
||||
|
||||
|
||||
def _time_remaining(reset_at: str | None) -> str:
|
||||
"""Format time until reset as human-readable string."""
|
||||
if not reset_at or reset_at == "null":
|
||||
return "unknown"
|
||||
|
||||
try:
|
||||
reset = datetime.fromisoformat(reset_at.replace("Z", "+00:00"))
|
||||
now = datetime.now(UTC)
|
||||
diff = reset - now
|
||||
|
||||
if diff.total_seconds() <= 0:
|
||||
return "resetting now"
|
||||
|
||||
hours = int(diff.total_seconds() // 3600)
|
||||
mins = int((diff.total_seconds() % 3600) // 60)
|
||||
|
||||
if hours > 0:
|
||||
return f"{hours}h {mins}m"
|
||||
return f"{mins}m"
|
||||
|
||||
except (ValueError, TypeError):
|
||||
return "unknown"
|
||||
return get_quota_store().current_mode()
|
||||
except Exception as exc:
|
||||
logger.warning("Quota mode check failed, defaulting to BURST: %s", exc)
|
||||
return "BURST"
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
_quota_monitor: QuotaMonitor | None = None
|
||||
def quota_report() -> str:
|
||||
"""Return a human-readable quota report for CLI / dashboard display."""
|
||||
try:
|
||||
store = get_quota_store()
|
||||
today = store.today_summary()
|
||||
month = store.month_summary()
|
||||
|
||||
|
||||
def get_quota_monitor() -> QuotaMonitor:
|
||||
"""Get or create the quota monitor singleton."""
|
||||
global _quota_monitor
|
||||
if _quota_monitor is None:
|
||||
_quota_monitor = QuotaMonitor()
|
||||
return _quota_monitor
|
||||
lines = [
|
||||
"═══════════════════════════════════════",
|
||||
" Claude API Quota — Metabolic Report ",
|
||||
"═══════════════════════════════════════",
|
||||
f" Today {today.calls:>6} calls "
|
||||
f"${today.cost_usd:>7.4f} [{today.mode}]",
|
||||
f" This month {month.calls:>5} calls "
|
||||
f"${month.cost_usd:>7.4f}",
|
||||
"───────────────────────────────────────",
|
||||
f" BURST threshold : ${today.burst_threshold:.2f}/day",
|
||||
f" ACTIVE threshold : ${today.active_threshold:.2f}/day",
|
||||
"───────────────────────────────────────",
|
||||
f" Current mode : {today.mode}",
|
||||
"═══════════════════════════════════════",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
except Exception as exc:
|
||||
return f"Quota report unavailable: {exc}"
|
||||
|
||||
@@ -32,15 +32,6 @@ except ImportError:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Quota monitor — optional, degrades gracefully if unavailable
|
||||
try:
|
||||
from infrastructure.claude_quota import QuotaMonitor, get_quota_monitor
|
||||
|
||||
_quota_monitor: "QuotaMonitor | None" = get_quota_monitor()
|
||||
except Exception as _exc: # pragma: no cover
|
||||
logger.debug("Quota monitor not available: %s", _exc)
|
||||
_quota_monitor = None
|
||||
|
||||
|
||||
class ProviderStatus(Enum):
|
||||
"""Health status of a provider."""
|
||||
@@ -310,22 +301,6 @@ class CascadeRouter:
|
||||
logger.debug("Ollama provider check error: %s", exc)
|
||||
return False
|
||||
|
||||
elif provider.type == "vllm_mlx":
|
||||
# Check if local vllm-mlx server is running (OpenAI-compatible)
|
||||
if requests is None:
|
||||
return True
|
||||
try:
|
||||
base_url = provider.base_url or provider.url or "http://localhost:8000"
|
||||
# Strip /v1 suffix — health endpoint is at the root
|
||||
server_root = base_url.rstrip("/")
|
||||
if server_root.endswith("/v1"):
|
||||
server_root = server_root[:-3]
|
||||
response = requests.get(f"{server_root}/health", timeout=5)
|
||||
return response.status_code == 200
|
||||
except Exception as exc:
|
||||
logger.debug("vllm-mlx provider check error: %s", exc)
|
||||
return False
|
||||
|
||||
elif provider.type in ("openai", "anthropic", "grok"):
|
||||
# Check if API key is set
|
||||
return provider.api_key is not None and provider.api_key != ""
|
||||
@@ -482,25 +457,6 @@ class CascadeRouter:
|
||||
|
||||
raise RuntimeError("; ".join(errors))
|
||||
|
||||
def _quota_allows_cloud(self, provider: Provider) -> bool:
|
||||
"""Check quota before routing to a cloud provider.
|
||||
|
||||
Uses the metabolic protocol: cloud calls are gated by 5-hour quota.
|
||||
Returns True (allow cloud) if quota monitor is unavailable or returns None.
|
||||
"""
|
||||
if _quota_monitor is None:
|
||||
return True
|
||||
try:
|
||||
# Map provider type to task_value heuristic
|
||||
task_value = "high" # conservative default
|
||||
status = _quota_monitor.check()
|
||||
if status is None:
|
||||
return True # No credentials — caller decides based on config
|
||||
return _quota_monitor.should_use_cloud(task_value)
|
||||
except Exception as exc:
|
||||
logger.warning("Quota check failed, allowing cloud: %s", exc)
|
||||
return True
|
||||
|
||||
def _is_provider_available(self, provider: Provider) -> bool:
|
||||
"""Check if a provider should be tried (enabled + circuit breaker)."""
|
||||
if not provider.enabled:
|
||||
@@ -554,15 +510,6 @@ class CascadeRouter:
|
||||
if not self._is_provider_available(provider):
|
||||
continue
|
||||
|
||||
# Metabolic protocol: skip cloud providers when quota is low
|
||||
if provider.type in ("anthropic", "openai", "grok"):
|
||||
if not self._quota_allows_cloud(provider):
|
||||
logger.info(
|
||||
"Metabolic protocol: skipping cloud provider %s (quota too low)",
|
||||
provider.name,
|
||||
)
|
||||
continue
|
||||
|
||||
selected_model, is_fallback_model = self._select_model(provider, model, content_type)
|
||||
|
||||
try:
|
||||
@@ -635,14 +582,6 @@ class CascadeRouter:
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
elif provider.type == "vllm_mlx":
|
||||
result = await self._call_vllm_mlx(
|
||||
provider=provider,
|
||||
messages=messages,
|
||||
model=model or provider.get_default_model(),
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown provider type: {provider.type}")
|
||||
|
||||
@@ -839,48 +778,6 @@ class CascadeRouter:
|
||||
"model": response.model,
|
||||
}
|
||||
|
||||
async def _call_vllm_mlx(
|
||||
self,
|
||||
provider: Provider,
|
||||
messages: list[dict],
|
||||
model: str,
|
||||
temperature: float,
|
||||
max_tokens: int | None,
|
||||
) -> dict:
|
||||
"""Call vllm-mlx via its OpenAI-compatible API.
|
||||
|
||||
vllm-mlx exposes the same /v1/chat/completions endpoint as OpenAI,
|
||||
so we reuse the OpenAI client pointed at the local server.
|
||||
No API key is required for local deployments.
|
||||
"""
|
||||
import openai
|
||||
|
||||
base_url = provider.base_url or provider.url or "http://localhost:8000"
|
||||
# Ensure the base_url ends with /v1 as expected by the OpenAI client
|
||||
if not base_url.rstrip("/").endswith("/v1"):
|
||||
base_url = base_url.rstrip("/") + "/v1"
|
||||
|
||||
client = openai.AsyncOpenAI(
|
||||
api_key=provider.api_key or "no-key-required",
|
||||
base_url=base_url,
|
||||
timeout=self.config.timeout_seconds,
|
||||
)
|
||||
|
||||
kwargs: dict = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if max_tokens:
|
||||
kwargs["max_tokens"] = max_tokens
|
||||
|
||||
response = await client.chat.completions.create(**kwargs)
|
||||
|
||||
return {
|
||||
"content": response.choices[0].message.content,
|
||||
"model": response.model,
|
||||
}
|
||||
|
||||
def _record_success(self, provider: Provider, latency_ms: float) -> None:
|
||||
"""Record a successful request."""
|
||||
provider.metrics.total_requests += 1
|
||||
|
||||
@@ -12,11 +12,6 @@ Quick start::
|
||||
register_adapter("mock", MockWorldAdapter)
|
||||
world = get_adapter("mock")
|
||||
perception = world.observe()
|
||||
|
||||
Registered adapters:
|
||||
"mock" — in-memory stub for testing
|
||||
"tes3mp" — Morrowind multiplayer (stub, pending PR #864)
|
||||
"bannerlord" — Bannerlord via GABS mod (M3 campaign strategy)
|
||||
"""
|
||||
|
||||
from infrastructure.world.registry import AdapterRegistry
|
||||
@@ -27,27 +22,6 @@ register_adapter = _registry.register
|
||||
get_adapter = _registry.get
|
||||
list_adapters = _registry.list_adapters
|
||||
|
||||
# -- Built-in adapter registration -----------------------------------------
|
||||
# Adapters are registered lazily to avoid import errors when their
|
||||
# optional dependencies (e.g., GABS TCP connection) are unavailable.
|
||||
|
||||
def _register_builtin_adapters() -> None:
|
||||
from infrastructure.world.adapters.mock import MockWorldAdapter
|
||||
from infrastructure.world.adapters.tes3mp import TES3MPWorldAdapter
|
||||
|
||||
_registry.register("mock", MockWorldAdapter)
|
||||
_registry.register("tes3mp", TES3MPWorldAdapter)
|
||||
|
||||
try:
|
||||
from bannerlord.adapter import BannerlordWorldAdapter
|
||||
_registry.register("bannerlord", BannerlordWorldAdapter)
|
||||
except Exception:
|
||||
# bannerlord package not installed or import error — skip silently
|
||||
pass
|
||||
|
||||
|
||||
_register_builtin_adapters()
|
||||
|
||||
__all__ = [
|
||||
"register_adapter",
|
||||
"get_adapter",
|
||||
|
||||
@@ -1,488 +0,0 @@
|
||||
"""Kimi delegation for heavy research via Gitea labels.
|
||||
|
||||
When research exceeds local + Groq capacity, Timmy delegates to Kimi by:
|
||||
1. Filling a research template with full context
|
||||
2. Creating a Gitea issue labeled `kimi-ready`
|
||||
3. Monitoring for Kimi's completion (issue closed + artifact committed)
|
||||
4. Indexing Kimi's artifact into semantic memory
|
||||
5. Extracting action items and creating follow-up issues
|
||||
|
||||
Delegation flow:
|
||||
Timmy detects capacity exceeded
|
||||
→ Fills template with context
|
||||
→ Creates `kimi-ready` Gitea issue
|
||||
→ Kimi picks up, executes, commits artifact, closes issue
|
||||
→ Timmy indexes artifact + creates follow-ups
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Label applied to issues that Kimi should pick up
|
||||
KIMI_READY_LABEL = "kimi-ready"
|
||||
|
||||
# Label colour for the kimi-ready label (dark teal)
|
||||
KIMI_LABEL_COLOR = "#006b75"
|
||||
|
||||
# Keywords that suggest a task exceeds local capacity
|
||||
_HEAVY_RESEARCH_KEYWORDS = frozenset(
|
||||
{
|
||||
"comprehensive",
|
||||
"exhaustive",
|
||||
"systematic review",
|
||||
"literature review",
|
||||
"benchmark",
|
||||
"comparative analysis",
|
||||
"large-scale",
|
||||
"survey",
|
||||
"meta-analysis",
|
||||
"deep research",
|
||||
"extensive",
|
||||
}
|
||||
)
|
||||
|
||||
# Minimum word count that hints at a heavy task
|
||||
_HEAVY_WORD_THRESHOLD = 50
|
||||
|
||||
|
||||
def exceeds_local_capacity(task_description: str) -> bool:
|
||||
"""Heuristic: does this research task exceed local + Groq capacity?
|
||||
|
||||
Returns True when the task description signals heavy or broad research
|
||||
that benefits from Kimi's 262K context and long-running processing.
|
||||
|
||||
Args:
|
||||
task_description: Free-text description of the research task.
|
||||
|
||||
Returns:
|
||||
True if the task should be delegated to Kimi.
|
||||
"""
|
||||
lower = task_description.lower()
|
||||
word_count = len(task_description.split())
|
||||
|
||||
has_heavy_keyword = any(kw in lower for kw in _HEAVY_RESEARCH_KEYWORDS)
|
||||
is_long_task = word_count >= _HEAVY_WORD_THRESHOLD
|
||||
|
||||
return has_heavy_keyword or is_long_task
|
||||
|
||||
|
||||
def _build_research_template(
|
||||
task: str,
|
||||
context: str,
|
||||
question: str,
|
||||
priority: str = "normal",
|
||||
) -> str:
|
||||
"""Fill the standard Kimi research template with task context.
|
||||
|
||||
Args:
|
||||
task: Short title for the research task.
|
||||
context: Background information and relevant project context.
|
||||
question: The specific research question to answer.
|
||||
priority: Task priority — "low", "normal", or "high".
|
||||
|
||||
Returns:
|
||||
Markdown-formatted issue body ready for Gitea.
|
||||
"""
|
||||
return f"""\
|
||||
## Research Request
|
||||
|
||||
**Priority:** {priority}
|
||||
|
||||
### Research Question
|
||||
|
||||
{question}
|
||||
|
||||
### Background / Context
|
||||
|
||||
{context}
|
||||
|
||||
### Scope
|
||||
|
||||
Please produce a thorough, well-structured research report covering:
|
||||
|
||||
- Direct answer to the research question above
|
||||
- Supporting evidence and sources where applicable
|
||||
- Trade-offs, limitations, or caveats
|
||||
- Concrete recommendations or next steps
|
||||
|
||||
### Deliverables
|
||||
|
||||
Commit your findings as a markdown artifact (e.g. `memory/research/{_slugify(task)}.md`)
|
||||
and close this issue when complete.
|
||||
|
||||
### Task
|
||||
|
||||
{task}
|
||||
|
||||
---
|
||||
*Delegated by Timmy via Kimi delegation pipeline. Label: `{KIMI_READY_LABEL}`*
|
||||
"""
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
"""Convert text to a safe filename slug."""
|
||||
slug = re.sub(r"[^\w\s-]", "", text.lower())
|
||||
slug = re.sub(r"[\s_]+", "-", slug)
|
||||
return slug[:60].strip("-")
|
||||
|
||||
|
||||
async def _get_or_create_label(
|
||||
client: Any,
|
||||
base_url: str,
|
||||
headers: dict[str, str],
|
||||
repo: str,
|
||||
) -> int | None:
|
||||
"""Ensure the `kimi-ready` label exists; return its ID or None on error.
|
||||
|
||||
Args:
|
||||
client: httpx.AsyncClient instance.
|
||||
base_url: Gitea API base URL.
|
||||
headers: Auth headers.
|
||||
repo: owner/repo string.
|
||||
|
||||
Returns:
|
||||
Label ID, or None if the operation failed.
|
||||
"""
|
||||
labels_url = f"{base_url}/repos/{repo}/labels"
|
||||
|
||||
# Check for existing label
|
||||
try:
|
||||
resp = await client.get(labels_url, headers=headers)
|
||||
if resp.status_code == 200:
|
||||
for label in resp.json():
|
||||
if label.get("name") == KIMI_READY_LABEL:
|
||||
return label["id"]
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to list Gitea labels: %s", exc)
|
||||
return None
|
||||
|
||||
# Create the label
|
||||
try:
|
||||
resp = await client.post(
|
||||
labels_url,
|
||||
headers=headers,
|
||||
json={"name": KIMI_READY_LABEL, "color": KIMI_LABEL_COLOR},
|
||||
)
|
||||
if resp.status_code in (200, 201):
|
||||
return resp.json().get("id")
|
||||
logger.warning("Label creation returned %s: %s", resp.status_code, resp.text[:200])
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to create Gitea label: %s", exc)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def create_kimi_research_issue(
|
||||
task: str,
|
||||
context: str,
|
||||
question: str,
|
||||
priority: str = "normal",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a Gitea issue labeled `kimi-ready` for Kimi to pick up.
|
||||
|
||||
Args:
|
||||
task: Short title for the research task (used as issue title).
|
||||
context: Background information and project context.
|
||||
question: The specific research question.
|
||||
priority: Task priority — "low", "normal", or "high".
|
||||
|
||||
Returns:
|
||||
Dict with `success`, `issue_number`, `issue_url`, and `error` keys.
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
|
||||
from config import settings
|
||||
except ImportError as exc:
|
||||
return {"success": False, "error": f"Missing dependency: {exc}"}
|
||||
|
||||
if not settings.gitea_enabled or not settings.gitea_token:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Gitea integration not configured (no token or disabled).",
|
||||
}
|
||||
|
||||
base_url = f"{settings.gitea_url}/api/v1"
|
||||
repo = settings.gitea_repo
|
||||
headers = {
|
||||
"Authorization": f"token {settings.gitea_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
label_id = await _get_or_create_label(client, base_url, headers, repo)
|
||||
|
||||
body = _build_research_template(task, context, question, priority)
|
||||
issue_payload: dict[str, Any] = {"title": task, "body": body}
|
||||
if label_id is not None:
|
||||
issue_payload["labels"] = [label_id]
|
||||
|
||||
resp = await client.post(
|
||||
f"{base_url}/repos/{repo}/issues",
|
||||
headers=headers,
|
||||
json=issue_payload,
|
||||
)
|
||||
|
||||
if resp.status_code in (200, 201):
|
||||
data = resp.json()
|
||||
number = data.get("number")
|
||||
url = data.get("html_url", "")
|
||||
logger.info("Created kimi-ready issue #%s: %s", number, task[:60])
|
||||
return {
|
||||
"success": True,
|
||||
"issue_number": number,
|
||||
"issue_url": url,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
logger.warning("Issue creation failed (%s): %s", resp.status_code, resp.text[:200])
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Gitea API error {resp.status_code}: {resp.text[:200]}",
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("create_kimi_research_issue failed: %s", exc)
|
||||
return {"success": False, "error": str(exc)}
|
||||
|
||||
|
||||
async def poll_kimi_issue(
|
||||
issue_number: int,
|
||||
poll_interval: int = 60,
|
||||
max_wait: int = 3600,
|
||||
) -> dict[str, Any]:
|
||||
"""Poll a Gitea issue until it is closed (Kimi completed) or timeout.
|
||||
|
||||
Args:
|
||||
issue_number: The Gitea issue number to watch.
|
||||
poll_interval: Seconds between polls. Default 60.
|
||||
max_wait: Maximum total seconds to wait. Default 3600 (1 hour).
|
||||
|
||||
Returns:
|
||||
Dict with `completed` bool, `state`, `body`, and `error` keys.
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
|
||||
from config import settings
|
||||
except ImportError as exc:
|
||||
return {"completed": False, "error": f"Missing dependency: {exc}"}
|
||||
|
||||
if not settings.gitea_enabled or not settings.gitea_token:
|
||||
return {"completed": False, "error": "Gitea not configured."}
|
||||
|
||||
base_url = f"{settings.gitea_url}/api/v1"
|
||||
repo = settings.gitea_repo
|
||||
headers = {"Authorization": f"token {settings.gitea_token}"}
|
||||
issue_url = f"{base_url}/repos/{repo}/issues/{issue_number}"
|
||||
|
||||
elapsed = 0
|
||||
while elapsed < max_wait:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(issue_url, headers=headers)
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
state = data.get("state", "open")
|
||||
if state == "closed":
|
||||
logger.info("Kimi completed issue #%s", issue_number)
|
||||
return {
|
||||
"completed": True,
|
||||
"state": state,
|
||||
"body": data.get("body", ""),
|
||||
"error": None,
|
||||
}
|
||||
else:
|
||||
logger.warning("Poll issue #%s returned %s", issue_number, resp.status_code)
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("Poll error for issue #%s: %s", issue_number, exc)
|
||||
|
||||
await asyncio.sleep(poll_interval)
|
||||
elapsed += poll_interval
|
||||
|
||||
return {
|
||||
"completed": False,
|
||||
"state": "timeout",
|
||||
"body": "",
|
||||
"error": f"Timed out after {max_wait}s waiting for issue #{issue_number}",
|
||||
}
|
||||
|
||||
|
||||
def _extract_action_items(text: str) -> list[str]:
|
||||
"""Extract action items from markdown text.
|
||||
|
||||
Looks for lines that start with checklist markers, numbered items,
|
||||
or explicit "Action:" / "TODO:" prefixes.
|
||||
|
||||
Args:
|
||||
text: Markdown text from Kimi's artifact.
|
||||
|
||||
Returns:
|
||||
List of action item strings (deduplicated, whitespace-stripped).
|
||||
"""
|
||||
items: list[str] = []
|
||||
patterns = [
|
||||
re.compile(r"^[-*]\s+\[ \]\s+(.+)", re.MULTILINE), # - [ ] checkbox
|
||||
re.compile(r"^\d+\.\s+(.+)", re.MULTILINE), # 1. numbered list
|
||||
re.compile(r"^(?:Action|TODO|Next step):\s*(.+)", re.MULTILINE | re.IGNORECASE),
|
||||
]
|
||||
seen: set[str] = set()
|
||||
for pat in patterns:
|
||||
for m in pat.finditer(text):
|
||||
item = m.group(1).strip()
|
||||
if item and item not in seen:
|
||||
items.append(item)
|
||||
seen.add(item)
|
||||
return items
|
||||
|
||||
|
||||
async def index_kimi_artifact(
|
||||
issue_number: int,
|
||||
title: str,
|
||||
artifact_content: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Index Kimi's research artifact into Timmy's semantic memory.
|
||||
|
||||
Args:
|
||||
issue_number: Source Gitea issue number (used as task_id).
|
||||
title: Human-readable title for the memory entry.
|
||||
artifact_content: The research artifact text to index.
|
||||
|
||||
Returns:
|
||||
Dict with `success` bool and `memory_id` or `error`.
|
||||
"""
|
||||
if not artifact_content.strip():
|
||||
return {"success": False, "error": "Empty artifact — nothing to index."}
|
||||
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
from timmy.memory_system import store_memory
|
||||
|
||||
# store_memory is synchronous — wrap in thread to avoid blocking event loop
|
||||
entry = await asyncio.to_thread(
|
||||
store_memory,
|
||||
content=artifact_content,
|
||||
source="kimi",
|
||||
context_type="document",
|
||||
task_id=str(issue_number),
|
||||
metadata={"issue_number": issue_number, "title": title},
|
||||
)
|
||||
logger.info("Indexed Kimi artifact for issue #%s (id=%s)", issue_number, entry.id)
|
||||
return {"success": True, "memory_id": entry.id}
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to index Kimi artifact for issue #%s: %s", issue_number, exc)
|
||||
return {"success": False, "error": str(exc)}
|
||||
|
||||
|
||||
async def extract_and_create_followups(
|
||||
artifact_content: str,
|
||||
source_issue_number: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Extract action items from artifact and create follow-up Gitea issues.
|
||||
|
||||
Args:
|
||||
artifact_content: Text of Kimi's research artifact.
|
||||
source_issue_number: Issue number that produced the artifact (for cross-links).
|
||||
|
||||
Returns:
|
||||
Dict with `success`, `created` (list of issue numbers), and `error`.
|
||||
"""
|
||||
items = _extract_action_items(artifact_content)
|
||||
if not items:
|
||||
logger.info("No action items found in artifact for issue #%s", source_issue_number)
|
||||
return {"success": True, "created": [], "error": None}
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
from config import settings
|
||||
except ImportError as exc:
|
||||
return {"success": False, "created": [], "error": str(exc)}
|
||||
|
||||
if not settings.gitea_enabled or not settings.gitea_token:
|
||||
return {
|
||||
"success": False,
|
||||
"created": [],
|
||||
"error": "Gitea not configured.",
|
||||
}
|
||||
|
||||
base_url = f"{settings.gitea_url}/api/v1"
|
||||
repo = settings.gitea_repo
|
||||
headers = {
|
||||
"Authorization": f"token {settings.gitea_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
created: list[int] = []
|
||||
|
||||
for item in items:
|
||||
body = (
|
||||
f"Follow-up from Kimi research artifact in #{source_issue_number}.\n\n"
|
||||
f"**Action item:** {item}"
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
f"{base_url}/repos/{repo}/issues",
|
||||
headers=headers,
|
||||
json={"title": item[:120], "body": body},
|
||||
)
|
||||
if resp.status_code in (200, 201):
|
||||
num = resp.json().get("number")
|
||||
if num:
|
||||
created.append(num)
|
||||
logger.info(
|
||||
"Created follow-up issue #%s from kimi artifact #%s",
|
||||
num,
|
||||
source_issue_number,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Follow-up issue creation returned %s for item: %s",
|
||||
resp.status_code,
|
||||
item[:60],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to create follow-up for item '%s': %s", item[:60], exc)
|
||||
|
||||
return {"success": True, "created": created, "error": None}
|
||||
|
||||
|
||||
async def delegate_research_to_kimi(
|
||||
task: str,
|
||||
context: str,
|
||||
question: str,
|
||||
priority: str = "normal",
|
||||
) -> dict[str, Any]:
|
||||
"""Top-level entry point: delegate a heavy research task to Kimi.
|
||||
|
||||
Creates the `kimi-ready` Gitea issue and returns immediately.
|
||||
Monitoring, artifact indexing, and follow-up creation happen
|
||||
separately via `poll_kimi_issue`, `index_kimi_artifact`, and
|
||||
`extract_and_create_followups`.
|
||||
|
||||
Args:
|
||||
task: Short title (becomes the issue title).
|
||||
context: Background / project context.
|
||||
question: The specific research question Kimi should answer.
|
||||
priority: "low", "normal", or "high".
|
||||
|
||||
Returns:
|
||||
Dict with `success`, `issue_number`, `issue_url`, and `error`.
|
||||
"""
|
||||
if not task.strip() or not question.strip():
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Both `task` and `question` are required.",
|
||||
}
|
||||
|
||||
logger.info("Delegating research to Kimi: %s", task[:80])
|
||||
return await create_kimi_research_issue(task, context, question, priority)
|
||||
@@ -1,175 +0,0 @@
|
||||
"""Paperclip integration for Timmy.
|
||||
|
||||
This module provides a client for the Paperclip API, and a poller for
|
||||
running research tasks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
from config import settings
|
||||
from timmy.research_triage import triage_research_report
|
||||
from timmy.research_tools import google_web_search, get_llm_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaperclipTask:
|
||||
"""A task from the Paperclip API."""
|
||||
|
||||
id: str
|
||||
kind: str
|
||||
context: dict
|
||||
|
||||
|
||||
class PaperclipClient:
|
||||
"""A client for the Paperclip API."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.base_url = settings.paperclip_url
|
||||
self.api_key = settings.paperclip_api_key
|
||||
self.agent_id = settings.paperclip_agent_id
|
||||
self.company_id = settings.paperclip_company_id
|
||||
self.timeout = settings.paperclip_timeout
|
||||
|
||||
async def get_tasks(self) -> list[PaperclipTask]:
|
||||
"""Get a list of tasks from the Paperclip API."""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
resp = await client.get(
|
||||
f"{self.base_url}/api/tasks",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
params={
|
||||
"agent_id": self.agent_id,
|
||||
"company_id": self.company_id,
|
||||
"status": "queued",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
tasks = resp.json()
|
||||
return [
|
||||
PaperclipTask(id=t["id"], kind=t["kind"], context=t["context"])
|
||||
for t in tasks
|
||||
]
|
||||
|
||||
async def update_task_status(
|
||||
self, task_id: str, status: str, result: str | None = None
|
||||
) -> None:
|
||||
"""Update the status of a task."""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
await client.patch(
|
||||
f"{self.base_url}/api/tasks/{task_id}",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
json={"status": status, "result": result},
|
||||
)
|
||||
|
||||
|
||||
class ResearchOrchestrator:
|
||||
"""Orchestrates research tasks."""
|
||||
|
||||
async def get_gitea_issue(self, issue_number: int) -> dict:
|
||||
"""Get a Gitea issue by its number."""
|
||||
owner, repo = settings.gitea_repo.split("/", 1)
|
||||
api_url = f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}"
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
api_url,
|
||||
headers={"Authorization": f"token {settings.gitea_token}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def post_gitea_comment(self, issue_number: int, comment: str) -> None:
|
||||
"""Post a comment to a Gitea issue."""
|
||||
owner, repo = settings.gitea_repo.split("/", 1)
|
||||
api_url = f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments"
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
await client.post(
|
||||
api_url,
|
||||
headers={"Authorization": f"token {settings.gitea_token}"},
|
||||
json={"body": comment},
|
||||
)
|
||||
|
||||
async def run_research_pipeline(self, issue_title: str) -> str:
|
||||
"""Run the research pipeline."""
|
||||
search_results = await google_web_search(issue_title)
|
||||
|
||||
llm_client = get_llm_client()
|
||||
response = await llm_client.completion(
|
||||
f"Summarize the following search results and generate a research report:\\n\\n{search_results}",
|
||||
max_tokens=2048,
|
||||
)
|
||||
return response.text
|
||||
|
||||
async def run(self, context: dict) -> str:
|
||||
"""Run a research task."""
|
||||
issue_number = context.get("issue_number")
|
||||
if not issue_number:
|
||||
return "Missing issue_number in task context"
|
||||
|
||||
issue = await self.get_gitea_issue(issue_number)
|
||||
|
||||
report = await self.run_research_pipeline(issue["title"])
|
||||
|
||||
triage_results = await triage_research_report(report, source_issue=issue_number)
|
||||
|
||||
comment = f"Research complete for issue #{issue_number}.\\n\\n"
|
||||
if triage_results:
|
||||
comment += "Created the following issues:\\n"
|
||||
for result in triage_results:
|
||||
if result["gitea_issue"]:
|
||||
comment += f"- #{result['gitea_issue']['number']}: {result['action_item'].title}\\n"
|
||||
else:
|
||||
comment += "No new issues were created.\\n"
|
||||
|
||||
await self.post_gitea_comment(issue_number, comment)
|
||||
|
||||
return f"Research complete for issue #{issue_number}"
|
||||
|
||||
|
||||
class PaperclipPoller:
|
||||
"""Polls the Paperclip API for new tasks."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.client = PaperclipClient()
|
||||
self.orchestrator = ResearchOrchestrator()
|
||||
self.poll_interval = settings.paperclip_poll_interval
|
||||
|
||||
async def poll(self) -> None:
|
||||
"""Poll the Paperclip API for new tasks."""
|
||||
if self.poll_interval == 0:
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
tasks = await self.client.get_tasks()
|
||||
for task in tasks:
|
||||
if task.kind == "research":
|
||||
await self.run_research_task(task)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("Error polling Paperclip: %s", exc)
|
||||
|
||||
await asyncio.sleep(self.poll_interval)
|
||||
|
||||
async def run_research_task(self, task: PaperclipTask) -> None:
|
||||
"""Run a research task."""
|
||||
await self.client.update_task_status(task.id, "running")
|
||||
try:
|
||||
result = await self.orchestrator.run(task.context)
|
||||
await self.client.update_task_status(task.id, "completed", result)
|
||||
except Exception as exc:
|
||||
logger.error("Error running research task: %s", exc, exc_info=True)
|
||||
await self.client.update_task_status(task.id, "failed", str(exc))
|
||||
|
||||
|
||||
async def start_paperclip_poller() -> None:
|
||||
"""Start the Paperclip poller."""
|
||||
if settings.paperclip_enabled:
|
||||
poller = PaperclipPoller()
|
||||
asyncio.create_task(poller.poll())
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Tools for the research pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from config import settings
|
||||
from serpapi import GoogleSearch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def google_web_search(query: str) -> str:
|
||||
"""Perform a Google search and return the results."""
|
||||
if "SERPAPI_API_KEY" not in os.environ:
|
||||
logger.warning("SERPAPI_API_KEY not set, skipping web search")
|
||||
return ""
|
||||
params = {
|
||||
"q": query,
|
||||
"api_key": os.environ["SERPAPI_API_KEY"],
|
||||
}
|
||||
search = GoogleSearch(params)
|
||||
results = search.get_dict()
|
||||
return str(results)
|
||||
|
||||
|
||||
def get_llm_client() -> Any:
|
||||
"""Get an LLM client."""
|
||||
# This is a placeholder. In a real application, this would return
|
||||
# a client for an LLM service like OpenAI, Anthropic, or a local
|
||||
# model.
|
||||
class MockLLMClient:
|
||||
async def completion(self, prompt: str, max_tokens: int) -> Any:
|
||||
class MockCompletion:
|
||||
def __init__(self, text: str) -> None:
|
||||
self.text = text
|
||||
|
||||
return MockCompletion(f"This is a summary of the search results for '{prompt}'.")
|
||||
|
||||
return MockLLMClient()
|
||||
@@ -54,7 +54,9 @@ class ActionItem:
|
||||
parts.append(f"- {url}")
|
||||
|
||||
if source_issue:
|
||||
parts.append(f"\n### Origin\nExtracted from research in #{source_issue}")
|
||||
parts.append(
|
||||
f"\n### Origin\nExtracted from research in #{source_issue}"
|
||||
)
|
||||
|
||||
parts.append("\n---\n*Auto-triaged from research findings by Timmy*")
|
||||
return "\n".join(parts)
|
||||
@@ -121,7 +123,7 @@ def _validate_action_item(raw_item: dict[str, Any]) -> ActionItem | None:
|
||||
|
||||
labels = raw_item.get("labels", [])
|
||||
if isinstance(labels, str):
|
||||
labels = [lbl.strip() for lbl in labels.split(",") if lbl.strip()]
|
||||
labels = [l.strip() for l in labels.split(",") if l.strip()]
|
||||
if not isinstance(labels, list):
|
||||
labels = []
|
||||
|
||||
@@ -301,7 +303,7 @@ async def _resolve_label_ids(
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
|
||||
existing = {lbl["name"]: lbl["id"] for lbl in resp.json()}
|
||||
existing = {l["name"]: l["id"] for l in resp.json()}
|
||||
label_ids = []
|
||||
|
||||
for name in label_names:
|
||||
|
||||
@@ -14,9 +14,7 @@ app = typer.Typer(help="Timmy Serve — sovereign AI agent API")
|
||||
def start(
|
||||
port: int = typer.Option(8402, "--port", "-p", help="Port for the serve API"),
|
||||
host: str = typer.Option("0.0.0.0", "--host", "-h", help="Host to bind to"),
|
||||
price: int = typer.Option(
|
||||
None, "--price", help="Price per request in sats (default: from config)"
|
||||
),
|
||||
price: int = typer.Option(None, "--price", help="Price per request in sats (default: from config)"),
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Print config and exit (for testing)"),
|
||||
):
|
||||
"""Start Timmy in serve mode."""
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
"""Tests for the CampaignOrchestrator — mocked GABS client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from bannerlord.campaign import CampaignOrchestrator
|
||||
from bannerlord.types import (
|
||||
FactionState,
|
||||
GameState,
|
||||
KingdomState,
|
||||
PartyState,
|
||||
)
|
||||
|
||||
|
||||
def _make_state(
|
||||
*,
|
||||
in_game_day: int = 50,
|
||||
troops: int = 150,
|
||||
denars: int = 10_000,
|
||||
food_days: int = 10,
|
||||
kingdom_name: str = "House Timmerson",
|
||||
fiefs: list | None = None,
|
||||
active_wars: list | None = None,
|
||||
) -> GameState:
|
||||
return GameState(
|
||||
in_game_day=in_game_day,
|
||||
party=PartyState(
|
||||
troops=troops,
|
||||
denars=denars,
|
||||
food_days=food_days,
|
||||
location="Epicrotea",
|
||||
),
|
||||
kingdom=KingdomState(
|
||||
name=kingdom_name,
|
||||
fiefs=fiefs or ["Epicrotea"],
|
||||
active_wars=active_wars or [],
|
||||
daily_income=500,
|
||||
daily_expenses=300,
|
||||
),
|
||||
factions=[
|
||||
FactionState(
|
||||
name="Vlandia",
|
||||
leader="Derthert",
|
||||
fiefs=["Pravend"],
|
||||
army_strength=200,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_db(tmp_path):
|
||||
return tmp_path / "test_campaign.db"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def orch(tmp_db):
|
||||
return CampaignOrchestrator(
|
||||
db_path=tmp_db,
|
||||
tick_interval=0.0,
|
||||
)
|
||||
|
||||
|
||||
class TestCampaignOrchestratorLifecycle:
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_creates_session(self, orch):
|
||||
with patch.object(orch._gabs, "connect", return_value=False):
|
||||
sid = await orch.start()
|
||||
assert sid is not None
|
||||
assert orch.session_id == sid
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_resumes_existing_session(self, orch):
|
||||
existing_sid = orch._memory.start_session("existing_run")
|
||||
orch._session_id = existing_sid
|
||||
with patch.object(orch._gabs, "connect", return_value=False):
|
||||
sid = await orch.start()
|
||||
assert sid == existing_sid
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_disconnects_gabs(self, orch):
|
||||
disconnect_mock = AsyncMock()
|
||||
orch._gabs.disconnect = disconnect_mock
|
||||
await orch.stop()
|
||||
disconnect_mock.assert_awaited_once()
|
||||
|
||||
|
||||
class TestCampaignTick:
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_tick_logs_subgoal(self, orch, tmp_db):
|
||||
state = _make_state()
|
||||
orch._gabs.get_game_state = AsyncMock(return_value=state)
|
||||
orch._gabs._call = AsyncMock(return_value=None)
|
||||
orch._session_id = orch._memory.start_session()
|
||||
|
||||
await orch._tick(1)
|
||||
|
||||
entries = orch.memory.get_recent_subgoals(orch.session_id, limit=5)
|
||||
assert len(entries) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_stops_at_max_ticks(self, orch):
|
||||
state = _make_state()
|
||||
orch._gabs.connect = AsyncMock(return_value=False)
|
||||
orch._gabs.get_game_state = AsyncMock(return_value=state)
|
||||
orch._gabs._call = AsyncMock(return_value=None)
|
||||
orch._gabs.disconnect = AsyncMock()
|
||||
|
||||
summary = await orch.run(max_ticks=3)
|
||||
assert summary["ticks_run"] == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_detects_done_condition(self, orch):
|
||||
"""Campaign stops early when M3 done condition is met."""
|
||||
state = _make_state(
|
||||
in_game_day=110,
|
||||
fiefs=["A", "B", "C"],
|
||||
)
|
||||
orch._gabs.connect = AsyncMock(return_value=False)
|
||||
orch._gabs.get_game_state = AsyncMock(return_value=state)
|
||||
orch._gabs._call = AsyncMock(return_value=None)
|
||||
orch._gabs.disconnect = AsyncMock()
|
||||
|
||||
summary = await orch.run(max_ticks=100)
|
||||
# Should stop at tick 1 because done condition is met immediately
|
||||
assert summary["ticks_run"] <= 2
|
||||
assert summary["survival_goal_met"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_shape(self, orch):
|
||||
state = _make_state(in_game_day=110, fiefs=["A", "B", "C"])
|
||||
orch._gabs.connect = AsyncMock(return_value=False)
|
||||
orch._gabs.get_game_state = AsyncMock(return_value=state)
|
||||
orch._gabs._call = AsyncMock(return_value=None)
|
||||
orch._gabs.disconnect = AsyncMock()
|
||||
|
||||
summary = await orch.run(max_ticks=1)
|
||||
assert "ticks_run" in summary
|
||||
assert "session_id" in summary
|
||||
assert "has_kingdom" in summary
|
||||
assert "fief_count" in summary
|
||||
@@ -1,161 +0,0 @@
|
||||
"""Tests for Bannerlord companion worker agents."""
|
||||
|
||||
from bannerlord.agents.companions.caravan import CaravanCompanion
|
||||
from bannerlord.agents.companions.logistics import LogisticsCompanion
|
||||
from bannerlord.agents.companions.scout import ScoutCompanion
|
||||
from bannerlord.types import (
|
||||
FactionState,
|
||||
GameState,
|
||||
KingSubgoal,
|
||||
KingdomState,
|
||||
PartyState,
|
||||
SubgoalToken,
|
||||
)
|
||||
|
||||
|
||||
def _state(
|
||||
*,
|
||||
troops: int = 150,
|
||||
denars: int = 10_000,
|
||||
food_days: int = 10,
|
||||
wounded_pct: float = 0.0,
|
||||
prisoners: int = 0,
|
||||
location: str = "Epicrotea",
|
||||
active_wars: list | None = None,
|
||||
factions: list | None = None,
|
||||
) -> GameState:
|
||||
return GameState(
|
||||
party=PartyState(
|
||||
troops=troops,
|
||||
denars=denars,
|
||||
food_days=food_days,
|
||||
wounded_pct=wounded_pct,
|
||||
prisoners=prisoners,
|
||||
location=location,
|
||||
),
|
||||
kingdom=KingdomState(
|
||||
name="House Timmerson",
|
||||
active_wars=active_wars or [],
|
||||
),
|
||||
factions=factions or [],
|
||||
)
|
||||
|
||||
|
||||
class TestLogisticsCompanion:
|
||||
def setup_method(self):
|
||||
self.companion = LogisticsCompanion()
|
||||
|
||||
def test_recruits_on_recruit_subgoal(self):
|
||||
state = _state()
|
||||
sg = KingSubgoal(token=SubgoalToken.RECRUIT, quantity=30)
|
||||
actions = self.companion.evaluate(state, sg)
|
||||
assert any(a["primitive"] == "recruit_troop" for a in actions)
|
||||
|
||||
def test_rests_on_heal_subgoal(self):
|
||||
state = _state()
|
||||
sg = KingSubgoal(token=SubgoalToken.HEAL)
|
||||
actions = self.companion.evaluate(state, sg)
|
||||
assert any(a["primitive"] == "rest_party" for a in actions)
|
||||
|
||||
def test_rests_when_heavily_wounded(self):
|
||||
state = _state(wounded_pct=0.25)
|
||||
sg = KingSubgoal(token=SubgoalToken.CONSOLIDATE)
|
||||
actions = self.companion.evaluate(state, sg)
|
||||
assert any(a["primitive"] == "rest_party" for a in actions)
|
||||
|
||||
def test_buys_food_when_low(self):
|
||||
state = _state(food_days=2)
|
||||
sg = KingSubgoal(token=SubgoalToken.CONSOLIDATE)
|
||||
actions = self.companion.evaluate(state, sg)
|
||||
assert any(a["primitive"] == "buy_supplies" for a in actions)
|
||||
|
||||
def test_no_food_purchase_when_stocked(self):
|
||||
state = _state(food_days=10)
|
||||
sg = KingSubgoal(token=SubgoalToken.CONSOLIDATE)
|
||||
actions = self.companion.evaluate(state, sg)
|
||||
assert not any(a["primitive"] == "buy_supplies" for a in actions)
|
||||
|
||||
def test_sells_prisoners_at_cap(self):
|
||||
state = _state(prisoners=20)
|
||||
sg = KingSubgoal(token=SubgoalToken.CONSOLIDATE)
|
||||
actions = self.companion.evaluate(state, sg)
|
||||
assert any(a["primitive"] == "sell_prisoners" for a in actions)
|
||||
|
||||
def test_upgrades_troops_on_train(self):
|
||||
state = _state()
|
||||
sg = KingSubgoal(token=SubgoalToken.TRAIN)
|
||||
actions = self.companion.evaluate(state, sg)
|
||||
assert any(a["primitive"] == "upgrade_troops" for a in actions)
|
||||
|
||||
|
||||
class TestCaravanCompanion:
|
||||
def setup_method(self):
|
||||
self.companion = CaravanCompanion()
|
||||
|
||||
def test_no_actions_when_not_trade_subgoal(self):
|
||||
state = _state()
|
||||
sg = KingSubgoal(token=SubgoalToken.RAID_ECONOMY)
|
||||
actions = self.companion.evaluate(state, sg)
|
||||
assert actions == []
|
||||
|
||||
def test_assesses_prices_on_trade(self):
|
||||
state = _state()
|
||||
sg = KingSubgoal(token=SubgoalToken.TRADE)
|
||||
actions = self.companion.evaluate(state, sg)
|
||||
assert any(a["primitive"] == "assess_prices" for a in actions)
|
||||
|
||||
def test_deploys_caravan_when_flush(self):
|
||||
state = _state(denars=15_000)
|
||||
sg = KingSubgoal(token=SubgoalToken.TRADE)
|
||||
actions = self.companion.evaluate(state, sg)
|
||||
assert any(a["primitive"] == "establish_caravan" for a in actions)
|
||||
|
||||
def test_no_caravan_when_broke(self):
|
||||
state = _state(denars=5_000)
|
||||
sg = KingSubgoal(token=SubgoalToken.TRADE)
|
||||
actions = self.companion.evaluate(state, sg)
|
||||
assert not any(a["primitive"] == "establish_caravan" for a in actions)
|
||||
|
||||
def test_profitable_trade_threshold(self):
|
||||
assert CaravanCompanion.is_profitable_trade(100, 116) # 16% margin = ok
|
||||
assert not CaravanCompanion.is_profitable_trade(100, 114) # 14% = below threshold
|
||||
assert not CaravanCompanion.is_profitable_trade(0, 100) # zero buy price = no
|
||||
|
||||
|
||||
class TestScoutCompanion:
|
||||
def setup_method(self):
|
||||
self.companion = ScoutCompanion()
|
||||
|
||||
def test_tracks_lord_on_spy_subgoal(self):
|
||||
state = _state()
|
||||
sg = KingSubgoal(token=SubgoalToken.SPY, target="Derthert")
|
||||
actions = self.companion.evaluate(state, sg)
|
||||
assert any(a["primitive"] == "track_lord" for a in actions)
|
||||
|
||||
def test_assesses_garrison_on_expand(self):
|
||||
state = _state()
|
||||
sg = KingSubgoal(token=SubgoalToken.EXPAND_TERRITORY, target="Pravend")
|
||||
actions = self.companion.evaluate(state, sg)
|
||||
assert any(a["primitive"] == "assess_garrison" for a in actions)
|
||||
|
||||
def test_maps_patrols_in_war_regions(self):
|
||||
state = _state(
|
||||
active_wars=["Vlandia"],
|
||||
factions=[
|
||||
FactionState(
|
||||
name="Vlandia",
|
||||
leader="Derthert",
|
||||
fiefs=["Pravend"],
|
||||
army_strength=300,
|
||||
)
|
||||
],
|
||||
)
|
||||
sg = KingSubgoal(token=SubgoalToken.CONSOLIDATE)
|
||||
actions = self.companion.evaluate(state, sg)
|
||||
assert any(a["primitive"] == "map_patrol_routes" for a in actions)
|
||||
|
||||
def test_no_patrol_map_when_no_wars(self):
|
||||
state = _state(active_wars=[])
|
||||
sg = KingSubgoal(token=SubgoalToken.CONSOLIDATE)
|
||||
actions = self.companion.evaluate(state, sg)
|
||||
assert not any(a["primitive"] == "map_patrol_routes" for a in actions)
|
||||
@@ -1,162 +0,0 @@
|
||||
"""Tests for the GABSClient — uses mocked asyncio streams, no real TCP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from bannerlord.gabs_client import GABSClient
|
||||
from bannerlord.types import GameState
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return GABSClient(host="127.0.0.1", port=4825, timeout=2.0)
|
||||
|
||||
|
||||
class TestGABSClientConnection:
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_returns_false_when_refused(self, client):
|
||||
with patch(
|
||||
"asyncio.open_connection",
|
||||
side_effect=ConnectionRefusedError("refused"),
|
||||
):
|
||||
result = await client.connect()
|
||||
assert result is False
|
||||
assert not client.is_connected
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_success(self, client):
|
||||
mock_reader = AsyncMock()
|
||||
mock_writer = MagicMock()
|
||||
mock_writer.drain = AsyncMock()
|
||||
mock_writer.close = MagicMock()
|
||||
mock_writer.wait_closed = AsyncMock()
|
||||
|
||||
# Simulate tools/list response on connect
|
||||
tools_response = json.dumps({"jsonrpc": "2.0", "id": 1, "result": []}) + "\n"
|
||||
mock_reader.readline = AsyncMock(return_value=tools_response.encode())
|
||||
|
||||
with patch("asyncio.open_connection", return_value=(mock_reader, mock_writer)):
|
||||
with patch("asyncio.wait_for", side_effect=_passthrough_wait_for):
|
||||
result = await client.connect()
|
||||
|
||||
assert result is True
|
||||
assert client.is_connected
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_when_not_connected(self, client):
|
||||
# Should not raise
|
||||
await client.disconnect()
|
||||
assert not client.is_connected
|
||||
|
||||
|
||||
class TestGABSClientCall:
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_returns_none_when_disconnected(self, client):
|
||||
result = await client._call("game/get_state")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_id_increments(self, client):
|
||||
assert client._next_id() == 1
|
||||
assert client._next_id() == 2
|
||||
assert client._next_id() == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_game_state_returns_empty_when_disconnected(self, client):
|
||||
state = await client.get_game_state()
|
||||
assert isinstance(state, GameState)
|
||||
assert state.tick == 0
|
||||
assert not state.has_kingdom()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_party_returns_false_when_disconnected(self, client):
|
||||
result = await client.move_party("Vlandia")
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_peace_returns_false_when_disconnected(self, client):
|
||||
result = await client.propose_peace("Vlandia")
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assess_prices_returns_empty_dict_when_disconnected(self, client):
|
||||
result = await client.assess_prices("Pravend")
|
||||
assert result == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_map_patrol_routes_returns_empty_list_when_disconnected(self, client):
|
||||
result = await client.map_patrol_routes("Vlandia")
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestGameStateParsing:
|
||||
def test_parse_game_state_full(self):
|
||||
client = GABSClient()
|
||||
raw = {
|
||||
"tick": 5,
|
||||
"in_game_day": 42,
|
||||
"party": {
|
||||
"location": "Pravend",
|
||||
"troops": 200,
|
||||
"food_days": 8,
|
||||
"wounded_pct": 0.1,
|
||||
"denars": 15000,
|
||||
"morale": 85.0,
|
||||
"prisoners": 3,
|
||||
},
|
||||
"kingdom": {
|
||||
"name": "House Timmerson",
|
||||
"fiefs": ["Pravend", "Epicrotea"],
|
||||
"daily_income": 500,
|
||||
"daily_expenses": 300,
|
||||
"vassal_lords": ["Lord A"],
|
||||
"active_wars": ["Sturgia"],
|
||||
"active_alliances": ["Battania"],
|
||||
},
|
||||
"factions": [
|
||||
{
|
||||
"name": "Sturgia",
|
||||
"leader": "Raganvad",
|
||||
"fiefs": ["Varcheg"],
|
||||
"army_strength": 250,
|
||||
"treasury": 5000,
|
||||
"is_at_war_with": ["House Timmerson"],
|
||||
"relations": {"House Timmerson": -50},
|
||||
}
|
||||
],
|
||||
}
|
||||
state = client._parse_game_state(raw)
|
||||
assert state.tick == 5
|
||||
assert state.in_game_day == 42
|
||||
assert state.party.location == "Pravend"
|
||||
assert state.party.troops == 200
|
||||
assert state.kingdom.name == "House Timmerson"
|
||||
assert state.fief_count() == 2
|
||||
assert len(state.factions) == 1
|
||||
assert state.factions[0].name == "Sturgia"
|
||||
assert state.has_kingdom()
|
||||
assert not state.is_two_front_war()
|
||||
|
||||
def test_parse_game_state_minimal(self):
|
||||
client = GABSClient()
|
||||
state = client._parse_game_state({})
|
||||
assert isinstance(state, GameState)
|
||||
assert not state.has_kingdom()
|
||||
|
||||
def test_tool_count_zero_before_connect(self):
|
||||
client = GABSClient()
|
||||
assert client.tool_count() == 0
|
||||
assert client.available_tools == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _passthrough_wait_for(coro, timeout=None):
|
||||
"""Stand-in for asyncio.wait_for that just awaits the coroutine."""
|
||||
import asyncio
|
||||
return await coro
|
||||
@@ -1,176 +0,0 @@
|
||||
"""Tests for the King agent decision rules."""
|
||||
|
||||
import pytest
|
||||
|
||||
from bannerlord.agents.king import KingAgent, _MIN_DENARS, _MIN_TROOPS, _TARGET_FIEFS
|
||||
from bannerlord.types import (
|
||||
FactionState,
|
||||
GameState,
|
||||
KingdomState,
|
||||
PartyState,
|
||||
SubgoalToken,
|
||||
)
|
||||
|
||||
|
||||
def _make_state(
|
||||
*,
|
||||
troops: int = 150,
|
||||
denars: int = 10_000,
|
||||
wounded_pct: float = 0.0,
|
||||
food_days: int = 10,
|
||||
kingdom_name: str = "",
|
||||
fiefs: list | None = None,
|
||||
active_wars: list | None = None,
|
||||
active_alliances: list | None = None,
|
||||
factions: list | None = None,
|
||||
in_game_day: int = 50,
|
||||
) -> GameState:
|
||||
return GameState(
|
||||
in_game_day=in_game_day,
|
||||
party=PartyState(
|
||||
troops=troops,
|
||||
denars=denars,
|
||||
wounded_pct=wounded_pct,
|
||||
food_days=food_days,
|
||||
location="Epicrotea",
|
||||
),
|
||||
kingdom=KingdomState(
|
||||
name=kingdom_name,
|
||||
fiefs=fiefs or [],
|
||||
active_wars=active_wars or [],
|
||||
active_alliances=active_alliances or [],
|
||||
),
|
||||
factions=factions or [],
|
||||
)
|
||||
|
||||
|
||||
class TestKingAgentRules:
|
||||
def setup_method(self):
|
||||
self.king = KingAgent()
|
||||
|
||||
def test_heal_when_heavily_wounded(self):
|
||||
state = _make_state(wounded_pct=0.35)
|
||||
sg = self.king.decide(state)
|
||||
assert sg.token == SubgoalToken.HEAL
|
||||
|
||||
def test_recruit_when_low_troops(self):
|
||||
state = _make_state(troops=30, wounded_pct=0.0)
|
||||
sg = self.king.decide(state)
|
||||
assert sg.token == SubgoalToken.RECRUIT
|
||||
assert sg.quantity == _MIN_TROOPS - 30
|
||||
|
||||
def test_trade_when_broke(self):
|
||||
state = _make_state(troops=150, denars=2_000)
|
||||
sg = self.king.decide(state)
|
||||
assert sg.token == SubgoalToken.TRADE
|
||||
|
||||
def test_no_two_front_war_rule(self):
|
||||
"""King must avoid 2-front wars by seeking peace."""
|
||||
state = _make_state(
|
||||
active_wars=["Vlandia", "Sturgia"],
|
||||
kingdom_name="House Timmerson",
|
||||
factions=[
|
||||
FactionState(name="Vlandia", leader="Derthert", army_strength=500),
|
||||
FactionState(name="Sturgia", leader="Raganvad", army_strength=200),
|
||||
],
|
||||
)
|
||||
sg = self.king.decide(state)
|
||||
assert sg.token == SubgoalToken.ALLY
|
||||
# Should target weakest enemy (Sturgia at 200 strength)
|
||||
assert sg.target == "Sturgia"
|
||||
|
||||
def test_expand_territory_when_no_kingdom(self):
|
||||
state = _make_state(
|
||||
troops=150,
|
||||
denars=10_000,
|
||||
kingdom_name="",
|
||||
factions=[
|
||||
FactionState(
|
||||
name="Vlandia",
|
||||
leader="Derthert",
|
||||
fiefs=["Pravend"],
|
||||
army_strength=100,
|
||||
is_at_war_with=["Battania", "Aserai"],
|
||||
)
|
||||
],
|
||||
)
|
||||
sg = self.king.decide(state)
|
||||
# Distracted faction should be the expansion target
|
||||
assert sg.token == SubgoalToken.EXPAND_TERRITORY
|
||||
|
||||
def test_train_when_troops_insufficient_for_expansion(self):
|
||||
state = _make_state(
|
||||
troops=90,
|
||||
denars=10_000,
|
||||
kingdom_name="",
|
||||
factions=[
|
||||
FactionState(
|
||||
name="Vlandia",
|
||||
leader="Derthert",
|
||||
fiefs=["Pravend"],
|
||||
army_strength=100,
|
||||
)
|
||||
],
|
||||
)
|
||||
sg = self.king.decide(state)
|
||||
assert sg.token == SubgoalToken.TRAIN
|
||||
|
||||
def test_expand_when_below_target_fiefs(self):
|
||||
state = _make_state(
|
||||
kingdom_name="House Timmerson",
|
||||
fiefs=["Epicrotea"],
|
||||
factions=[
|
||||
FactionState(
|
||||
name="Vlandia",
|
||||
leader="Derthert",
|
||||
fiefs=["Pravend", "Sargot"],
|
||||
army_strength=100,
|
||||
)
|
||||
],
|
||||
)
|
||||
sg = self.king.decide(state)
|
||||
assert sg.token == SubgoalToken.EXPAND_TERRITORY
|
||||
|
||||
def test_consolidate_when_stable(self):
|
||||
state = _make_state(
|
||||
kingdom_name="House Timmerson",
|
||||
fiefs=["Epicrotea", "Pravend", "Sargot"],
|
||||
active_alliances=["Battania"],
|
||||
)
|
||||
sg = self.king.decide(state)
|
||||
assert sg.token in {SubgoalToken.CONSOLIDATE, SubgoalToken.FORTIFY}
|
||||
|
||||
def test_tick_increments(self):
|
||||
king = KingAgent()
|
||||
state = _make_state()
|
||||
king.decide(state)
|
||||
king.decide(state)
|
||||
assert king.tick == 2
|
||||
|
||||
def test_done_condition_not_met_without_kingdom(self):
|
||||
state = _make_state(in_game_day=200)
|
||||
assert not self.king.is_done_condition_met(state)
|
||||
|
||||
def test_done_condition_met(self):
|
||||
state = _make_state(
|
||||
kingdom_name="House Timmerson",
|
||||
fiefs=["A", "B", "C"],
|
||||
in_game_day=110,
|
||||
)
|
||||
assert self.king.is_done_condition_met(state)
|
||||
|
||||
def test_done_condition_not_met_insufficient_days(self):
|
||||
state = _make_state(
|
||||
kingdom_name="House Timmerson",
|
||||
fiefs=["A", "B", "C"],
|
||||
in_game_day=50,
|
||||
)
|
||||
assert not self.king.is_done_condition_met(state)
|
||||
|
||||
def test_campaign_summary_shape(self):
|
||||
state = _make_state(kingdom_name="House Timmerson", fiefs=["A"])
|
||||
summary = self.king.campaign_summary(state)
|
||||
assert "tick" in summary
|
||||
assert "has_kingdom" in summary
|
||||
assert "fief_count" in summary
|
||||
assert "survival_goal_met" in summary
|
||||
@@ -1,140 +0,0 @@
|
||||
"""Tests for SessionMemory — SQLite-backed campaign persistence."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from bannerlord.session_memory import SessionMemory
|
||||
from bannerlord.types import KingSubgoal, SubgoalToken
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory(tmp_path):
|
||||
return SessionMemory(tmp_path / "test_campaign.db")
|
||||
|
||||
|
||||
class TestSessionLifecycle:
|
||||
def test_start_session_returns_id(self, memory):
|
||||
sid = memory.start_session()
|
||||
assert sid.startswith("session_")
|
||||
|
||||
def test_start_session_with_explicit_id(self, memory):
|
||||
sid = memory.start_session("my_run_001")
|
||||
assert sid == "my_run_001"
|
||||
|
||||
def test_start_idempotent(self, memory):
|
||||
sid1 = memory.start_session("run")
|
||||
sid2 = memory.start_session("run")
|
||||
assert sid1 == sid2
|
||||
|
||||
def test_get_session_returns_dict(self, memory):
|
||||
sid = memory.start_session()
|
||||
row = memory.get_session(sid)
|
||||
assert row is not None
|
||||
assert row["session_id"] == sid
|
||||
|
||||
def test_get_unknown_session_returns_none(self, memory):
|
||||
assert memory.get_session("does_not_exist") is None
|
||||
|
||||
def test_list_sessions(self, memory):
|
||||
memory.start_session("s1")
|
||||
memory.start_session("s2")
|
||||
sessions = memory.list_sessions()
|
||||
ids = [s["session_id"] for s in sessions]
|
||||
assert "s1" in ids
|
||||
assert "s2" in ids
|
||||
|
||||
def test_update_session(self, memory):
|
||||
sid = memory.start_session()
|
||||
memory.update_session(sid, kingdom_name="House Timmerson", fief_count=2, in_game_day=45)
|
||||
row = memory.get_session(sid)
|
||||
assert row["kingdom_name"] == "House Timmerson"
|
||||
assert row["fief_count"] == 2
|
||||
assert row["in_game_day"] == 45
|
||||
|
||||
|
||||
class TestSubgoalLog:
|
||||
def test_log_and_retrieve_subgoal(self, memory):
|
||||
sid = memory.start_session()
|
||||
sg = KingSubgoal(token=SubgoalToken.EXPAND_TERRITORY, target="Epicrotea")
|
||||
row_id = memory.log_subgoal(sid, tick=1, in_game_day=10, subgoal=sg)
|
||||
assert row_id > 0
|
||||
entries = memory.get_recent_subgoals(sid, limit=5)
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["token"] == "EXPAND_TERRITORY"
|
||||
assert entries[0]["target"] == "Epicrotea"
|
||||
assert entries[0]["outcome"] == "pending"
|
||||
|
||||
def test_complete_subgoal(self, memory):
|
||||
sid = memory.start_session()
|
||||
sg = KingSubgoal(token=SubgoalToken.TRADE)
|
||||
row_id = memory.log_subgoal(sid, tick=2, in_game_day=11, subgoal=sg)
|
||||
memory.complete_subgoal(row_id, outcome="success")
|
||||
entries = memory.get_recent_subgoals(sid, limit=5)
|
||||
assert entries[0]["outcome"] == "success"
|
||||
assert entries[0]["completed_at"] is not None
|
||||
|
||||
def test_count_token(self, memory):
|
||||
sid = memory.start_session()
|
||||
for i in range(3):
|
||||
memory.log_subgoal(
|
||||
sid, tick=i, in_game_day=i,
|
||||
subgoal=KingSubgoal(token=SubgoalToken.RECRUIT)
|
||||
)
|
||||
memory.log_subgoal(
|
||||
sid, tick=10, in_game_day=10,
|
||||
subgoal=KingSubgoal(token=SubgoalToken.TRADE)
|
||||
)
|
||||
assert memory.count_token(sid, SubgoalToken.RECRUIT) == 3
|
||||
assert memory.count_token(sid, SubgoalToken.TRADE) == 1
|
||||
assert memory.count_token(sid, SubgoalToken.ALLY) == 0
|
||||
|
||||
def test_recent_subgoals_respects_limit(self, memory):
|
||||
sid = memory.start_session()
|
||||
for i in range(10):
|
||||
memory.log_subgoal(
|
||||
sid, tick=i, in_game_day=i,
|
||||
subgoal=KingSubgoal(token=SubgoalToken.CONSOLIDATE)
|
||||
)
|
||||
entries = memory.get_recent_subgoals(sid, limit=3)
|
||||
assert len(entries) == 3
|
||||
|
||||
|
||||
class TestStrategyNotes:
|
||||
def test_add_and_get_notes(self, memory):
|
||||
sid = memory.start_session()
|
||||
memory.add_note(sid, in_game_day=5, note_type="intel", content="Vlandia weakened")
|
||||
notes = memory.get_notes(sid)
|
||||
assert len(notes) == 1
|
||||
assert notes[0]["content"] == "Vlandia weakened"
|
||||
|
||||
def test_filter_notes_by_type(self, memory):
|
||||
sid = memory.start_session()
|
||||
memory.add_note(sid, 1, "milestone", "Kingdom established")
|
||||
memory.add_note(sid, 2, "intel", "Enemy sighted")
|
||||
milestones = memory.get_notes(sid, note_type="milestone")
|
||||
assert len(milestones) == 1
|
||||
assert milestones[0]["content"] == "Kingdom established"
|
||||
|
||||
def test_record_kingdom_established(self, memory):
|
||||
sid = memory.start_session()
|
||||
memory.record_kingdom_established(sid, in_game_day=42, kingdom_name="House Timmerson")
|
||||
milestones = memory.get_milestones(sid)
|
||||
assert any("House Timmerson" in m["content"] for m in milestones)
|
||||
row = memory.get_session(sid)
|
||||
assert row["kingdom_name"] == "House Timmerson"
|
||||
|
||||
def test_record_war_declared(self, memory):
|
||||
sid = memory.start_session()
|
||||
memory.record_war_declared(sid, in_game_day=20, faction="Vlandia")
|
||||
notes = memory.get_notes(sid, note_type="war_declared")
|
||||
assert len(notes) == 1
|
||||
assert "Vlandia" in notes[0]["content"]
|
||||
|
||||
def test_record_peace_agreed(self, memory):
|
||||
sid = memory.start_session()
|
||||
memory.record_peace_agreed(sid, in_game_day=30, faction="Sturgia")
|
||||
notes = memory.get_notes(sid, note_type="peace_agreed")
|
||||
assert len(notes) == 1
|
||||
assert "Sturgia" in notes[0]["content"]
|
||||
@@ -1,103 +0,0 @@
|
||||
"""Tests for Bannerlord M3 core data types."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from bannerlord.types import (
|
||||
FactionState,
|
||||
GameState,
|
||||
KingSubgoal,
|
||||
KingdomState,
|
||||
PartyState,
|
||||
SubgoalToken,
|
||||
VassalReward,
|
||||
)
|
||||
|
||||
|
||||
class TestSubgoalToken:
|
||||
def test_all_tokens_are_strings(self):
|
||||
for token in SubgoalToken:
|
||||
assert isinstance(str(token), str)
|
||||
|
||||
def test_round_trip_from_string(self):
|
||||
assert SubgoalToken("EXPAND_TERRITORY") == SubgoalToken.EXPAND_TERRITORY
|
||||
assert SubgoalToken("ALLY") == SubgoalToken.ALLY
|
||||
|
||||
|
||||
class TestKingSubgoal:
|
||||
def test_defaults(self):
|
||||
sg = KingSubgoal(token=SubgoalToken.CONSOLIDATE)
|
||||
assert sg.priority == 1.0
|
||||
assert sg.target is None
|
||||
assert sg.quantity is None
|
||||
assert isinstance(sg.issued_at, datetime)
|
||||
|
||||
def test_to_dict_round_trip(self):
|
||||
sg = KingSubgoal(
|
||||
token=SubgoalToken.EXPAND_TERRITORY,
|
||||
target="Epicrotea",
|
||||
quantity=None,
|
||||
priority=1.5,
|
||||
deadline_days=10,
|
||||
context="capture castle",
|
||||
)
|
||||
d = sg.to_dict()
|
||||
assert d["token"] == "EXPAND_TERRITORY"
|
||||
assert d["target"] == "Epicrotea"
|
||||
assert d["priority"] == 1.5
|
||||
|
||||
restored = KingSubgoal.from_dict(d)
|
||||
assert restored.token == SubgoalToken.EXPAND_TERRITORY
|
||||
assert restored.target == "Epicrotea"
|
||||
assert restored.priority == 1.5
|
||||
assert restored.deadline_days == 10
|
||||
|
||||
def test_from_dict_without_issued_at(self):
|
||||
d = {"token": "TRADE"}
|
||||
sg = KingSubgoal.from_dict(d)
|
||||
assert sg.token == SubgoalToken.TRADE
|
||||
assert isinstance(sg.issued_at, datetime)
|
||||
|
||||
|
||||
class TestGameState:
|
||||
def test_empty_state(self):
|
||||
state = GameState()
|
||||
assert not state.has_kingdom()
|
||||
assert state.fief_count() == 0
|
||||
assert state.active_war_count() == 0
|
||||
assert not state.is_two_front_war()
|
||||
|
||||
def test_has_kingdom(self):
|
||||
state = GameState(kingdom=KingdomState(name="House Timmerson"))
|
||||
assert state.has_kingdom()
|
||||
|
||||
def test_fief_count(self):
|
||||
state = GameState(
|
||||
kingdom=KingdomState(name="House Timmerson", fiefs=["Pravend", "Epicrotea"])
|
||||
)
|
||||
assert state.fief_count() == 2
|
||||
|
||||
def test_two_front_war(self):
|
||||
state = GameState(
|
||||
kingdom=KingdomState(
|
||||
name="House Timmerson",
|
||||
active_wars=["Vlandia", "Sturgia"],
|
||||
)
|
||||
)
|
||||
assert state.is_two_front_war()
|
||||
|
||||
def test_single_war_not_two_front(self):
|
||||
state = GameState(
|
||||
kingdom=KingdomState(
|
||||
name="House Timmerson",
|
||||
active_wars=["Vlandia"],
|
||||
)
|
||||
)
|
||||
assert not state.is_two_front_war()
|
||||
|
||||
|
||||
class TestVassalReward:
|
||||
def test_defaults(self):
|
||||
reward = VassalReward(agent_id="war_vassal")
|
||||
assert reward.total == 0.0
|
||||
assert reward.subgoal_bonus == 0.0
|
||||
assert isinstance(reward.computed_at, datetime)
|
||||
@@ -1,179 +0,0 @@
|
||||
"""Tests for Bannerlord vassal agents (War, Economy, Diplomacy)."""
|
||||
|
||||
from bannerlord.agents.diplomacy_vassal import DiplomacyVassal
|
||||
from bannerlord.agents.economy_vassal import EconomyVassal
|
||||
from bannerlord.agents.war_vassal import WarVassal
|
||||
from bannerlord.types import (
|
||||
FactionState,
|
||||
GameState,
|
||||
KingSubgoal,
|
||||
KingdomState,
|
||||
PartyState,
|
||||
SubgoalToken,
|
||||
)
|
||||
|
||||
|
||||
def _state(
|
||||
*,
|
||||
troops: int = 150,
|
||||
denars: int = 10_000,
|
||||
food_days: int = 10,
|
||||
kingdom_name: str = "House Timmerson",
|
||||
fiefs: list | None = None,
|
||||
active_wars: list | None = None,
|
||||
active_alliances: list | None = None,
|
||||
factions: list | None = None,
|
||||
) -> GameState:
|
||||
return GameState(
|
||||
party=PartyState(
|
||||
troops=troops,
|
||||
denars=denars,
|
||||
food_days=food_days,
|
||||
location="Epicrotea",
|
||||
),
|
||||
kingdom=KingdomState(
|
||||
name=kingdom_name,
|
||||
fiefs=fiefs or ["Epicrotea"],
|
||||
active_wars=active_wars or [],
|
||||
active_alliances=active_alliances or [],
|
||||
),
|
||||
factions=factions or [],
|
||||
)
|
||||
|
||||
|
||||
class TestWarVassal:
|
||||
def setup_method(self):
|
||||
self.vassal = WarVassal()
|
||||
|
||||
def test_is_relevant_expand(self):
|
||||
sg = KingSubgoal(token=SubgoalToken.EXPAND_TERRITORY)
|
||||
assert self.vassal.is_relevant(sg)
|
||||
|
||||
def test_is_relevant_raid(self):
|
||||
sg = KingSubgoal(token=SubgoalToken.RAID_ECONOMY)
|
||||
assert self.vassal.is_relevant(sg)
|
||||
|
||||
def test_not_relevant_for_trade(self):
|
||||
sg = KingSubgoal(token=SubgoalToken.TRADE)
|
||||
assert not self.vassal.is_relevant(sg)
|
||||
|
||||
def test_plan_expansion_with_target(self):
|
||||
state = _state()
|
||||
sg = KingSubgoal(token=SubgoalToken.EXPAND_TERRITORY, target="Pravend")
|
||||
tasks = self.vassal.plan(state, sg)
|
||||
primitives = [t.primitive for t in tasks]
|
||||
assert "siege_settlement" in primitives
|
||||
assert "auto_resolve_battle" in primitives
|
||||
|
||||
def test_plan_expansion_scouts_garrison_first(self):
|
||||
state = _state()
|
||||
sg = KingSubgoal(token=SubgoalToken.EXPAND_TERRITORY, target="Pravend")
|
||||
tasks = self.vassal.plan(state, sg)
|
||||
# Scout garrison should be the first task (highest priority)
|
||||
assert tasks[0].primitive == "assess_garrison"
|
||||
|
||||
def test_plan_expansion_recruits_when_low(self):
|
||||
state = _state(troops=80)
|
||||
sg = KingSubgoal(token=SubgoalToken.EXPAND_TERRITORY, target="Pravend")
|
||||
tasks = self.vassal.plan(state, sg)
|
||||
primitives = [t.primitive for t in tasks]
|
||||
assert "recruit_troop" in primitives
|
||||
|
||||
def test_plan_expansion_no_target(self):
|
||||
state = _state()
|
||||
sg = KingSubgoal(token=SubgoalToken.EXPAND_TERRITORY, target=None)
|
||||
tasks = self.vassal.plan(state, sg)
|
||||
assert tasks == []
|
||||
|
||||
def test_plan_raid_with_target(self):
|
||||
state = _state()
|
||||
sg = KingSubgoal(token=SubgoalToken.RAID_ECONOMY, target="Enemy Village")
|
||||
tasks = self.vassal.plan(state, sg)
|
||||
assert any(t.primitive == "raid_village" for t in tasks)
|
||||
|
||||
def test_compute_reward_territory_gain(self):
|
||||
prev = _state(fiefs=["A"])
|
||||
curr = _state(fiefs=["A", "B"])
|
||||
sg = KingSubgoal(token=SubgoalToken.EXPAND_TERRITORY)
|
||||
reward = self.vassal.compute_reward(prev, curr, sg)
|
||||
assert reward.total > 0
|
||||
assert reward.component_scores["territory"] > 0
|
||||
|
||||
|
||||
class TestEconomyVassal:
|
||||
def setup_method(self):
|
||||
self.vassal = EconomyVassal()
|
||||
|
||||
def test_is_relevant_fortify(self):
|
||||
sg = KingSubgoal(token=SubgoalToken.FORTIFY)
|
||||
assert self.vassal.is_relevant(sg)
|
||||
|
||||
def test_is_relevant_consolidate(self):
|
||||
sg = KingSubgoal(token=SubgoalToken.CONSOLIDATE)
|
||||
assert self.vassal.is_relevant(sg)
|
||||
|
||||
def test_not_relevant_for_raid(self):
|
||||
sg = KingSubgoal(token=SubgoalToken.RAID_ECONOMY)
|
||||
assert not self.vassal.is_relevant(sg)
|
||||
|
||||
def test_plan_fortify_queues_build(self):
|
||||
state = _state()
|
||||
sg = KingSubgoal(token=SubgoalToken.FORTIFY, target="Epicrotea")
|
||||
tasks = self.vassal.plan(state, sg)
|
||||
primitives = [t.primitive for t in tasks]
|
||||
assert "build_project" in primitives
|
||||
assert "set_tax_policy" in primitives
|
||||
|
||||
def test_plan_buys_food_when_low(self):
|
||||
state = _state(food_days=2)
|
||||
sg = KingSubgoal(token=SubgoalToken.CONSOLIDATE)
|
||||
tasks = self.vassal.plan(state, sg)
|
||||
primitives = [t.primitive for t in tasks]
|
||||
assert "buy_supplies" in primitives
|
||||
|
||||
def test_plan_consolidate_sets_tax(self):
|
||||
state = _state()
|
||||
sg = KingSubgoal(token=SubgoalToken.CONSOLIDATE)
|
||||
tasks = self.vassal.plan(state, sg)
|
||||
assert any(t.primitive == "set_tax_policy" for t in tasks)
|
||||
|
||||
|
||||
class TestDiplomacyVassal:
|
||||
def setup_method(self):
|
||||
self.vassal = DiplomacyVassal()
|
||||
|
||||
def test_is_relevant_ally(self):
|
||||
sg = KingSubgoal(token=SubgoalToken.ALLY)
|
||||
assert self.vassal.is_relevant(sg)
|
||||
|
||||
def test_not_relevant_for_train(self):
|
||||
sg = KingSubgoal(token=SubgoalToken.TRAIN)
|
||||
assert not self.vassal.is_relevant(sg)
|
||||
|
||||
def test_plan_proposes_peace_with_enemy(self):
|
||||
state = _state(active_wars=["Vlandia"])
|
||||
sg = KingSubgoal(token=SubgoalToken.ALLY, target="Vlandia")
|
||||
tasks = self.vassal.plan(state, sg)
|
||||
assert any(t.primitive == "propose_peace" for t in tasks)
|
||||
|
||||
def test_plan_requests_alliance_with_non_enemy(self):
|
||||
state = _state()
|
||||
sg = KingSubgoal(token=SubgoalToken.ALLY, target="Battania")
|
||||
tasks = self.vassal.plan(state, sg)
|
||||
primitives = [t.primitive for t in tasks]
|
||||
assert "send_envoy" in primitives
|
||||
assert "request_alliance" in primitives
|
||||
|
||||
def test_plan_no_target_returns_empty(self):
|
||||
state = _state()
|
||||
sg = KingSubgoal(token=SubgoalToken.ALLY, target=None)
|
||||
tasks = self.vassal.plan(state, sg)
|
||||
assert tasks == []
|
||||
|
||||
def test_should_avoid_war_when_two_fronts(self):
|
||||
state = _state(active_wars=["A", "B"])
|
||||
assert self.vassal.should_avoid_war(state)
|
||||
|
||||
def test_should_not_avoid_war_when_one_front(self):
|
||||
state = _state(active_wars=["A"])
|
||||
assert not self.vassal.should_avoid_war(state)
|
||||
@@ -24,6 +24,7 @@ from dashboard.routes.health import (
|
||||
_generate_recommendations,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic models
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -117,9 +118,7 @@ class TestGenerateRecommendations:
|
||||
|
||||
def test_unavailable_service(self):
|
||||
deps = [
|
||||
DependencyStatus(
|
||||
name="Ollama AI", status="unavailable", sovereignty_score=10, details={}
|
||||
)
|
||||
DependencyStatus(name="Ollama AI", status="unavailable", sovereignty_score=10, details={})
|
||||
]
|
||||
recs = _generate_recommendations(deps)
|
||||
assert any("Ollama AI is unavailable" in r for r in recs)
|
||||
@@ -138,7 +137,9 @@ class TestGenerateRecommendations:
|
||||
|
||||
def test_degraded_non_lightning(self):
|
||||
"""Degraded non-Lightning dep produces no specific recommendation."""
|
||||
deps = [DependencyStatus(name="Redis", status="degraded", sovereignty_score=5, details={})]
|
||||
deps = [
|
||||
DependencyStatus(name="Redis", status="degraded", sovereignty_score=5, details={})
|
||||
]
|
||||
recs = _generate_recommendations(deps)
|
||||
assert recs == ["System operating optimally - all dependencies healthy"]
|
||||
|
||||
@@ -378,9 +379,7 @@ class TestHealthEndpoint:
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_ok_when_ollama_up(self, client):
|
||||
with patch(
|
||||
"dashboard.routes.health.check_ollama", new_callable=AsyncMock, return_value=True
|
||||
):
|
||||
with patch("dashboard.routes.health.check_ollama", new_callable=AsyncMock, return_value=True):
|
||||
data = client.get("/health").json()
|
||||
|
||||
assert data["status"] == "ok"
|
||||
@@ -416,9 +415,7 @@ class TestHealthStatusPanel:
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
|
||||
def test_shows_up_when_ollama_healthy(self, client):
|
||||
with patch(
|
||||
"dashboard.routes.health.check_ollama", new_callable=AsyncMock, return_value=True
|
||||
):
|
||||
with patch("dashboard.routes.health.check_ollama", new_callable=AsyncMock, return_value=True):
|
||||
text = client.get("/health/status").text
|
||||
|
||||
assert "UP" in text
|
||||
|
||||
@@ -1,267 +1,139 @@
|
||||
"""Tests for Claude Quota Monitor and Metabolic Protocol."""
|
||||
"""Tests for the Claude quota tracker and metabolic mode advisor.
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
Refs: #1074
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from infrastructure.claude_quota import (
|
||||
MetabolicTier,
|
||||
QuotaMonitor,
|
||||
QuotaStatus,
|
||||
_time_remaining,
|
||||
get_quota_monitor,
|
||||
ACTIVE_THRESHOLD,
|
||||
BURST_THRESHOLD,
|
||||
ClaudeCall,
|
||||
ClaudeQuotaStore,
|
||||
MetabolicMode,
|
||||
_mode_for_cost,
|
||||
current_mode,
|
||||
quota_report,
|
||||
record_usage,
|
||||
)
|
||||
|
||||
|
||||
def _make_status(five_hour: float = 0.0, seven_day: float = 0.0) -> QuotaStatus:
|
||||
"""Helper: build a QuotaStatus with given utilization values."""
|
||||
return QuotaStatus(
|
||||
five_hour_utilization=five_hour,
|
||||
five_hour_resets_at=None,
|
||||
seven_day_utilization=seven_day,
|
||||
seven_day_resets_at=None,
|
||||
raw_response={},
|
||||
fetched_at=datetime.now(UTC),
|
||||
)
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
"""Fresh quota store backed by a temp DB."""
|
||||
return ClaudeQuotaStore(db_path=tmp_path / "test_quota.db")
|
||||
|
||||
|
||||
class TestMetabolicTierThresholds:
|
||||
"""Test the three-tier metabolic protocol thresholds."""
|
||||
|
||||
def test_burst_when_five_hour_below_50pct(self):
|
||||
status = _make_status(five_hour=0.49, seven_day=0.10)
|
||||
assert status.recommended_tier == MetabolicTier.BURST
|
||||
|
||||
def test_burst_at_zero_utilization(self):
|
||||
status = _make_status(five_hour=0.0, seven_day=0.0)
|
||||
assert status.recommended_tier == MetabolicTier.BURST
|
||||
|
||||
def test_active_when_five_hour_at_50pct(self):
|
||||
status = _make_status(five_hour=0.50, seven_day=0.10)
|
||||
assert status.recommended_tier == MetabolicTier.ACTIVE
|
||||
|
||||
def test_active_when_five_hour_between_50_and_80pct(self):
|
||||
status = _make_status(five_hour=0.79, seven_day=0.10)
|
||||
assert status.recommended_tier == MetabolicTier.ACTIVE
|
||||
|
||||
def test_active_when_five_hour_at_80pct(self):
|
||||
# five_hour >= 0.80 but seven_day < 0.80 → ACTIVE (not RESTING)
|
||||
status = _make_status(five_hour=0.80, seven_day=0.50)
|
||||
assert status.recommended_tier == MetabolicTier.ACTIVE
|
||||
|
||||
def test_resting_when_seven_day_at_80pct(self):
|
||||
status = _make_status(five_hour=0.30, seven_day=0.80)
|
||||
assert status.recommended_tier == MetabolicTier.RESTING
|
||||
|
||||
def test_resting_when_seven_day_above_80pct(self):
|
||||
status = _make_status(five_hour=0.10, seven_day=0.95)
|
||||
assert status.recommended_tier == MetabolicTier.RESTING
|
||||
|
||||
def test_resting_when_both_critical(self):
|
||||
status = _make_status(five_hour=0.90, seven_day=0.90)
|
||||
assert status.recommended_tier == MetabolicTier.RESTING
|
||||
|
||||
def test_seven_day_takes_precedence_over_five_hour(self):
|
||||
# Weekly quota critical overrides whatever five-hour says
|
||||
status = _make_status(five_hour=0.10, seven_day=0.85)
|
||||
assert status.recommended_tier == MetabolicTier.RESTING
|
||||
# ── Unit: cost calculation ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestQuotaStatusProperties:
|
||||
"""Test QuotaStatus computed properties."""
|
||||
class TestClaudeCallCost:
|
||||
def test_haiku_cost(self):
|
||||
call = ClaudeCall(model="haiku", input_tokens=1_000_000, output_tokens=0)
|
||||
assert call.cost_usd == pytest.approx(0.25)
|
||||
|
||||
def test_five_hour_pct(self):
|
||||
status = _make_status(five_hour=0.42)
|
||||
assert status.five_hour_pct == 42
|
||||
def test_sonnet_output_cost(self):
|
||||
call = ClaudeCall(model="sonnet", input_tokens=0, output_tokens=1_000_000)
|
||||
assert call.cost_usd == pytest.approx(15.00)
|
||||
|
||||
def test_seven_day_pct(self):
|
||||
status = _make_status(seven_day=0.75)
|
||||
assert status.seven_day_pct == 75
|
||||
def test_opus_combined_cost(self):
|
||||
call = ClaudeCall(model="opus", input_tokens=100_000, output_tokens=50_000)
|
||||
# input: 100k * 15/1M = 1.50, output: 50k * 75/1M = 3.75 → 5.25
|
||||
assert call.cost_usd == pytest.approx(5.25)
|
||||
|
||||
def test_summary_contains_tier(self):
|
||||
status = _make_status(five_hour=0.20, seven_day=0.10)
|
||||
summary = status.summary()
|
||||
assert "burst" in summary
|
||||
assert "20%" in summary
|
||||
def test_unknown_model_uses_default(self):
|
||||
call = ClaudeCall(model="unknown-model-xyz", input_tokens=1_000_000, output_tokens=0)
|
||||
assert call.cost_usd == pytest.approx(3.00) # default input cost
|
||||
|
||||
def test_five_hour_resets_in_unknown_when_none(self):
|
||||
status = _make_status()
|
||||
assert status.five_hour_resets_in == "unknown"
|
||||
|
||||
def test_seven_day_resets_in_unknown_when_none(self):
|
||||
status = _make_status()
|
||||
assert status.seven_day_resets_in == "unknown"
|
||||
def test_zero_tokens_zero_cost(self):
|
||||
call = ClaudeCall(model="haiku", input_tokens=0, output_tokens=0)
|
||||
assert call.cost_usd == 0.0
|
||||
|
||||
|
||||
class TestTimeRemaining:
|
||||
"""Test _time_remaining helper."""
|
||||
|
||||
def test_none_returns_unknown(self):
|
||||
assert _time_remaining(None) == "unknown"
|
||||
|
||||
def test_empty_string_returns_unknown(self):
|
||||
assert _time_remaining("") == "unknown"
|
||||
|
||||
def test_past_time_returns_resetting_now(self):
|
||||
past = (datetime.now(UTC) - timedelta(hours=1)).isoformat()
|
||||
assert _time_remaining(past) == "resetting now"
|
||||
|
||||
def test_future_time_hours_and_minutes(self):
|
||||
future = (datetime.now(UTC) + timedelta(hours=2, minutes=15)).isoformat()
|
||||
result = _time_remaining(future)
|
||||
assert "2h" in result
|
||||
# Minutes may vary ±1 due to test execution time
|
||||
assert "m" in result
|
||||
|
||||
def test_future_time_minutes_only(self):
|
||||
future = (datetime.now(UTC) + timedelta(minutes=45)).isoformat()
|
||||
result = _time_remaining(future)
|
||||
assert "h" not in result
|
||||
# Minutes may vary ±1 due to test execution time
|
||||
assert "m" in result
|
||||
|
||||
def test_z_suffix_handled(self):
|
||||
future = (datetime.now(UTC) + timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
result = _time_remaining(future)
|
||||
assert result != "unknown"
|
||||
# ── Unit: metabolic mode thresholds ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestQuotaMonitorSelectModel:
|
||||
"""Test select_model metabolic routing."""
|
||||
class TestMetabolicMode:
|
||||
def test_under_burst_threshold(self):
|
||||
assert _mode_for_cost(0.0) == "BURST"
|
||||
assert _mode_for_cost(BURST_THRESHOLD - 0.01) == "BURST"
|
||||
|
||||
def test_no_quota_high_complexity_returns_14b(self):
|
||||
monitor = QuotaMonitor()
|
||||
monitor._get_token = lambda: None
|
||||
assert monitor.select_model("high") == "qwen3:14b"
|
||||
def test_at_burst_threshold_is_active(self):
|
||||
assert _mode_for_cost(BURST_THRESHOLD) == "ACTIVE"
|
||||
|
||||
def test_no_quota_low_complexity_returns_8b(self):
|
||||
monitor = QuotaMonitor()
|
||||
monitor._get_token = lambda: None
|
||||
assert monitor.select_model("low") == "qwen3:8b"
|
||||
def test_between_thresholds(self):
|
||||
mid = (BURST_THRESHOLD + ACTIVE_THRESHOLD) / 2
|
||||
assert _mode_for_cost(mid) == "ACTIVE"
|
||||
|
||||
def test_burst_tier_high_complexity_returns_cloud(self):
|
||||
monitor = QuotaMonitor()
|
||||
monitor._last_status = _make_status(five_hour=0.10, seven_day=0.10)
|
||||
monitor._cache_seconds = 9999
|
||||
result = monitor.select_model("high")
|
||||
assert result == "claude-sonnet-4-6"
|
||||
def test_at_active_threshold_is_resting(self):
|
||||
assert _mode_for_cost(ACTIVE_THRESHOLD) == "RESTING"
|
||||
|
||||
def test_burst_tier_medium_complexity_returns_14b(self):
|
||||
monitor = QuotaMonitor()
|
||||
monitor._last_status = _make_status(five_hour=0.10, seven_day=0.10)
|
||||
monitor._cache_seconds = 9999
|
||||
result = monitor.select_model("medium")
|
||||
assert result == "qwen3:14b"
|
||||
|
||||
def test_active_tier_returns_14b(self):
|
||||
monitor = QuotaMonitor()
|
||||
monitor._last_status = _make_status(five_hour=0.65, seven_day=0.10)
|
||||
monitor._cache_seconds = 9999
|
||||
result = monitor.select_model("high")
|
||||
assert result == "qwen3:14b"
|
||||
|
||||
def test_resting_tier_returns_8b(self):
|
||||
monitor = QuotaMonitor()
|
||||
monitor._last_status = _make_status(five_hour=0.10, seven_day=0.85)
|
||||
monitor._cache_seconds = 9999
|
||||
result = monitor.select_model("high")
|
||||
assert result == "qwen3:8b"
|
||||
def test_over_active_threshold(self):
|
||||
assert _mode_for_cost(ACTIVE_THRESHOLD + 10) == "RESTING"
|
||||
|
||||
|
||||
class TestQuotaMonitorShouldUseCloud:
|
||||
"""Test should_use_cloud gate."""
|
||||
|
||||
def test_no_credentials_always_false(self):
|
||||
monitor = QuotaMonitor()
|
||||
monitor._get_token = lambda: None
|
||||
assert monitor.should_use_cloud("critical") is False
|
||||
|
||||
def test_critical_task_allowed_when_under_95pct(self):
|
||||
monitor = QuotaMonitor()
|
||||
monitor._last_status = _make_status(five_hour=0.10, seven_day=0.94)
|
||||
monitor._cache_seconds = 9999
|
||||
assert monitor.should_use_cloud("critical") is True
|
||||
|
||||
def test_critical_task_blocked_when_over_95pct(self):
|
||||
monitor = QuotaMonitor()
|
||||
monitor._last_status = _make_status(five_hour=0.10, seven_day=0.96)
|
||||
monitor._cache_seconds = 9999
|
||||
assert monitor.should_use_cloud("critical") is False
|
||||
|
||||
def test_high_task_allowed_under_60pct(self):
|
||||
monitor = QuotaMonitor()
|
||||
monitor._last_status = _make_status(five_hour=0.59, seven_day=0.10)
|
||||
monitor._cache_seconds = 9999
|
||||
assert monitor.should_use_cloud("high") is True
|
||||
|
||||
def test_high_task_blocked_at_60pct(self):
|
||||
monitor = QuotaMonitor()
|
||||
monitor._last_status = _make_status(five_hour=0.60, seven_day=0.10)
|
||||
monitor._cache_seconds = 9999
|
||||
assert monitor.should_use_cloud("high") is False
|
||||
|
||||
def test_normal_task_allowed_under_30pct(self):
|
||||
monitor = QuotaMonitor()
|
||||
monitor._last_status = _make_status(five_hour=0.29, seven_day=0.10)
|
||||
monitor._cache_seconds = 9999
|
||||
assert monitor.should_use_cloud("normal") is True
|
||||
|
||||
def test_normal_task_blocked_at_30pct(self):
|
||||
monitor = QuotaMonitor()
|
||||
monitor._last_status = _make_status(five_hour=0.30, seven_day=0.10)
|
||||
monitor._cache_seconds = 9999
|
||||
assert monitor.should_use_cloud("normal") is False
|
||||
|
||||
def test_routine_task_always_false(self):
|
||||
monitor = QuotaMonitor()
|
||||
monitor._last_status = _make_status(five_hour=0.0, seven_day=0.0)
|
||||
monitor._cache_seconds = 9999
|
||||
assert monitor.should_use_cloud("routine") is False
|
||||
# ── Store: record and query ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestQuotaMonitorCaching:
|
||||
"""Test 30-second TTL cache."""
|
||||
class TestClaudeQuotaStore:
|
||||
def test_record_call(self, store):
|
||||
call = ClaudeCall(model="haiku", input_tokens=1000, output_tokens=500)
|
||||
store.record_call(call)
|
||||
summary = store.today_summary()
|
||||
assert summary.calls == 1
|
||||
assert summary.input_tokens == 1000
|
||||
assert summary.output_tokens == 500
|
||||
assert summary.cost_usd > 0
|
||||
|
||||
def test_cached_result_returned_within_ttl(self):
|
||||
monitor = QuotaMonitor()
|
||||
fresh_status = _make_status(five_hour=0.10)
|
||||
monitor._last_status = fresh_status
|
||||
monitor._cache_seconds = 30
|
||||
def test_today_summary_empty_db(self, store):
|
||||
summary = store.today_summary()
|
||||
assert summary.calls == 0
|
||||
assert summary.cost_usd == 0.0
|
||||
assert summary.mode == "BURST"
|
||||
|
||||
# Should NOT re-fetch — returns cached
|
||||
with patch.object(monitor, "_get_token", return_value="tok") as mock_tok:
|
||||
result = monitor.check()
|
||||
mock_tok.assert_not_called()
|
||||
def test_month_summary_aggregates_multiple_calls(self, store):
|
||||
for _ in range(5):
|
||||
store.record_call(ClaudeCall(model="haiku", input_tokens=100, output_tokens=50))
|
||||
month = store.month_summary()
|
||||
assert month.calls == 5
|
||||
assert month.input_tokens == 500
|
||||
assert month.output_tokens == 250
|
||||
|
||||
assert result is fresh_status
|
||||
def test_current_mode_burst_when_empty(self, store):
|
||||
assert store.current_mode() == "BURST"
|
||||
|
||||
def test_stale_cache_triggers_fetch(self):
|
||||
monitor = QuotaMonitor()
|
||||
old_time = datetime.now(UTC) - timedelta(seconds=60)
|
||||
stale_status = QuotaStatus(
|
||||
five_hour_utilization=0.10,
|
||||
five_hour_resets_at=None,
|
||||
seven_day_utilization=0.10,
|
||||
seven_day_resets_at=None,
|
||||
raw_response={},
|
||||
fetched_at=old_time,
|
||||
def test_current_mode_resting_when_expensive(self, store):
|
||||
# Record enough usage to push past ACTIVE_THRESHOLD
|
||||
# ACTIVE_THRESHOLD = 5.00, opus input = 15/1M
|
||||
# Need >5.00: 5.00/15 * 1M ≈ 333_334 input tokens
|
||||
store.record_call(
|
||||
ClaudeCall(model="opus", input_tokens=400_000, output_tokens=0)
|
||||
)
|
||||
monitor._last_status = stale_status
|
||||
mode = store.current_mode()
|
||||
assert mode == "RESTING"
|
||||
|
||||
# Token unavailable → returns None (triggers re-fetch path)
|
||||
with patch.object(monitor, "_get_token", return_value=None):
|
||||
result = monitor.check()
|
||||
|
||||
assert result is None # No credentials after cache miss
|
||||
def test_summary_as_dict(self, store):
|
||||
summary = store.today_summary()
|
||||
d = summary.as_dict()
|
||||
assert "period" in d
|
||||
assert "calls" in d
|
||||
assert "cost_usd" in d
|
||||
assert "mode" in d
|
||||
|
||||
|
||||
class TestGetQuotaMonitorSingleton:
|
||||
"""Test module-level singleton."""
|
||||
# ── Convenience functions ─────────────────────────────────────────────────────
|
||||
|
||||
def test_returns_same_instance(self):
|
||||
m1 = get_quota_monitor()
|
||||
m2 = get_quota_monitor()
|
||||
assert m1 is m2
|
||||
|
||||
def test_returns_quota_monitor_instance(self):
|
||||
monitor = get_quota_monitor()
|
||||
assert isinstance(monitor, QuotaMonitor)
|
||||
class TestConvenienceFunctions:
|
||||
def test_record_usage_does_not_raise(self):
|
||||
# Uses module-level store; should not raise even if DB path issues
|
||||
record_usage(model="haiku", input_tokens=10, output_tokens=5, task_label="test")
|
||||
|
||||
def test_current_mode_returns_valid_mode(self):
|
||||
mode = current_mode()
|
||||
assert mode in ("BURST", "ACTIVE", "RESTING")
|
||||
|
||||
def test_quota_report_returns_string(self):
|
||||
report = quota_report()
|
||||
assert isinstance(report, str)
|
||||
assert "BURST" in report or "ACTIVE" in report or "RESTING" in report
|
||||
|
||||
@@ -489,197 +489,6 @@ class TestProviderAvailabilityCheck:
|
||||
|
||||
assert router._check_provider_available(provider) is False
|
||||
|
||||
def test_check_vllm_mlx_without_requests(self):
|
||||
"""Test vllm-mlx returns True when requests not available (fallback)."""
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
|
||||
provider = Provider(
|
||||
name="vllm-mlx-local",
|
||||
type="vllm_mlx",
|
||||
enabled=True,
|
||||
priority=2,
|
||||
base_url="http://localhost:8000/v1",
|
||||
)
|
||||
|
||||
import infrastructure.router.cascade as cascade_module
|
||||
|
||||
old_requests = cascade_module.requests
|
||||
cascade_module.requests = None
|
||||
try:
|
||||
assert router._check_provider_available(provider) is True
|
||||
finally:
|
||||
cascade_module.requests = old_requests
|
||||
|
||||
def test_check_vllm_mlx_server_healthy(self):
|
||||
"""Test vllm-mlx when health check succeeds."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
|
||||
provider = Provider(
|
||||
name="vllm-mlx-local",
|
||||
type="vllm_mlx",
|
||||
enabled=True,
|
||||
priority=2,
|
||||
base_url="http://localhost:8000/v1",
|
||||
)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch("infrastructure.router.cascade.requests") as mock_requests:
|
||||
mock_requests.get.return_value = mock_response
|
||||
result = router._check_provider_available(provider)
|
||||
|
||||
assert result is True
|
||||
mock_requests.get.assert_called_once_with("http://localhost:8000/health", timeout=5)
|
||||
|
||||
def test_check_vllm_mlx_server_down(self):
|
||||
"""Test vllm-mlx when server is not running."""
|
||||
from unittest.mock import patch
|
||||
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
|
||||
provider = Provider(
|
||||
name="vllm-mlx-local",
|
||||
type="vllm_mlx",
|
||||
enabled=True,
|
||||
priority=2,
|
||||
base_url="http://localhost:8000/v1",
|
||||
)
|
||||
|
||||
with patch("infrastructure.router.cascade.requests") as mock_requests:
|
||||
mock_requests.get.side_effect = ConnectionRefusedError("Connection refused")
|
||||
result = router._check_provider_available(provider)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_check_vllm_mlx_default_url(self):
|
||||
"""Test vllm-mlx uses default localhost:8000 when no URL configured."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
|
||||
provider = Provider(
|
||||
name="vllm-mlx-local",
|
||||
type="vllm_mlx",
|
||||
enabled=True,
|
||||
priority=2,
|
||||
)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch("infrastructure.router.cascade.requests") as mock_requests:
|
||||
mock_requests.get.return_value = mock_response
|
||||
router._check_provider_available(provider)
|
||||
|
||||
mock_requests.get.assert_called_once_with("http://localhost:8000/health", timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestVllmMlxProvider:
|
||||
"""Test vllm-mlx provider integration."""
|
||||
|
||||
async def test_complete_with_vllm_mlx(self):
|
||||
"""Test successful completion via vllm-mlx."""
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
|
||||
provider = Provider(
|
||||
name="vllm-mlx-local",
|
||||
type="vllm_mlx",
|
||||
enabled=True,
|
||||
priority=2,
|
||||
base_url="http://localhost:8000/v1",
|
||||
models=[{"name": "Qwen/Qwen2.5-14B-Instruct-MLX", "default": True}],
|
||||
)
|
||||
router.providers = [provider]
|
||||
|
||||
with patch.object(router, "_call_vllm_mlx") as mock_call:
|
||||
mock_call.return_value = {
|
||||
"content": "MLX response",
|
||||
"model": "Qwen/Qwen2.5-14B-Instruct-MLX",
|
||||
}
|
||||
|
||||
result = await router.complete(
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
)
|
||||
|
||||
assert result["content"] == "MLX response"
|
||||
assert result["provider"] == "vllm-mlx-local"
|
||||
assert result["model"] == "Qwen/Qwen2.5-14B-Instruct-MLX"
|
||||
|
||||
async def test_vllm_mlx_base_url_normalization(self):
|
||||
"""Test _call_vllm_mlx appends /v1 when missing."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
|
||||
provider = Provider(
|
||||
name="vllm-mlx-local",
|
||||
type="vllm_mlx",
|
||||
enabled=True,
|
||||
priority=2,
|
||||
base_url="http://localhost:8000", # No /v1
|
||||
models=[{"name": "qwen-mlx", "default": True}],
|
||||
)
|
||||
|
||||
mock_choice = MagicMock()
|
||||
mock_choice.message.content = "hello"
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [mock_choice]
|
||||
mock_response.model = "qwen-mlx"
|
||||
|
||||
async def fake_create(**kwargs):
|
||||
return mock_response
|
||||
|
||||
with patch("openai.AsyncOpenAI") as mock_openai_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.chat.completions.create = AsyncMock(side_effect=fake_create)
|
||||
mock_openai_cls.return_value = mock_client
|
||||
|
||||
await router._call_vllm_mlx(
|
||||
provider=provider,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
model="qwen-mlx",
|
||||
temperature=0.7,
|
||||
max_tokens=None,
|
||||
)
|
||||
|
||||
call_kwargs = mock_openai_cls.call_args
|
||||
base_url_used = call_kwargs.kwargs.get("base_url") or call_kwargs[1].get("base_url")
|
||||
assert base_url_used.endswith("/v1")
|
||||
|
||||
async def test_vllm_mlx_is_local_not_cloud(self):
|
||||
"""Confirm vllm_mlx is not subject to metabolic protocol cloud skip."""
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
|
||||
provider = Provider(
|
||||
name="vllm-mlx-local",
|
||||
type="vllm_mlx",
|
||||
enabled=True,
|
||||
priority=2,
|
||||
base_url="http://localhost:8000/v1",
|
||||
models=[{"name": "qwen-mlx", "default": True}],
|
||||
)
|
||||
router.providers = [provider]
|
||||
|
||||
# Quota monitor returns False (block cloud) — vllm_mlx should still be tried
|
||||
with patch("infrastructure.router.cascade._quota_monitor") as mock_qm:
|
||||
mock_qm.check.return_value = object()
|
||||
mock_qm.should_use_cloud.return_value = False
|
||||
|
||||
with patch.object(router, "_call_vllm_mlx") as mock_call:
|
||||
mock_call.return_value = {
|
||||
"content": "Local MLX response",
|
||||
"model": "qwen-mlx",
|
||||
}
|
||||
result = await router.complete(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
|
||||
assert result["content"] == "Local MLX response"
|
||||
|
||||
|
||||
class TestCascadeRouterReload:
|
||||
"""Test hot-reload of providers.yaml."""
|
||||
|
||||
@@ -175,7 +175,9 @@ async def test_bridge_run_simple_response():
|
||||
bridge = MCPBridge(include_gitea=False, include_shell=False)
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"message": {"role": "assistant", "content": "Hello!"}}
|
||||
mock_resp.json.return_value = {
|
||||
"message": {"role": "assistant", "content": "Hello!"}
|
||||
}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
mock_client = AsyncMock()
|
||||
@@ -236,7 +238,9 @@ async def test_bridge_run_with_tool_call():
|
||||
|
||||
# Round 2: model returns final text
|
||||
final_resp = MagicMock()
|
||||
final_resp.json.return_value = {"message": {"role": "assistant", "content": "Done with tools!"}}
|
||||
final_resp.json.return_value = {
|
||||
"message": {"role": "assistant", "content": "Done with tools!"}
|
||||
}
|
||||
final_resp.raise_for_status = MagicMock()
|
||||
|
||||
mock_client = AsyncMock()
|
||||
@@ -272,13 +276,17 @@ async def test_bridge_run_unknown_tool():
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{"function": {"name": "nonexistent", "arguments": {}}}],
|
||||
"tool_calls": [
|
||||
{"function": {"name": "nonexistent", "arguments": {}}}
|
||||
],
|
||||
}
|
||||
}
|
||||
tool_call_resp.raise_for_status = MagicMock()
|
||||
|
||||
final_resp = MagicMock()
|
||||
final_resp.json.return_value = {"message": {"role": "assistant", "content": "OK"}}
|
||||
final_resp.json.return_value = {
|
||||
"message": {"role": "assistant", "content": "OK"}
|
||||
}
|
||||
final_resp.raise_for_status = MagicMock()
|
||||
|
||||
mock_client = AsyncMock()
|
||||
@@ -324,7 +332,9 @@ async def test_bridge_run_max_rounds():
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{"function": {"name": "loop_tool", "arguments": {}}}],
|
||||
"tool_calls": [
|
||||
{"function": {"name": "loop_tool", "arguments": {}}}
|
||||
],
|
||||
}
|
||||
}
|
||||
tool_call_resp.raise_for_status = MagicMock()
|
||||
@@ -355,7 +365,9 @@ async def test_bridge_run_connection_error():
|
||||
bridge = MCPBridge(include_gitea=False, include_shell=False)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
|
||||
mock_client.post = AsyncMock(
|
||||
side_effect=httpx.ConnectError("Connection refused")
|
||||
)
|
||||
mock_client.aclose = AsyncMock()
|
||||
|
||||
bridge._client = mock_client
|
||||
|
||||
@@ -9,6 +9,7 @@ import pytest
|
||||
from timmy.research_triage import (
|
||||
ActionItem,
|
||||
_parse_llm_response,
|
||||
_resolve_label_ids,
|
||||
_validate_action_item,
|
||||
create_gitea_issue,
|
||||
extract_action_items,
|
||||
@@ -249,9 +250,7 @@ class TestCreateGiteaIssue:
|
||||
|
||||
with (
|
||||
patch("timmy.research_triage.settings") as mock_settings,
|
||||
patch(
|
||||
"timmy.research_triage._resolve_label_ids", new_callable=AsyncMock, return_value=[1]
|
||||
),
|
||||
patch("timmy.research_triage._resolve_label_ids", new_callable=AsyncMock, return_value=[1]),
|
||||
patch("timmy.research_triage.httpx.AsyncClient") as mock_cls,
|
||||
):
|
||||
mock_settings.gitea_enabled = True
|
||||
@@ -285,9 +284,7 @@ class TestCreateGiteaIssue:
|
||||
|
||||
with (
|
||||
patch("timmy.research_triage.settings") as mock_settings,
|
||||
patch(
|
||||
"timmy.research_triage._resolve_label_ids", new_callable=AsyncMock, return_value=[]
|
||||
),
|
||||
patch("timmy.research_triage._resolve_label_ids", new_callable=AsyncMock, return_value=[]),
|
||||
patch("timmy.research_triage.httpx.AsyncClient") as mock_cls,
|
||||
):
|
||||
mock_settings.gitea_enabled = True
|
||||
@@ -334,9 +331,7 @@ class TestTriageResearchReport:
|
||||
|
||||
with (
|
||||
patch("timmy.research_triage.settings") as mock_settings,
|
||||
patch(
|
||||
"timmy.research_triage._resolve_label_ids", new_callable=AsyncMock, return_value=[]
|
||||
),
|
||||
patch("timmy.research_triage._resolve_label_ids", new_callable=AsyncMock, return_value=[]),
|
||||
patch("timmy.research_triage.httpx.AsyncClient") as mock_cls,
|
||||
):
|
||||
mock_settings.gitea_enabled = True
|
||||
|
||||
@@ -1,460 +0,0 @@
|
||||
"""Unit tests for timmy.kimi_delegation — Kimi research delegation via Gitea labels."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from timmy.kimi_delegation import (
|
||||
KIMI_LABEL_COLOR,
|
||||
KIMI_READY_LABEL,
|
||||
_build_research_template,
|
||||
_extract_action_items,
|
||||
_slugify,
|
||||
delegate_research_to_kimi,
|
||||
exceeds_local_capacity,
|
||||
)
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_kimi_ready_label():
|
||||
assert KIMI_READY_LABEL == "kimi-ready"
|
||||
|
||||
|
||||
def test_kimi_label_color_is_hex():
|
||||
assert KIMI_LABEL_COLOR.startswith("#")
|
||||
assert len(KIMI_LABEL_COLOR) == 7
|
||||
|
||||
|
||||
# ── exceeds_local_capacity ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExceedsLocalCapacity:
|
||||
def test_keyword_comprehensive(self):
|
||||
assert exceeds_local_capacity("Do a comprehensive review of X") is True
|
||||
|
||||
def test_keyword_deep_research(self):
|
||||
assert exceeds_local_capacity("deep research into neural networks") is True
|
||||
|
||||
def test_keyword_benchmark(self):
|
||||
assert exceeds_local_capacity("benchmark these five models") is True
|
||||
|
||||
def test_keyword_exhaustive(self):
|
||||
assert exceeds_local_capacity("exhaustive list of options") is True
|
||||
|
||||
def test_keyword_case_insensitive(self):
|
||||
assert exceeds_local_capacity("COMPREHENSIVE analysis") is True
|
||||
|
||||
def test_keyword_survey(self):
|
||||
assert exceeds_local_capacity("survey all available tools") is True
|
||||
|
||||
def test_keyword_extensive(self):
|
||||
assert exceeds_local_capacity("extensive documentation needed") is True
|
||||
|
||||
def test_short_simple_task(self):
|
||||
assert exceeds_local_capacity("fix the login bug") is False
|
||||
|
||||
def test_long_task_exceeds_word_threshold(self):
|
||||
long_task = " ".join(["word"] * 55)
|
||||
assert exceeds_local_capacity(long_task) is True
|
||||
|
||||
def test_exactly_at_threshold(self):
|
||||
at_threshold = " ".join(["word"] * 50)
|
||||
assert exceeds_local_capacity(at_threshold) is True
|
||||
|
||||
def test_just_below_threshold(self):
|
||||
short = " ".join(["word"] * 49)
|
||||
assert exceeds_local_capacity(short) is False
|
||||
|
||||
def test_empty_string(self):
|
||||
assert exceeds_local_capacity("") is False
|
||||
|
||||
|
||||
# ── _slugify ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSlugify:
|
||||
def test_simple_text(self):
|
||||
assert _slugify("Hello World") == "hello-world"
|
||||
|
||||
def test_special_characters_removed(self):
|
||||
assert _slugify("Hello, World!") == "hello-world"
|
||||
|
||||
def test_underscores_become_dashes(self):
|
||||
assert _slugify("hello_world") == "hello-world"
|
||||
|
||||
def test_multiple_spaces(self):
|
||||
assert _slugify("hello world") == "hello-world"
|
||||
|
||||
def test_truncates_to_60(self):
|
||||
long = "a" * 80
|
||||
result = _slugify(long)
|
||||
assert len(result) <= 60
|
||||
|
||||
def test_no_leading_trailing_dashes(self):
|
||||
result = _slugify(" hello ")
|
||||
assert not result.startswith("-")
|
||||
assert not result.endswith("-")
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _slugify("") == ""
|
||||
|
||||
|
||||
# ── _build_research_template ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildResearchTemplate:
|
||||
def test_contains_task(self):
|
||||
body = _build_research_template("My Task", "some context", "What is X?")
|
||||
assert "My Task" in body
|
||||
|
||||
def test_contains_question(self):
|
||||
body = _build_research_template("Task", "ctx", "What is the answer?")
|
||||
assert "What is the answer?" in body
|
||||
|
||||
def test_contains_context(self):
|
||||
body = _build_research_template("Task", "project background", "Q?")
|
||||
assert "project background" in body
|
||||
|
||||
def test_contains_kimi_ready_label(self):
|
||||
body = _build_research_template("Task", "ctx", "Q?")
|
||||
assert KIMI_READY_LABEL in body
|
||||
|
||||
def test_default_priority_normal(self):
|
||||
body = _build_research_template("Task", "ctx", "Q?")
|
||||
assert "normal" in body
|
||||
|
||||
def test_custom_priority_high(self):
|
||||
body = _build_research_template("Task", "ctx", "Q?", priority="high")
|
||||
assert "high" in body
|
||||
|
||||
def test_contains_deliverables_section(self):
|
||||
body = _build_research_template("Task", "ctx", "Q?")
|
||||
assert "Deliverables" in body
|
||||
|
||||
def test_slug_in_artifact_path(self):
|
||||
body = _build_research_template("My Research Task", "ctx", "Q?")
|
||||
assert "my-research-task" in body
|
||||
|
||||
def test_contains_research_request_header(self):
|
||||
body = _build_research_template("Task", "ctx", "Q?")
|
||||
assert "## Research Request" in body
|
||||
|
||||
|
||||
# ── _extract_action_items ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtractActionItems:
|
||||
def test_checkbox_items(self):
|
||||
text = "- [ ] Do thing A\n- [ ] Do thing B"
|
||||
items = _extract_action_items(text)
|
||||
assert "Do thing A" in items
|
||||
assert "Do thing B" in items
|
||||
|
||||
def test_numbered_list(self):
|
||||
text = "1. First step\n2. Second step\n3. Third step"
|
||||
items = _extract_action_items(text)
|
||||
assert "First step" in items
|
||||
assert "Second step" in items
|
||||
assert "Third step" in items
|
||||
|
||||
def test_action_prefix(self):
|
||||
text = "Action: Implement caching layer"
|
||||
items = _extract_action_items(text)
|
||||
assert "Implement caching layer" in items
|
||||
|
||||
def test_todo_prefix(self):
|
||||
text = "TODO: Write tests"
|
||||
items = _extract_action_items(text)
|
||||
assert "Write tests" in items
|
||||
|
||||
def test_next_step_prefix(self):
|
||||
text = "Next step: Deploy to staging"
|
||||
items = _extract_action_items(text)
|
||||
assert "Deploy to staging" in items
|
||||
|
||||
def test_case_insensitive_prefixes(self):
|
||||
text = "TODO: Upper\ntodo: lower\nTodo: Mixed"
|
||||
items = _extract_action_items(text)
|
||||
assert len(items) == 3
|
||||
|
||||
def test_deduplication(self):
|
||||
text = "1. Do the thing\n2. Do the thing"
|
||||
items = _extract_action_items(text)
|
||||
assert items.count("Do the thing") == 1
|
||||
|
||||
def test_empty_text(self):
|
||||
assert _extract_action_items("") == []
|
||||
|
||||
def test_no_action_items(self):
|
||||
text = "This is just a paragraph with no action items."
|
||||
assert _extract_action_items(text) == []
|
||||
|
||||
def test_returns_list(self):
|
||||
assert isinstance(_extract_action_items("1. Item"), list)
|
||||
|
||||
|
||||
# ── delegate_research_to_kimi ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDelegateResearchToKimi:
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_task_returns_error(self):
|
||||
result = await delegate_research_to_kimi("", "context", "question?")
|
||||
assert result["success"] is False
|
||||
assert "task" in result["error"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_whitespace_task_returns_error(self):
|
||||
result = await delegate_research_to_kimi(" ", "context", "question?")
|
||||
assert result["success"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_question_returns_error(self):
|
||||
result = await delegate_research_to_kimi("Task title", "context", "")
|
||||
assert result["success"] is False
|
||||
assert "question" in result["error"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_whitespace_question_returns_error(self):
|
||||
result = await delegate_research_to_kimi("Task", "ctx", " ")
|
||||
assert result["success"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delegates_to_create_issue(self):
|
||||
with patch(
|
||||
"timmy.kimi_delegation.create_kimi_research_issue",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"success": True,
|
||||
"issue_number": 42,
|
||||
"issue_url": "http://x/42",
|
||||
"error": None,
|
||||
},
|
||||
) as mock_create:
|
||||
result = await delegate_research_to_kimi("Task", "ctx", "What is X?", "high")
|
||||
mock_create.assert_awaited_once_with("Task", "ctx", "What is X?", "high")
|
||||
assert result["success"] is True
|
||||
assert result["issue_number"] == 42
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passes_default_priority(self):
|
||||
with patch(
|
||||
"timmy.kimi_delegation.create_kimi_research_issue",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"success": True, "issue_number": 1, "issue_url": "", "error": None},
|
||||
) as mock_create:
|
||||
await delegate_research_to_kimi("Task", "ctx", "Q?")
|
||||
_, _, _, priority = mock_create.call_args.args
|
||||
assert priority == "normal"
|
||||
|
||||
|
||||
# ── create_kimi_research_issue ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCreateKimiResearchIssue:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_gitea_token_returns_error(self):
|
||||
from timmy.kimi_delegation import create_kimi_research_issue
|
||||
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.gitea_enabled = True
|
||||
mock_settings.gitea_token = ""
|
||||
|
||||
with patch("config.settings", mock_settings):
|
||||
result = await create_kimi_research_issue("Task", "ctx", "Q?")
|
||||
assert result["success"] is False
|
||||
assert "not configured" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitea_disabled_returns_error(self):
|
||||
from timmy.kimi_delegation import create_kimi_research_issue
|
||||
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.gitea_enabled = False
|
||||
mock_settings.gitea_token = "tok"
|
||||
|
||||
with patch("config.settings", mock_settings):
|
||||
result = await create_kimi_research_issue("Task", "ctx", "Q?")
|
||||
assert result["success"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_issue_creation(self):
|
||||
from timmy.kimi_delegation import create_kimi_research_issue
|
||||
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.gitea_enabled = True
|
||||
mock_settings.gitea_token = "fake-token"
|
||||
mock_settings.gitea_url = "http://gitea.local"
|
||||
mock_settings.gitea_repo = "owner/repo"
|
||||
|
||||
label_resp = MagicMock()
|
||||
label_resp.status_code = 200
|
||||
label_resp.json.return_value = [{"name": "kimi-ready", "id": 7}]
|
||||
|
||||
issue_resp = MagicMock()
|
||||
issue_resp.status_code = 201
|
||||
issue_resp.json.return_value = {
|
||||
"number": 101,
|
||||
"html_url": "http://gitea.local/issues/101",
|
||||
}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = label_resp
|
||||
mock_client.post.return_value = issue_resp
|
||||
|
||||
async_ctx = AsyncMock()
|
||||
async_ctx.__aenter__.return_value = mock_client
|
||||
async_ctx.__aexit__.return_value = False
|
||||
|
||||
with (
|
||||
patch("config.settings", mock_settings),
|
||||
patch("httpx.AsyncClient", return_value=async_ctx),
|
||||
):
|
||||
result = await create_kimi_research_issue("Task", "ctx", "Q?")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["issue_number"] == 101
|
||||
assert result["error"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_error_returns_failure(self):
|
||||
from timmy.kimi_delegation import create_kimi_research_issue
|
||||
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.gitea_enabled = True
|
||||
mock_settings.gitea_token = "tok"
|
||||
mock_settings.gitea_url = "http://gitea.local"
|
||||
mock_settings.gitea_repo = "owner/repo"
|
||||
|
||||
label_resp = MagicMock()
|
||||
label_resp.status_code = 200
|
||||
label_resp.json.return_value = [{"name": "kimi-ready", "id": 7}]
|
||||
|
||||
issue_resp = MagicMock()
|
||||
issue_resp.status_code = 500
|
||||
issue_resp.text = "Internal Server Error"
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = label_resp
|
||||
mock_client.post.return_value = issue_resp
|
||||
|
||||
async_ctx = AsyncMock()
|
||||
async_ctx.__aenter__.return_value = mock_client
|
||||
async_ctx.__aexit__.return_value = False
|
||||
|
||||
with (
|
||||
patch("config.settings", mock_settings),
|
||||
patch("httpx.AsyncClient", return_value=async_ctx),
|
||||
):
|
||||
result = await create_kimi_research_issue("Task", "ctx", "Q?")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "500" in result["error"]
|
||||
|
||||
|
||||
# ── index_kimi_artifact ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIndexKimiArtifact:
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_artifact_returns_error(self):
|
||||
from timmy.kimi_delegation import index_kimi_artifact
|
||||
|
||||
result = await index_kimi_artifact(42, "Title", "")
|
||||
assert result["success"] is False
|
||||
assert "Empty" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_whitespace_only_artifact_returns_error(self):
|
||||
from timmy.kimi_delegation import index_kimi_artifact
|
||||
|
||||
result = await index_kimi_artifact(42, "Title", " \n ")
|
||||
assert result["success"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_indexing(self):
|
||||
from timmy.kimi_delegation import index_kimi_artifact
|
||||
|
||||
mock_entry = MagicMock()
|
||||
mock_entry.id = "mem-abc-123"
|
||||
|
||||
with patch("timmy.memory_system.store_memory", return_value=mock_entry) as mock_store:
|
||||
result = await index_kimi_artifact(55, "Research Title", "Artifact content here.")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["memory_id"] == "mem-abc-123"
|
||||
mock_store.assert_called_once()
|
||||
call_kwargs = mock_store.call_args.kwargs
|
||||
assert call_kwargs["source"] == "kimi"
|
||||
assert call_kwargs["context_type"] == "document"
|
||||
assert call_kwargs["task_id"] == "55"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_memory_exception_returns_error(self):
|
||||
from timmy.kimi_delegation import index_kimi_artifact
|
||||
|
||||
with patch(
|
||||
"timmy.memory_system.store_memory",
|
||||
side_effect=RuntimeError("DB error"),
|
||||
):
|
||||
result = await index_kimi_artifact(1, "T", "Some content")
|
||||
assert result["success"] is False
|
||||
assert "DB error" in result["error"]
|
||||
|
||||
|
||||
# ── extract_and_create_followups ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtractAndCreateFollowups:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_action_items_returns_empty_list(self):
|
||||
from timmy.kimi_delegation import extract_and_create_followups
|
||||
|
||||
result = await extract_and_create_followups("No action items here.", 10)
|
||||
assert result["success"] is True
|
||||
assert result["created"] == []
|
||||
assert result["error"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitea_not_configured(self):
|
||||
from timmy.kimi_delegation import extract_and_create_followups
|
||||
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.gitea_enabled = False
|
||||
mock_settings.gitea_token = ""
|
||||
|
||||
with patch("config.settings", mock_settings):
|
||||
result = await extract_and_create_followups("1. Do the thing", 10)
|
||||
assert result["success"] is False
|
||||
assert result["created"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_followup_issues(self):
|
||||
from timmy.kimi_delegation import extract_and_create_followups
|
||||
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.gitea_enabled = True
|
||||
mock_settings.gitea_token = "tok"
|
||||
mock_settings.gitea_url = "http://gitea.local"
|
||||
mock_settings.gitea_repo = "owner/repo"
|
||||
|
||||
issue_resp = MagicMock()
|
||||
issue_resp.status_code = 201
|
||||
issue_resp.json.return_value = {"number": 200}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.return_value = issue_resp
|
||||
|
||||
async_ctx = AsyncMock()
|
||||
async_ctx.__aenter__.return_value = mock_client
|
||||
async_ctx.__aexit__.return_value = False
|
||||
|
||||
with (
|
||||
patch("config.settings", mock_settings),
|
||||
patch("httpx.AsyncClient", return_value=async_ctx),
|
||||
):
|
||||
result = await extract_and_create_followups("1. Do the thing\n2. Do another thing", 10)
|
||||
|
||||
assert result["success"] is True
|
||||
assert 200 in result["created"]
|
||||
Reference in New Issue
Block a user