Skip to content

Add LLM conversations and actions for player bots - #81

Merged
pmbstyle merged 59 commits into
mainfrom
agent/hot-bot-llm
Aug 5, 2026
Merged

Add LLM conversations and actions for player bots#81
pmbstyle merged 59 commits into
mainfrom
agent/hot-bot-llm

Conversation

@pmbstyle

@pmbstyle pmbstyle commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Summary

  • route direct player conversations with hot and cold bots through OpenRouter whenever the LLM integration is enabled
  • keep compact per-player conversation history, persona and recent activity context, with automatic summarization and optional local Langfuse tracing
  • select a single responder for party chat through explicit names, partial-name addressing, conversation focus and a lightweight ambiguity router
  • expose bounded, audited engine tools for equipment, trade, pull state, combat priorities, buffs, regrouping, town errands and supply delivery
  • let merchant bots negotiate within server-owned price limits and atomically relist their public stores at the agreed price
  • preserve the deterministic bot brain for background behavior and avoid ambient LLM calls when no player is talking to a bot

Why

Bot dialogue previously had no durable conversational context or safe bridge into server actions. Direct messages could bypass the LLM, cold bots were effectively disconnected from chat, party messages could prompt several bots at once, and generated replies could claim actions that had not actually completed. Merchant negotiation also could not update the public store that the player was interacting with.

This change adds one progressive AI layer around the existing bot runtime: the LLM handles player-facing communication and high-level intent, while authoritative game services validate and execute every action.

Player and developer impact

  • bot replies retain conversational continuity without sending an unbounded transcript on every request
  • party follow-ups such as short confirmations stay with the active conversation owner, while commands addressed to a party member reach only that bot
  • cold companions can answer remotely and perform supported arrival or town-supply workflows without simulating an invisible full route
  • tool results are reflected truthfully in chat, including insufficient funds, unavailable actions, combat restrictions and committed trade/store state
  • merchant bots understand preferred and minimum prices and can recreate a sell store after an accepted bargain
  • Langfuse captures model stages, usage and tool execution in development when explicitly enabled; it remains disabled by default

Reliability and safety

  • interactive chat is admitted globally and processed in FIFO order without artificial per-message cooldowns
  • structured responses can recover from reasoning-heavy or truncated model output
  • tool authorization, idempotency, stale-world checks and committed-state validation prevent duplicate or outdated mutations
  • purchase windows carry store revisions and prices so clients cannot buy against a stale relisting
  • supply and trade flows use atomic persistence and lifecycle checks before announcing success

Validation

  • full npm test suite
  • JavaScript syntax validation
  • focused regression coverage for OpenRouter, Langfuse, conversation storage and summarization, hot/cold chat, party routing, tool authorization, supply errands, trade atomicity and merchant store negotiation
  • git diff --check

Summary by CodeRabbit

  • New Features
    • Bots now support richer party dialogue, role-based routing, follow-up conversations, and optional arrival when requested.
    • Added party regrouping, combat preferences, equipment upgrades, supply errands, resource delivery, and bot trading.
    • Added merchant price negotiation with quotes, counteroffers, reservations, and accepted-price trading.
    • Bots can maintain conversation history and create ambient interactions between eligible characters.
    • Added configurable inference limits and optional diagnostics for bot interactions.
  • Bug Fixes
    • Improved purchase and trade reliability with stock, price, rollback, and duplicate-action protections.
    • Added clearer suggestions for misspelled bot names and safer handling of disconnects and interrupted workflows.
    • Improved bot support actions so confirmations are only reported after effects are applied.

pmbstyle added 30 commits August 1, 2026 19:01
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1e1b9257-09af-4077-b0f9-ffdfde6d9866

📥 Commits

Reviewing files that changed from the base of the PR and between d34cc82 and af082e3.

📒 Files selected for processing (1)
  • tests/test_trade_store_atomicity.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_trade_store_atomicity.js

📝 Walkthrough

Walkthrough

This change adds structured bot inference, Langfuse tracing, persistent conversations and journals, party routing, ambient scenes, policy-controlled tools, supply errands, negotiations, atomic trades, lifecycle integration, and expanded automated tests.

Changes

Bot AI platform

Layer / File(s) Summary
Configuration and persistence
config/*, database/sql/sqlite.sql, src/Database.js
OpenRouter, Langfuse, ambient-scene, conversation, journal, tool, LLM-turn, and negotiation storage is added. Inventory transfer now runs transactionally.
AI services and dialogue
src/GameServer/Bot/AI/*
Gateway requests, tracing, context assembly, conversation persistence, summarization, inference budgets, structured turns, cold chat, and hot dialogue queues are added.
Party control and ambient behavior
src/GameServer/Bot/AI/Party*, src/GameServer/Bot/AI/HotBotPolicyOverlay.js, src/GameServer/Bot/AI/BotAgentTools.js
Party routing, policy overlays, regrouping, ambient scenes, tool authorization, equipment actions, arrival state, and structured action outcomes are added.
Supply, negotiation, and trade
src/GameServer/Bot/AI/BotSupplyErrand.js, src/GameServer/Bot/Economy/*, src/GameServer/Bot/BotTradeService.js, src/GameServer/Bot/TradeService.js
Supply errands, merchant revisions, store republishing, price negotiation, reservations, atomic transfers, rollback, and replay handling are added.
Runtime wiring and tests
src/GameServer/Bot/*, src/GameServer/Network/*, src/GameServer/World/*, tests/*
Bot lifecycle, messaging, status, event journaling, tracing startup, population handling, and integration tests are updated.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the PR's primary addition of LLM-powered conversations and actions for player bots.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch agent/hot-bot-llm

Comment @coderabbitai help to get the list of available commands.

@socket-security

socket-security Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​langfuse/​otel@​5.10.01001009099100
Addednpm/​@​opentelemetry/​sdk-node@​0.221.09810010097100
Addednpm/​@​langfuse/​tracing@​5.10.010010010099100

View full report

@socket-security

socket-security Bot commented Aug 5, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm @protobufjs/float is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: ?npm/@opentelemetry/sdk-node@0.221.0npm/@protobufjs/float@1.0.2

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@protobufjs/float@1.0.2. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm yargs is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: ?npm/@opentelemetry/sdk-node@0.221.0npm/yargs@17.7.3

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/yargs@17.7.3. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@pmbstyle pmbstyle self-assigned this Aug 5, 2026
@pmbstyle
pmbstyle marked this pull request as ready for review August 5, 2026 15:59

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/GameServer/Bot/BotManager.js (1)

1396-1408: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Do not report a cancelled ambient scene as completed.

deliver skips output when ambientScene.cancelled is true, but the timeout at line 1406 still calls BotAmbientDirector.finish(ambientScene, 'completed'). A scene that was cancelled mid-flight is then recorded as completed. Apply the same check before you finish the scene.

🐛 Proposed fix
-        setTimeout(() => ambientScene
-            ? BotAmbientDirector.finish(ambientScene, 'completed')
-            : BotConversation.finish(conversation), 6500);
+        setTimeout(() => {
+            if (!ambientScene) return BotConversation.finish(conversation);
+            if (ambientScene.cancelled || ambientScene.finished) return undefined;
+            return BotAmbientDirector.finish(ambientScene, 'completed');
+        }, 6500);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/BotManager.js` around lines 1396 - 1408, Update the final
timeout in the conversation delivery flow around deliver so it does not call
BotAmbientDirector.finish with 'completed' when ambientScene.cancelled is true.
Preserve completion for active scenes and the existing BotConversation.finish
behavior when no ambient scene is present.
🟠 Major comments (24)
src/GameServer/Session.js-285-285 (1)

285-285: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard the new cleanup call so a throw cannot abort session teardown.

The placement is correct. cleanup runs before this.actor.destructor() and before this.actor = null, so it can still read the actor.

The call is not guarded. error() is the connection-close and error handler. If cleanup throws, the remaining teardown on Lines 289-306 does not run: the character status is not persisted, effects are not cleared, companions are not detached, the actor is not destroyed, and World.removeUser(this) never executes. The session then stays in World.user.sessions with a dead socket, and dataSendToOthers writes to it for every other visible player.

A throw is plausible. BotNegotiationService.cleanup reaches activeFor, which calls stockValid, which accesses session.actor.backpack and calls item.fetchSelfId() without optional chaining. A partially initialized or partially torn-down actor raises a TypeError there.

Wrap the call in a try/catch and log the failure.

🛡️ Proposed fix
         if (this.actor) {
-            invoke('GameServer/Bot/BotTradeService').cleanup(this, 'disconnect');
+            try {
+                invoke('GameServer/Bot/BotTradeService').cleanup(this, 'disconnect');
+            } catch (cleanupError) {
+                utils.infoWarn('GameServer', 'bot trade cleanup failed on disconnect: %s', cleanupError.message || cleanupError);
+            }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Session.js` at line 285, Guard the
invoke('GameServer/Bot/BotTradeService').cleanup call in the session teardown
path with try/catch so any cleanup exception is logged and does not prevent the
remaining error() teardown steps from running. Keep the call before
this.actor.destructor() and this.actor = null, and use the existing error
logging mechanism to record the failure.
src/GameServer/Bot/TradeService.js-251-259 (1)

251-259: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Log a failed Adena refund. The current code can drop the evidence.

If giveAdena fails, the actor has paid and received nothing. Line 255 preserves that fact only when error is an object. deductAdena rejects with the string "Not enough Adena.", and giveItem rejects with the string `Unknown item ${selfId}.`. For a string rejection, Line 255 is false and rollbackError is discarded, so the currency loss leaves no trace.

Add a warning log inside the catch block so every refund failure is recorded with the actor id and the amount, independent of the rejection type.

🛠️ Proposed fix
             if (adenaDeducted) {
                 try {
                     await giveAdena(actor, totalCost);
                 } catch (rollbackError) {
+                    utils.infoWarn(
+                        'TradeService',
+                        'adena refund failed for actor %s: amount=%d item=%s error=%s',
+                        actor.fetchId(),
+                        totalCost,
+                        selfId,
+                        rollbackError?.message || String(rollbackError)
+                    );
                     if (error && typeof error === 'object') {
                         error.rollbackError = rollbackError;
                     }
                 }
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/TradeService.js` around lines 251 - 259, Update the
giveAdena rollback catch block in the trade flow to always emit a warning
containing the actor id and totalCost when the refund fails, regardless of the
original rejection type; preserve the existing rollbackError attachment behavior
for object errors.
src/GameServer/Bot/Economy/BotNegotiationService.js-451-481 (1)

451-481: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

acceptPrice returns a Promise on the merchant-store path but callers do not await it.

acceptPrice is not marked async, so its return type depends on negotiation.storeRevision. When storeRevision is set, line 478 returns republishAcceptedStore(bot, negotiation), which is async. Otherwise, line 480 returns a plain object.

Callers do not await. In BotAgentTools.js (line 767–773), the function calls BotNegotiationService.acceptPrice() and immediately reads result.ok without await. The same occurs in test_bot_negotiation_flow.js (line 61) and test_bot_negotiation_database.js (line 41).

When storeRevision is set and acceptPrice returns a Promise, the caller reads result.ok on the Promise object, which is undefined. The caller then misinterprets a pending or completed store republish as a failure.

Make acceptPrice async and update all callers to await the result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/Economy/BotNegotiationService.js` around lines 451 - 481,
Make acceptPrice asynchronous so it consistently returns a Promise, including
the non-store path, and update every caller—including BotAgentTools and the
negotiation flow/database tests—to await its result before reading result.ok or
other fields.

Source: Linters/SAST tools

src/GameServer/Network/Request/Purchase.js-59-68 (1)

59-68: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make multi-entry merchant purchases atomic. Each buyFromStore call commits independently, so a later revision or price failure leaves earlier entries purchased while the handler sends actionFailed(). Process the full list through one transaction or roll back all committed entries, including inventory, Adena, store stock, and seller state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Network/Request/Purchase.js` around lines 59 - 68, Update the
purchase flow surrounding the loop in the request handler so all data.list
entries are processed atomically through a single transaction, or ensure
complete rollback when any buyFromStore call fails. Preserve all-or-nothing
state across inventory, Adena, store stock, and seller state, and only send
actionFailed() after the entire operation has been reverted.
src/GameServer/Bot/Population/Cooldown.js-36-37 (1)

36-37: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Move cleanup after a successful state save.

Lines 36–37 call BotTradeService.cleanup and BotAmbientDirector.cleanup before LifeState.upsertState on line 38. Both cleanup methods mutate the session's active trade and ambient scene state. If the state save fails on line 38 (returning null), the method returns state_save_failed on line 39. The session remains in memory with its trade and ambient state already cleared, but the cold state was not persisted to the database. This leaves the session in an inconsistent state.

Move the cleanup calls into the .then() chain after the !saved check succeeds, so cleanup only occurs after a successful save.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/Population/Cooldown.js` around lines 36 - 37, Move the
BotTradeService.cleanup and BotAmbientDirector.cleanup calls from before
LifeState.upsertState into the successful-save branch of its .then() chain,
after the !saved failure check. Preserve the state_save_failed return path so
both cleanups run only when the cold state has been persisted successfully.
tests/test_llm_equipment_tools.js-50-50 (1)

50-50: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Restore BotManager.sessions and correct the finally comment.

Line 50 overwrites BotManager.sessions, which is shared module state, and never restores it. The finally block at lines 76-78 is empty and its comment states that the fixture changes no persistent state. That statement is wrong.

Any later test in the same process that reads BotManager.sessions observes this fake bot fixture. The leak is silent and can produce a false pass or a false failure elsewhere. tests/test_cold_bot_chat.js lines 106-129 already saves and restores BotManager.findSessionByName for the same reason.

🐛 Proposed fix
+const originalSessions = BotManager.sessions;
 BotManager.sessions = [bot];
     console.log('LLM equipment tool checks passed');
 } finally {
-    // no persistent world state is changed by this fixture
+    BotManager.sessions = originalSessions;
 }

Also applies to: 76-78

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_llm_equipment_tools.js` at line 50, Update the test fixture around
BotManager.sessions so it saves the original shared sessions value before
assigning [bot], then restores that value in the finally block. Replace the
incorrect finally comment with one describing the restoration, ensuring later
tests see the original BotManager.sessions state.
src/GameServer/Bot/AI/PartyLLMRouter.js-149-157 (1)

149-157: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not set timeoutMs: 0 for this request. OpenRouterGateway disables the abort timer when timeoutMs is 0. A stalled provider request can keep the router lock active until the session ends. Preserve the configured positive timeout instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/AI/PartyLLMRouter.js` around lines 149 - 157, Update the
OpenRouterGateway.request call in PartyLLMRouter so it does not override the
request timeout with timeoutMs: 0; instead, preserve the existing positive
timeout from the cfg/config path when building the config object. Keep the rest
of the routing options in the same request flow unchanged, and adjust only the
timeout handling on the request sent from the PartyLLMRouter logic.
src/GameServer/Bot/AI/PartyDialogueRouter.js-134-162 (1)

134-162: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Restrict candidates to bot sessions.

buildCandidates applies isOnline and then eligible. For the party channel, eligible requires isCompanion, so only companion bots pass. For local chat, eligible is only distance <= hearingRadius. No predicate requires that the session belongs to a bot.

BotManager passes sessions: this.sessions and then starts an LLM reply for deterministicRoute.candidate (see src/GameServer/Bot/BotManager.js lines 774-804). If this.sessions contains human player sessions, a nearby player name, or the speaker's own name, resolves to a non-bot candidate and the reply pipeline runs for that session. Add an explicit bot check to buildCandidates.

#!/bin/bash
# Description: Determine whether the sessions passed to PartyDialogueRouter.select include human players.
set -euo pipefail

rg -n -C10 'PartyDialogueRouter.select' --type=js src
rg -n -C4 'this\.sessions\s*=' --type=js src/GameServer/Bot/BotManager.js
rg -n -C3 '\bisBot\b|botSession|isBotSession' --type=js src/GameServer/Bot | head -60
🐛 Proposed fix
 function buildCandidates({ sessions = [], playerSession, partyChannel = false, hearingRadius = HEARING_RADIUS } = {}) {
     const player = playerSession?.actor;
     const configuredPullerId = Number(playerSession?.partyCompanionSettings?.pullerId || 0);
     return sessions
+        .filter((session) => session !== playerSession && isBotSession(session))
         .filter((session) => isOnline(session))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/AI/PartyDialogueRouter.js` around lines 134 - 162, Update
buildCandidates to explicitly retain only bot sessions before applying the
existing isOnline and eligible filters. Reuse the repository’s established
bot-session predicate or bot marker identified in the surrounding BotManager
code, ensuring human sessions—including the speaker’s own session—cannot become
candidates while preserving the current party-channel and hearing-radius
eligibility behavior.
src/GameServer/Bot/AI/PartyDialogueState.js-74-91 (1)

74-91: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add expiry to the in-flight and router locks.

beginRouter refuses a new router call while routerInFlightAt is set, and only clearRouter resets it. inFlightBotId is only cleared by clearInFlight or recordDeliveredReply. Both values persist on the player session for its whole lifetime.

If a reply path ends without reaching its clear call, two effects follow: the cheap router stays disabled for that player, and PartyDialogueRouter.select keeps returning the stale in_flight candidate for every later message. Compare the stored timestamp against a deadline so a stuck lock self-recovers.

#!/bin/bash
# Description: Check that every beginRouter/beginRequest path has a guaranteed clear.
set -euo pipefail

rg -n -C12 'beginRouter\(|clearRouter\(' --type=js src
rg -n -C8 'clearInFlight\(|beginRequest\(' --type=js src
🛡️ Proposed fix
+const ROUTER_LOCK_TTL_MS = 30 * 1000;
+const IN_FLIGHT_TTL_MS = 60 * 1000;
+
 function beginRouter(playerSession, at = Date.now()) {
     const state = ensure(playerSession);
     if (!state) return false;
-    if (state.routerInFlightAt) return false;
+    const startedAt = Number(state.routerInFlightAt || 0);
+    if (startedAt && (Number(at || Date.now()) - startedAt) < ROUTER_LOCK_TTL_MS) return false;
     state.routerInFlightAt = Number(at || Date.now());
     return true;
 }

Apply the same deadline check where inFlightBotId is read, so snapshot never reports a stale in-flight bot.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/AI/PartyDialogueState.js` around lines 74 - 91, Add expiry
handling to the router and bot in-flight locks: update beginRouter to clear
routerInFlightAt and allow a new request when the stored timestamp exceeds the
configured deadline, and update the snapshot/read path used by
PartyDialogueRouter.select to ignore and clear stale inFlightBotId values.
Preserve current lock behavior while timestamps remain within the deadline,
using the existing time/deadline configuration symbols where available.
src/GameServer/Bot/BotTradeService.js-340-345 (1)

340-345: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

incomingSlots never counts a new stackable stack, so the capacity check can pass on a full inventory.

Line 343 requires !line.stackable. A stackable line that the recipient does not yet hold is therefore excluded from incomingNew, although it needs a free inventory slot. A non-stackable line is counted even when a same-selfId item is already present, which is also wrong because non-stackable items never merge.

The intended rule is: a line needs a new slot when it is not stackable, or when it is stackable and no matching stack exists.

🐛 Proposed fix
 function incomingSlots(session, outgoingLines) {
     const inventory = session.actor.backpack.fetchItems();
     const existingSelfIds = new Set(inventory.filter((item) => item.fetchStackable?.()).map((item) => Number(item.fetchSelfId())));
-    const incomingNew = outgoingLines.filter((line) => !line.stackable && !existingSelfIds.has(Number(line.selfId))).length;
+    const incomingNew = outgoingLines.filter((line) => !line.stackable || !existingSelfIds.has(Number(line.selfId))).length;
     return inventory.length + incomingNew <= MAX_INVENTORY_ITEMS;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/BotTradeService.js` around lines 340 - 345, Update
incomingSlots so incomingNew counts each outgoing line that requires a new
inventory slot: every non-stackable line, plus stackable lines whose selfId is
absent from existingSelfIds. Preserve the existing inventory-length capacity
calculation and avoid counting stackable lines that can merge with an existing
matching stack.
src/GameServer/Bot/BotManager.js-985-990 (1)

985-990: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard deterministicRoute.candidates before you pass it to the router.

Line 822 reads deterministicRoute.candidates?.length, so the field is treated as optional there. Lines 988 and 989 use it directly, and line 989 calls .find(...) on it. If PartyDialogueRouter.select returns needs_router or ambiguous without a candidates array, this throws a TypeError inside handlePlayerSpeakNow, the router flag stays set through beginRouter, and the player receives no reply.

Normalize the list once.

🐛 Proposed fix
             if (PartyDialogueState.beginRouter(playerSession)) {
+                const candidates = deterministicRoute.candidates || [];
                 return PartyLLMRouter.route({
                     text: rawText,
                     playerSession,
-                    candidates: deterministicRoute.candidates,
-                    selectedBotId: deterministicRoute.candidates.find((candidate) => candidate.selected)?.id || null,
+                    candidates,
+                    selectedBotId: candidates.find((candidate) => candidate.selected)?.id || null,
                     dialogueState: PartyDialogueState.snapshot(playerSession)
                 }).then((routerResult) => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/BotManager.js` around lines 985 - 990, Normalize
deterministicRoute.candidates once before the PartyLLMRouter.route call,
defaulting a missing value to an empty array. Use that normalized list for both
the candidates property and selectedBotId lookup, preserving
handlePlayerSpeakNow behavior when the route has no candidates.
src/GameServer/Bot/AI/States/ShoppingState.js-130-143 (1)

130-143: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Guard the purchased item before you read fetchId().

purchased.item can be undefined, and bot.backpack.fetchItemFromSelfId(companionErrand.itemId) can return null when the stack was merged under a different lookup key. Line 138 then throws a TypeError. The catch block at line 168 treats that throw as a purchase failure, so the bot reports "I could not complete that supply purchase", clears session.pendingResourceDelivery, and records failed telemetry — after the Adena was already spent and the item was added to the bot inventory. The item is then never delivered.

Fail with an explicit reason only when the item is really missing, and keep the delivery record when the purchase succeeded.

🐛 Proposed fix to validate the purchased item
                 const purchasedItem = purchased.item || bot.backpack.fetchItemFromSelfId(companionErrand.itemId);
+                if (!purchasedItem?.fetchId) {
+                    throw new Error('purchased_item_missing');
+                }
                 session.pendingResourceDelivery = {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/AI/States/ShoppingState.js` around lines 130 - 143,
Validate the resolved purchasedItem in the purchase flow before calling
fetchId(), and throw an explicit missing-item reason only when both
purchased.item and fetchItemFromSelfId return no item. Preserve the successful
purchase state by retaining session.pendingResourceDelivery and allowing
delivery to proceed whenever an item was resolved; only treat a genuinely
missing item as a failure.
src/GameServer/Bot/BotManager.js-741-762 (1)

741-762: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound the ingress chain and stop the rejection from escaping.

Two concerns in this serialization:

  1. tracked rethrows the failure at line 758. Callers of handlePlayerSpeak in the packet path normally ignore the return value, so a throw inside handlePlayerSpeakNow becomes an unhandled promise rejection.
  2. The chain has no depth limit. Each party-chat line appends another link that waits for the previous one. A player who sends lines faster than the router resolves grows the chain without bound, and the last queued line answers minutes later.

Log and swallow the failure at the tail, and drop the turn when a chain is already pending beyond a small limit.

🛡️ Proposed fix sketch
         const previous = playerSession.partyDialogueIngressPromise || Promise.resolve();
+        playerSession.partyDialogueIngressDepth = Number(playerSession.partyDialogueIngressDepth || 0);
+        if (playerSession.partyDialogueIngressDepth >= 3) {
+            return Promise.resolve({ ok: false, reason: 'ingress_busy' });
+        }
+        playerSession.partyDialogueIngressDepth += 1;
         const run = Promise.resolve(previous)
             .catch(() => {})
             .then(() => this.handlePlayerSpeakNow(playerSession, data));
         let tracked;
         const clear = () => {
+            playerSession.partyDialogueIngressDepth = Math.max(0, Number(playerSession.partyDialogueIngressDepth || 1) - 1);
             if (playerSession.partyDialogueIngressPromise === tracked) {
                 delete playerSession.partyDialogueIngressPromise;
             }
         };
         tracked = run.then(
             (result) => {
                 clear();
                 return result;
             },
             (error) => {
                 clear();
-                throw error;
+                utils.infoWarn('BotDialogue', 'party ingress turn failed: %s', error?.message || error);
+                return { ok: false, reason: 'ingress_error' };
             }
         );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/BotManager.js` around lines 741 - 762, Update the party
dialogue serialization around handlePlayerSpeak to swallow failures at the
tracked promise tail after logging them, rather than rethrowing and creating
unhandled rejections. Before appending a new turn, detect when
playerSession.partyDialogueIngressPromise already represents a queue beyond a
small bounded limit and drop the new turn; preserve cleanup of the tracked
promise when it settles.
src/GameServer/Bot/BotTradeService.js-308-313 (1)

308-313: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

The gift ledger is charged at offer time and never refunded on cancel.

offerBotItem adds delta to ledger.units when the line is created. cancelTrade calls releaseReservations, which removes the reservation entries but leaves ledger.units unchanged. A repeated open-and-cancel sequence therefore consumes the whole MAX_BOT_GIFT_UNITS budget for the hour although no item ever changed hands, and the bot then refuses legitimate gifts with gift_budget_exceeded.

Charge the ledger on successful commit, or record the charged amount per trade and refund it in releaseReservations.

🐛 Proposed direction: refund on release
     const delta = Math.max(0, nextCount - (current?.count || 0));
     if (!trade.supplyDelivery) {
         const ledger = botGiftLedger(botSession);
         if (ledger.units + delta > MAX_BOT_GIFT_UNITS) return { ok: false, reason: 'gift_budget_exceeded' };
         ledger.units += delta;
+        trade.giftUnitsCharged = Number(trade.giftUnitsCharged || 0) + delta;
     }
 function releaseReservations(trade) {
     const bot = trade?.botSession;
     if (!bot) return;
+    if (trade.state !== 'committed' && Number(trade.giftUnitsCharged || 0) > 0) {
+        const ledger = botGiftLedger(bot);
+        ledger.units = Math.max(0, ledger.units - Number(trade.giftUnitsCharged));
+        trade.giftUnitsCharged = 0;
+    }
     const reservations = botReservations(bot);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/BotTradeService.js` around lines 308 - 313, Update the
gift-budget accounting across offerBotItem and releaseReservations so
ledger.units is not permanently consumed when a trade is cancelled. Track each
offer’s charged delta by trade, then refund that amount when releaseReservations
removes the reservation, while preserving the MAX_BOT_GIFT_UNITS check and
preventing double refunds.
src/GameServer/Bot/AI/BotToolRegistry.js-182-183 (1)

182-183: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

session.botToolExecutions grows without bound.

Line 183 creates the map once per session and nothing ever removes entries from it. Every tool execution adds one entry keyed by turn, player, and action. A long-lived bot session accumulates entries for the whole session lifetime.

Two consequences:

  • The map retains a normalized result object per executed tool for as long as the session lives. This is a memory leak on the hot-bot path.
  • Line 230 materializes the full entry list with [...mutationStore.entries()] on every mutating call. Cost grows linearly with the number of past executions, so the total work over a session is quadratic.

Store the executions per turn and drop the previous turn when a new turn starts.

🛡️ Proposed fix sketch
-    const mutationStore = session && (session.botToolExecutions ||= new Map());
+    let mutationStore = null;
+    if (session) {
+        if (!session.botToolExecutions || session.botToolExecutionsTurn !== currentTurn) {
+            session.botToolExecutions = new Map();
+            session.botToolExecutionsTurn = currentTurn;
+        }
+        mutationStore = session.botToolExecutions;
+    }

With the store scoped to the current turn, the prefix scan at Lines 230-231 reduces to mutationStore.size > 0.

Also applies to: 229-237

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/AI/BotToolRegistry.js` around lines 182 - 183, Update the
mutation store initialization and lookup logic around mutationKey and the
mutating-call handling to scope executions to the current turn: when currentTurn
changes, discard the prior turn’s map and create a fresh store, while preserving
entries within the same turn. Replace the full mutationStore.entries() prefix
scan with a direct non-empty check, ensuring only current-turn executions are
retained and reused.
src/GameServer/Bot/AI/BotWorkflowTelemetry.js-2-2 (1)

2-2: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

activeWorkflows never releases abandoned workflows.

Line 32 adds an entry on the first phase of a workflow. Line 62 removes it only when the caller passes options.terminal === true. A workflow that never reaches a terminal phase leaves its entry in the map permanently. Player logout, bot death, an errand timeout, and any thrown exception all produce that outcome.

Two consequences:

  • The map grows without bound for the whole server uptime.
  • The root observation created at Line 25 is never ended, so the trace stays open in the Langfuse backend and the phase spans of later workflows are the only ones that close.

Add a TTL sweep and a cancellation entry point.

🛡️ Proposed fix sketch
 const activeWorkflows = new Map();
+const WORKFLOW_TTL_MS = 10 * 60 * 1000;
+
+function sweep(now) {
+    activeWorkflows.forEach((workflow, id) => {
+        if (now - workflow.startedAt < WORKFLOW_TTL_MS) return;
+        workflow.root?.end(
+            { workflowId: id, outcome: 'abandoned', durationMs: now - workflow.startedAt },
+            LangfuseTracing.observationStatus({ applied: false, reason: 'abandoned' })
+        );
+        activeWorkflows.delete(id);
+    });
+}
+
+function cancel(workflowId, reason = 'cancelled') {
+    const id = text(workflowId, 128);
+    const workflow = activeWorkflows.get(id);
+    if (!workflow) return false;
+    workflow.root?.end(
+        { workflowId: id, outcome: 'cancelled', reason, durationMs: Math.max(0, Date.now() - workflow.startedAt) },
+        LangfuseTracing.observationStatus({ applied: false, reason })
+    );
+    activeWorkflows.delete(id);
+    return true;
+}

Then call sweep(Date.now()) at the start of recordSupply, and export cancel so the bot lifecycle can close a workflow when a session ends.

Also applies to: 23-33, 51-63

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/AI/BotWorkflowTelemetry.js` at line 2, Add TTL-based
cleanup to the activeWorkflows management around recordSupply, including a
sweep(timestamp) helper that ends and removes workflows exceeding the configured
TTL; invoke sweep(Date.now()) at the start of recordSupply. Add and export
cancel so bot lifecycle code can explicitly end and remove a workflow when its
session ends, while preserving terminal cleanup behavior.
src/GameServer/Bot/AI/BotToolRegistry.js-34-53 (1)

34-53: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Bind the actor when reading coordinates. Creature.fetchLocX(), fetchLocY(), and fetchLocZ() access this.model, so passing them as unbound callbacks makes loc catch a TypeError and return 0. This prevents worldRevision from detecting actor movement and can make stale_world_state ineffective.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/AI/BotToolRegistry.js` around lines 34 - 53, Update
worldRevision’s coordinate reads so fetchLocX, fetchLocY, and fetchLocZ are
invoked with actor as their receiver, preserving the existing loc normalization
and fallback behavior while ensuring movement changes the computed revision.
src/GameServer/Bot/AI/BotRemoteChat.js-11-31 (1)

11-31: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Denial of Service (CWE-770): Allocation of Resources Without Limits or Throttling

Reachability: External · Exploitability: Moderate

Bound cold-chat work per player–bot pair.

timeoutMs: 0 disables the gateway deadline. A hung request keeps the queue head and its budget reservation active. enqueue and the global interactive waiter list have no depth cap. bypass: true skips request and token quotas, while circuitBreaker: false allows new requests after failures. Set a finite timeout, cap queued tells with a fallback response, and apply a cold-chat budget.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/AI/BotRemoteChat.js` around lines 11 - 31, Update the
cold-chat flow around config(), enqueue(), and the interactive waiter handling
to use a finite gateway timeout, limit queued tells per player–bot pair with a
fallback response when the cap is reached, and enforce the cold-chat
request/token budget. Remove quota bypass behavior and enable circuit-breaker
enforcement so hung or repeatedly failing requests cannot hold resources
indefinitely.
src/Database.js-507-522 (1)

507-522: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Two transfer entries for the same sourceItemId can duplicate items.

The validation loop (Lines 507-516) reads every source row before the mutation loop (Lines 519-522) writes. If two entries reference the same sourceItemId, both validate against the same pre-write snapshot, and both compute remaining from the stale source.amount. The second UPDATE overwrites the first. The target then receives amount1 + amount2 while the source loses only max(amount1, amount2).

Aggregate the requested amount per source item and validate the total.

🐛 Proposed fix: reject or aggregate duplicate source rows
             const sources = entries.map((entry) => {
                 if (!entry.fromCharacterId || !entry.toCharacterId || !entry.sourceItemId || !entry.selfId || entry.amount <= 0) {
                     throw new Error('invalid inventory transfer');
                 }
                 const source = one('SELECT id, selfId, name, amount, equipped, slot, petData FROM items WHERE id = ? AND characterId = ?', [entry.sourceItemId, entry.fromCharacterId]);
                 if (!source || Number(source.selfId) !== entry.selfId || Number(source.amount) < entry.amount || Number(source.equipped) !== 0) {
                     throw new Error('inventory item changed');
                 }
                 return { entry, source };
             });
+
+            // A batch must never read the same source row twice: both checks
+            // would pass against the pre-write snapshot and the second UPDATE
+            // would overwrite the first, duplicating the item.
+            const requested = new Map();
+            sources.forEach(({ entry }) => {
+                const key = `${entry.fromCharacterId}:${entry.sourceItemId}`;
+                requested.set(key, (requested.get(key) || 0) + entry.amount);
+            });
+            sources.forEach(({ entry, source }) => {
+                if (requested.get(`${entry.fromCharacterId}:${entry.sourceItemId}`) > Number(source.amount)) {
+                    throw new Error('inventory item changed');
+                }
+            });

Note that this rejects over-allocation but still leaves the stale remaining computation for duplicates. Deriving remaining from a running per-row balance instead of source.amount removes the overwrite completely.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Database.js` around lines 507 - 522, Update the transfer validation and
mutation flow around the sources mapping and moved loop to aggregate requested
amounts by sourceItemId before validating or mutating. Validate each unique
source against its total requested amount, then apply a single deletion or
update using that aggregate so duplicate entries cannot overwrite one another or
transfer more than the source holds.
src/GameServer/Bot/AI/OpenRouterGateway.js-455-457 (1)

455-457: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

An interactive request runs with no client timeout.

cfg.timeoutMs > 0 gates the abort timer. BotBrain.requestDecision passes timeoutMs: 0, so hot dialogue turns get no AbortController deadline and no completion-token limit (completionLimit returns null for interactive === true). A stalled provider connection then holds session.brainInFlight until the runtime's own socket timeout expires, and the pending-turn queue in BotBrain keeps growing during that window.

Apply an upper bound for interactive requests instead of disabling the timeout completely.

🛡️ Proposed fix
+    // An interactive turn may take longer than a background one, but it must
+    // still have a deadline: the caller holds brainInFlight until it resolves.
+    interactiveTimeoutMs: 60000,
     maxTokens: 320,
     timeoutMs: 3500,
-    const timeout = cfg.timeoutMs > 0
-        ? setTimeout(() => controller.abort(), Math.max(1, cfg.timeoutMs))
-        : null;
+    const deadlineMs = cfg.timeoutMs > 0
+        ? cfg.timeoutMs
+        : (requestData.interactive === true ? cfg.interactiveTimeoutMs : 0);
+    const timeout = deadlineMs > 0
+        ? setTimeout(() => controller.abort(), Math.max(1, deadlineMs))
+        : null;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/AI/OpenRouterGateway.js` around lines 455 - 457, Update
the timeout calculation near the request’s AbortController setup so interactive
requests receive a finite upper-bound deadline even when cfg.timeoutMs is 0.
Preserve configured positive timeouts, and apply the interactive fallback only
for the BotBrain.requestDecision path without changing unrelated request
behavior.
src/GameServer/Bot/AI/BotBrain.js-852-864 (1)

852-864: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The pending-turn queue has no bound.

Every chat message that arrives while session.brainInFlight is true is pushed to session.pendingBrainTurns. Nothing caps the array length, and nothing drops old entries. Each queued entry later becomes one provider request, and BotInferenceBudget.reserve is called with bypass: true at Line 882, so the budget does not reject it.

One player who sends chat faster than a turn completes therefore grows the array and the outbound inference cost without limit. Cap the queue and drop or coalesce the oldest entries when the cap is reached.

🛡️ Proposed bound on the queue
+const MAX_PENDING_BRAIN_TURNS = 3;
+
         if (session.brainInFlight) {
             const pending = {
                 event,
                 status,
                 text,
                 requestContext
             };
             const queue = session.pendingBrainTurns || (session.pendingBrainTurns = []);
+            if (queue.length >= MAX_PENDING_BRAIN_TURNS) {
+                debugSkip(session, cfg, 'pending_queue_full');
+                fallbackReply(session, requestContext, 'pending_queue_full');
+                return true;
+            }
             queue.push(pending);
             session.pendingBrainTurn = queue[0];
             debugSkip(session, cfg, 'request_queued');
             return true;
         }

Run the following script to check whether the chat ingress path already caps admission per player or per bot:

#!/bin/bash
set -euo pipefail
fd -t f -e js . src/GameServer/Network/Request src/GameServer/Bot/AI \
  | xargs -r rg -n -C5 'pendingBrainTurns|admission|maxPending|queueLimit|admit\(' 
fd -t f 'BotConversationService.js|ChatArrivalState.js|Speak.js' src | xargs -r rg -n -C5 'limit|max|queue|admit'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/AI/BotBrain.js` around lines 852 - 864, Bound
session.pendingBrainTurns in the brainInFlight branch before appending new
pending turns. When the configured cap is reached, drop or coalesce the oldest
queued entries so the queue remains bounded, while preserving the existing
pendingBrainTurn and request_queued behavior for retained entries.
src/GameServer/Bot/AI/BotAmbientDirector.js-273-294 (1)

273-294: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Store the participant sessions on the scene instead of recovering them from conversation lines.

finish rebuilds the participant sessions from scene.conversation?.lines?.[0]?.speaker and lines?.[1]?.speaker. start already holds the authoritative initiator and responder pair but does not keep them on the scene. Note that scene.participants holds display names (Line 254), not sessions.

If the conversation has fewer than two lines, or if speaker is not the session object, participants is empty. Then session.ambientScene is never cleared. expireSceneIfNeeded calls finish again, finish returns early on scene.finished, and ambientScene stays set forever. Every later eligible call returns scene_active at Line 205, so that bot never joins another ambient scene for the rest of its session.

Keep the session pair on the scene and iterate it.

🐛 Proposed fix
     const scene = {
         id: `ambient-${actorId(initiator)}-${actorId(responder)}-${now}`,
         topic: conversation.topic,
         participants: [actorName(initiator), actorName(responder)],
+        sessions: [initiator, responder],
         startedAt: now,
     scene.finished = true;
     BotConversation.finish(scene.conversation || scene);
     activeScenes.delete(scene.id);
-    const participants = [scene.conversation?.lines?.[0]?.speaker, scene.conversation?.lines?.[1]?.speaker]
-        .filter(Boolean);
+    const participants = (scene.sessions || []).filter(Boolean);
     participants.forEach((session) => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/AI/BotAmbientDirector.js` around lines 273 - 294, Update
the scene construction in start to store the authoritative initiator and
responder session objects on a dedicated scene field, then change finish to
iterate that stored participant pair instead of deriving sessions from
conversation lines. Preserve the existing cleanup, event recording, and refresh
behavior while ensuring both sessions have ambientScene cleared even when the
conversation has fewer than two lines.
src/GameServer/Bot/AI/BotConversationStore.js-149-185 (1)

149-185: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not latch a conversation into memory-only mode after a transient database error.

Two caches latch together and prevent recovery:

  1. ensureSchema stores its result in schemaPromise (Lines 130-135). One early failure, for example the database is not ready at server start, fixes the result at false for the process lifetime.
  2. ensureConversation returns the cached entry at Line 156 before any database work. Line 180 calls memoryEntry, which creates an entry whose conversation.id starts with memory:.

After a transient failure the pair is cached with a memory: id. Every later call returns that entry at Line 156. appendTurn then skips persistence at Line 307, and loadTurns skips the database at Line 192. The conversation never persists again, even after the database recovers. Only resetMemory clears it.

Cache the schema probe result only on success, and re-attempt the database lookup when the cached entry is still a memory: placeholder.

🛡️ Proposed fix
 function ensureSchema() {
     if (!databaseReady()) return Promise.resolve(false);
     if (!schemaPromise) {
         schemaPromise = Database.execute([
             'SELECT 1 FROM bot_conversations LIMIT 1',
             []
-        ], 'schema:bot-conversations').then(() => true).catch(() => false);
+        ], 'schema:bot-conversations').then(() => true).catch(() => {
+            // Allow a later retry once the database becomes available.
+            schemaPromise = null;
+            return false;
+        });
     }
     return schemaPromise;
 }
     const key = pairKey(player, bot);
     const cached = memory.get(key);
-    if (cached) return cached;
+    // A `memory:` id means persistence was unavailable earlier. Retry the
+    // database so the pair does not stay memory-only for the whole process.
+    if (cached && !String(cached.conversation?.id || '').startsWith('memory:')) return cached;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/AI/BotConversationStore.js` around lines 149 - 185, Update
ensureSchema so schemaPromise is retained only after a successful schema probe;
clear or avoid caching it when the probe fails so later calls can retry. In
ensureConversation, do not immediately return cached entries whose conversation
id has the memory: placeholder; re-run the database lookup and creation flow for
those entries, while preserving the fast return for persisted conversations.
src/GameServer/Bot/AI/BotConversationStore.js-247-265 (1)

247-265: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Allocate the turn ordinal atomically.

Lines 249-257 run UPDATE bot_conversations SET nextTurnOrdinal = nextTurnOrdinal + 1 and a separate SELECT nextTurnOrdinal with no transaction. Two concurrent appends for the same conversation can both increment and then both read the same larger value, so two messages receive the same turnOrdinal.

The store orders model-visible history by turnOrdinal, messageOrder, id (Lines 79-85 and 209-212), and messageOrder derives from the role. A collision interleaves player and bot messages in the history sent to the model. setSummary also marks compacted = 1 for every row with turnOrdinal <= throughOrdinal (Lines 424-427), so a collision can compact a turn that was never summarized.

Allocate and read in one statement, or wrap both statements in a transaction.

Run the following script to confirm whether concurrent same-pair appends are reachable and whether Database.execute supports transactions:

#!/bin/bash
# Description: Trace appendTurn callers, per-pair write serialization, and transaction support.
set -euo pipefail

fd -t f 'Database.js' src
ast-grep outline src/Database.js --items all
rg -nP -C4 '\b(BEGIN|transaction|serialize|RETURNING)\b' src/Database.js

rg -nP -C4 'appendTurn\s*\(' src
rg -nP -C8 'queueConversationWrite' src
ast-grep run --pattern 'function queueConversationWrite($$$) { $$$ }' --lang javascript src || true
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/AI/BotConversationStore.js` around lines 247 - 265, The
turn ordinal allocation in the append flow must be atomic: update the database
and obtain the newly allocated value in one statement, or execute the existing
UPDATE and SELECT within a transaction. Update the logic surrounding the visible
Database.execute calls, preserving the memory: fallback and assignment to
entry.conversation.nextTurnOrdinal while ensuring concurrent appends for the
same conversation receive distinct ordinals.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bd2192a0-30df-4ab4-8ce8-633d7ab39f6f

📥 Commits

Reviewing files that changed from the base of the PR and between 1771b99 and 74082a9.

📒 Files selected for processing (122)
  • config/default.ini
  • config/local.example.ini
  • database/sql/sqlite.sql
  • package.json
  • scripts/run-tests.js
  • src/Database.js
  • src/GameServer/Actor/Attack.js
  • src/GameServer/Actor/Backpack.js
  • src/GameServer/Actor/Generics/Die.js
  • src/GameServer/Actor/Generics/LevelUp.js
  • src/GameServer/Actor/Generics/NpcDied.js
  • src/GameServer/Actor/Generics/Revive.js
  • src/GameServer/Actor/Generics/Select.js
  • src/GameServer/Bot/AI/BotAgentTools.js
  • src/GameServer/Bot/AI/BotAmbientDirector.js
  • src/GameServer/Bot/AI/BotBrain.js
  • src/GameServer/Bot/AI/BotBrainContext.js
  • src/GameServer/Bot/AI/BotCombatUtility.js
  • src/GameServer/Bot/AI/BotContextAssembler.js
  • src/GameServer/Bot/AI/BotConversationService.js
  • src/GameServer/Bot/AI/BotConversationStore.js
  • src/GameServer/Bot/AI/BotConversationSummarizer.js
  • src/GameServer/Bot/AI/BotDialogueArbiter.js
  • src/GameServer/Bot/AI/BotEquipmentUpgrade.js
  • src/GameServer/Bot/AI/BotEventJournal.js
  • src/GameServer/Bot/AI/BotInferenceBudget.js
  • src/GameServer/Bot/AI/BotLLMTurnStore.js
  • src/GameServer/Bot/AI/BotLootEtiquette.js
  • src/GameServer/Bot/AI/BotRemoteChat.js
  • src/GameServer/Bot/AI/BotSkillCapabilities.js
  • src/GameServer/Bot/AI/BotStatus.js
  • src/GameServer/Bot/AI/BotSupplyErrand.js
  • src/GameServer/Bot/AI/BotSupportPlanner.js
  • src/GameServer/Bot/AI/BotToolAudit.js
  • src/GameServer/Bot/AI/BotToolRegistry.js
  • src/GameServer/Bot/AI/BotTownTravel.js
  • src/GameServer/Bot/AI/BotWorkflowTelemetry.js
  • src/GameServer/Bot/AI/ChatArrivalState.js
  • src/GameServer/Bot/AI/HotBotPolicyOverlay.js
  • src/GameServer/Bot/AI/LangfuseTracing.js
  • src/GameServer/Bot/AI/OpenRouterGateway.js
  • src/GameServer/Bot/AI/PartyAddressResolver.js
  • src/GameServer/Bot/AI/PartyCompanionService.js
  • src/GameServer/Bot/AI/PartyDialogueRouter.js
  • src/GameServer/Bot/AI/PartyDialogueState.js
  • src/GameServer/Bot/AI/PartyLLMRouter.js
  • src/GameServer/Bot/AI/PartyPulling.js
  • src/GameServer/Bot/AI/States/FollowingState.js
  • src/GameServer/Bot/AI/States/ShoppingState.js
  • src/GameServer/Bot/BotAI.js
  • src/GameServer/Bot/BotManager.js
  • src/GameServer/Bot/BotTradeService.js
  • src/GameServer/Bot/Economy/BotMerchantStoreService.js
  • src/GameServer/Bot/Economy/BotNegotiationService.js
  • src/GameServer/Bot/Economy/MarketOpportunity.js
  • src/GameServer/Bot/Population/BackgroundResolver.js
  • src/GameServer/Bot/Population/BotLifeState.js
  • src/GameServer/Bot/Population/Cooldown.js
  • src/GameServer/Bot/Population/HotActivation.js
  • src/GameServer/Bot/Population/PopulationService.js
  • src/GameServer/Bot/TradeService.js
  • src/GameServer/Network/Request/Purchase.js
  • src/GameServer/Network/Request/Speak.js
  • src/GameServer/Network/Request/TradeDone.js
  • src/GameServer/Session.js
  • src/GameServer/World/Generics/NpcShopBuyLists.js
  • src/GameServer/World/TownRespawn.js
  • src/GameServer/World/World.js
  • src/NodeL2.js
  • tests/test_ai_config_surface.js
  • tests/test_bot_activity_journal.js
  • tests/test_bot_agent_support_confirmation.js
  • tests/test_bot_ambient_director.js
  • tests/test_bot_brain_state_change.js
  • tests/test_bot_chat_commands.js
  • tests/test_bot_context_assembler.js
  • tests/test_bot_conversation_store.js
  • tests/test_bot_conversation_summary.js
  • tests/test_bot_dialogue_arbiter.js
  • tests/test_bot_inference_budget.js
  • tests/test_bot_inference_interactive_queue.js
  • tests/test_bot_llm_party_policy.js
  • tests/test_bot_merchant_store_negotiation.js
  • tests/test_bot_name_suggestion.js
  • tests/test_bot_negotiation_database.js
  • tests/test_bot_negotiation_flow.js
  • tests/test_bot_negotiation_policy.js
  • tests/test_bot_outbound_trade.js
  • tests/test_bot_party_chat.js
  • tests/test_bot_support_planner.js
  • tests/test_bot_tool_authorization.js
  • tests/test_bot_tool_pending_audit.js
  • tests/test_bot_tool_registry.js
  • tests/test_bot_town_travel.js
  • tests/test_bot_trade_atomicity.js
  • tests/test_bot_trade_database.js
  • tests/test_bot_trade_reservations.js
  • tests/test_chat_arrival_state.js
  • tests/test_cold_bot_chat.js
  • tests/test_hot_bot_conversation_flow.js
  • tests/test_hot_bot_policy_overlay.js
  • tests/test_hot_bot_queue_failure.js
  • tests/test_hot_bot_schema_repair.js
  • tests/test_hot_conversation_history_queue.js
  • tests/test_langfuse_tracing.js
  • tests/test_llm_configured_supply_store.js
  • tests/test_llm_equipment_tools.js
  • tests/test_llm_negotiation_tools.js
  • tests/test_llm_party_regroup.js
  • tests/test_llm_pull_policy_tools.js
  • tests/test_llm_skill_priority_tools.js
  • tests/test_llm_supply_errand.js
  • tests/test_llm_trade_tools.js
  • tests/test_openrouter_gateway.js
  • tests/test_party_address_resolver.js
  • tests/test_party_chat_routing_integration.js
  • tests/test_party_dialogue_router.js
  • tests/test_party_dialogue_state.js
  • tests/test_party_llm_router.js
  • tests/test_sqlite_bot_conversation_migration.js
  • tests/test_supply_trade_lifecycle.js
  • tests/test_trade_store_atomicity.js

Comment thread src/GameServer/Bot/AI/BotInferenceBudget.js
Comment thread src/GameServer/Bot/AI/States/FollowingState.js Outdated
Comment thread src/GameServer/Bot/TradeService.js
Comment thread src/GameServer/Bot/TradeService.js Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/GameServer/Bot/TradeService.js (1)

263-270: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make a failed refund recoverable.

If giveItem fails after deductAdena succeeds, Line 265 attempts a refund. If that database write also fails, this code only attaches rollbackError and rethrows. The actor then has no item and a persisted Adena deduction.

Use one database transaction for the debit and item transfer. If that is not possible, persist and retry a compensating refund before reporting the purchase as failed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/GameServer/Bot/TradeService.js` around lines 263 - 270, Update the trade
flow around deductAdena, giveItem, and the adenaDeducted rollback so the Adena
debit and item transfer execute within one database transaction and commit or
roll back together. If a shared transaction is unavailable, persist the failed
refund and retry the compensating giveAdena operation before rethrowing the
purchase error, rather than only attaching rollbackError.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_trade_store_atomicity.js`:
- Around line 90-95: Update the stale-price rejection test around
TradeService.buyFromStore to retain the buyer returned by actor(4) instead of
passing it inline, then assert the buyer’s Adena balance remains 100 after the
rejection. Preserve the existing assertion that the repriced lot count remains
unchanged.

---

Outside diff comments:
In `@src/GameServer/Bot/TradeService.js`:
- Around line 263-270: Update the trade flow around deductAdena, giveItem, and
the adenaDeducted rollback so the Adena debit and item transfer execute within
one database transaction and commit or roll back together. If a shared
transaction is unavailable, persist the failed refund and retry the compensating
giveAdena operation before rethrowing the purchase error, rather than only
attaching rollbackError.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5069502c-ced7-4dc3-bfad-92f3e8fa1155

📥 Commits

Reviewing files that changed from the base of the PR and between 74082a9 and d34cc82.

📒 Files selected for processing (6)
  • src/GameServer/Bot/AI/BotInferenceBudget.js
  • src/GameServer/Bot/AI/States/FollowingState.js
  • src/GameServer/Bot/TradeService.js
  • tests/test_bot_inference_interactive_queue.js
  • tests/test_llm_supply_errand.js
  • tests/test_trade_store_atomicity.js
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/test_llm_supply_errand.js
  • src/GameServer/Bot/AI/States/FollowingState.js

Comment thread tests/test_trade_store_atomicity.js
@pmbstyle
pmbstyle merged commit be990e3 into main Aug 5, 2026
4 checks passed
@pmbstyle
pmbstyle deleted the agent/hot-bot-llm branch August 5, 2026 18:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant