Skip to content

Commit e40c324

Browse files
committed
fix(core,unity): audit wave 2 — dead LLM auditing, atomic revision stores, retry tool-replay, rolling-summary convergence, memory clear/scope-key safety, additive-scene router
Also: AGENTS.md (runtime-first project goal + conventions for CLI agents), TODO roadmap [R4] runtime UI Toolkit feature plan, deferred audit findings filed.
1 parent f367f13 commit e40c324

46 files changed

Lines changed: 1382 additions & 225 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Agent instructions — CoreAI
2+
3+
## Project goal: RUNTIME-first, always
4+
5+
CoreAI's premise is creating and evolving the game **inside the running game** — world, mods, logic,
6+
and UI alike. When designing or reviewing any feature, check first: **"does this work in a built
7+
player on device?"**
8+
9+
- Editor-only mechanisms (`AssetDatabase` import, AssetBundle building, editor tooling, `#if UNITY_EDITOR`
10+
paths) must never be the *primary* path of a feature — at most a secondary convenience.
11+
- Example: runtime UI = UXML/USS text interpreted at runtime as the core path; materializing real
12+
project assets is an optional editor-only bonus.
13+
- WebGL is a first-class target: file persistence must call `CoreAiWebGlPersistence.Sync()`; no
14+
threads/blocking waits on the WebGL path.
15+
16+
## Conventions (enforced)
17+
18+
- **Comments**: only `/// <summary>` XML docs, `// WHY:`, `// TODO:`, `// HACK:`. No narrative or
19+
change-description comments.
20+
- **No audit reports live in the repo** — findings become `TODO.md` items; report files get deleted.
21+
- **Releases**: bump ALL FIVE `package.json` in lockstep (`com.neoxider.coreai`, `coreaiunity`,
22+
`coreaimods`, `coreaihub`, `coreaibenchmark`); changelog entries go under `[Unreleased]` in the only
23+
two changelogs: `Assets/CoreAI/CHANGELOG.md` (core + mods) and `Assets/CoreAiUnity/CHANGELOG.md` (host).
24+
- **Commits**: NEVER add `Co-Authored-By` or any AI-attribution trailers.
25+
- **TODO.md** is the living priority tracker; every fix wave updates it.
26+
- Every bug fix ships with a regression test; every feature ships with tests and docs.
27+
28+
## Verification while the Unity editor holds the project lock
29+
30+
Unity batchmode CLI fails (lockfile). Instead:
31+
32+
- `dotnet build <Project>.csproj` on the Unity-generated csproj = fast compile gate
33+
(`CoreAI.Core.csproj`, `CoreAI.Source.csproj`, `CoreAI.Mods.csproj`, `CoreAI.Tests.csproj`).
34+
- Full EditMode suite runs on next editor start ("verification gate" items in TODO.md).
35+
36+
More detail: `CONTRIBUTING.md` (hooks, CI jobs, Lua/no-Lua configurations).

Assets/CoreAI/CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,33 @@
22

33
## [Unreleased]
44

5+
### Fixed (2026-07-12 audit wave 2 — core)
6+
7+
- **Retries can no longer double-execute tools.** A failed completion that carries executed-tool evidence
8+
is not replayed by the HTTP retry loop or the fallback chain (the failure propagates instead of
9+
re-mutating the world); error results now retain `ExecutedToolCalls`, and the streaming replay-safety
10+
guard is cumulative across tool roundtrips (a failure after a tool-only roundtrip no longer looks
11+
pre-commit to the streaming retry decorator).
12+
- **Streaming usage is summed across the whole turn** (was: reset every roundtrip, underreporting
13+
multi-roundtrip turns ~N×; the roundtrip-cap fallback reported zero).
14+
- **Rolling summary converges.** Already-folded prefixes are detected (watermark in the *stored* summary
15+
only — the visible summary stays the clean LLM output within `MaxSummaryChars`) and never re-folded, so
16+
the summary stops accumulating duplicate bullets; failed overflow retries no longer persist summary
17+
changes; retries respect `EnableConversationHistorySummarization=false`; the cap default is 2048 tokens
18+
(was 0 = unbounded) and trimming keeps the newest content, evicting the oldest.
19+
- **`memory(action=clear)` wipes only the memory document** (versioned, revertible); chat history,
20+
transcripts, and prior versions survive — the model can no longer erase the user's conversation record.
21+
- **Agent-memory scope keys are injective.** Distinct raw scope values that sanitize to the same text
22+
(`a.b` vs `a/b`) get a stable hash suffix — no more cross-user memory/history sharing; unset and
23+
clean values keep their legacy on-disk keys (no data migration for the default case).
24+
- **Role files stop growing without bound**: chat history and transcripts are trimmed on write to
25+
configurable caps (were only trimmed on read).
26+
- **Replacing a role's tool list no longer disconnects skills**: `read_skill`/`call_skill_tool` are
27+
re-asserted when a live skill catalog exists.
28+
- **Audit chain verifier**: accepts a legitimate mid-file `ChainReset` as a new chain start, and rotation
29+
stages its anchor before the atomic swap — a crash between the two no longer bricks verification of all
30+
subsequent files.
31+
532
### Fixed (2026-07-11 audit wave)
633

734
- **SSE connect phase honors `TransportTimeoutSeconds`.** `HttpClientOpenAiTransport` now bounds the

Assets/CoreAI/Runtime/Core/Audit/AuditLogVerifier.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ public static AuditVerifyResult Verify(string filePath)
9797
long seq = obj["Seq"]?.Value<long>() ?? lineCount;
9898
string storedPrevHash = (string)obj["prevHash"] ?? "";
9999
string storedHash = (string)obj["hash"] ?? "";
100+
bool chainReset = (int?)obj["Kind"] == (int)AuditEntryKind.ChainReset;
100101

101102
// Anchored genesis: a file created by rotation starts with a RotationAnchor entry
102103
// whose own prevHash is the previous file's final hash, not "". Trust that stored
@@ -107,6 +108,11 @@ public static AuditVerifyResult Verify(string filePath)
107108
prevHash = storedPrevHash;
108109
}
109110

111+
if (chainReset)
112+
{
113+
prevHash = "";
114+
}
115+
110116
first = false;
111117

112118
if (storedPrevHash != prevHash)

Assets/CoreAI/Runtime/Core/Features/AgentMemory/AgentMemoryPolicy.cs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,27 @@ public void SetToolsForRole(string roleId, IReadOnlyList<ILlmTool> tools)
2727
{
2828
lock (_lock)
2929
{
30-
if (tools == null || tools.Count == 0)
30+
List<ILlmTool> replacement = tools == null
31+
? new List<ILlmTool>()
32+
: new List<ILlmTool>(tools);
33+
34+
if (_roleSkillCatalogs.TryGetValue(roleId, out MutableSkillCatalog catalog))
3135
{
32-
_customTools.Remove(roleId);
33-
return;
36+
replacement.RemoveAll(tool => tool != null &&
37+
(string.Equals(tool.Name, "read_skill", StringComparison.OrdinalIgnoreCase) ||
38+
string.Equals(tool.Name, "call_skill_tool", StringComparison.OrdinalIgnoreCase)));
39+
replacement.Add(ReadSkillLlmTool.Create(catalog));
40+
replacement.Add(CallSkillToolLlmTool.Create(catalog));
3441
}
3542

36-
_customTools[roleId] = new List<ILlmTool>(tools);
43+
if (replacement.Count == 0)
44+
{
45+
_customTools.Remove(roleId);
46+
}
47+
else
48+
{
49+
_customTools[roleId] = replacement;
50+
}
3751
}
3852
}
3953

Assets/CoreAI/Runtime/Core/Features/AgentMemory/ConversationContextBuildArgs.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,8 @@ public sealed class ConversationContextBuildArgs
3636
/// Maximum newest durable <c>tool</c> / <c>## Tool Results</c> messages retained in the prompt history copy.
3737
/// </summary>
3838
public int MaxRetainedToolResultMessages { get; set; }
39+
40+
/// <summary>Defers durable summary persistence until the owning LLM request succeeds.</summary>
41+
public bool DeferSummaryPersistence { get; set; }
3942
}
4043
}

Assets/CoreAI/Runtime/Core/Features/AgentMemory/ConversationContextSnapshot.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,14 @@ public sealed class ConversationContextSnapshot
1313

1414
/// <summary>True when older history was compacted into <see cref="Summary"/>.</summary>
1515
public bool WasCompacted { get; set; }
16+
17+
internal System.Action CommitSummary { get; set; }
18+
19+
internal void Commit()
20+
{
21+
System.Action commit = CommitSummary;
22+
CommitSummary = null;
23+
commit?.Invoke();
24+
}
1625
}
1726
}

Assets/CoreAI/Runtime/Core/Features/AgentMemory/ConversationHistoryPartition.cs

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -104,34 +104,63 @@ public static (int splitExclusive, List<ChatMessage> recentTail) PartitionByBudg
104104

105105
internal static class ConversationBulletSummary
106106
{
107-
public static string Format(string existingSummary, ChatMessage[] history, int splitExclusive)
107+
public static string Format(
108+
string existingSummary,
109+
ChatMessage[] history,
110+
int splitExclusive,
111+
int startInclusive = 0)
108112
{
109-
if (history == null || splitExclusive <= 0)
113+
if (history == null || splitExclusive <= startInclusive)
110114
{
111-
return "";
115+
return existingSummary?.Trim() ?? "";
112116
}
113117

114118
StringBuilder sb = new();
115119
if (!string.IsNullOrWhiteSpace(existingSummary))
116120
{
117121
sb.AppendLine(existingSummary.Trim());
118-
sb.AppendLine();
122+
}
123+
else
124+
{
125+
sb.AppendLine("Previous conversation summary:");
126+
}
127+
128+
for (int i = Math.Max(0, startInclusive); i < splitExclusive; i++)
129+
{
130+
sb.AppendLine(FormatMessage(history[i]));
131+
}
132+
133+
return sb.ToString().Trim();
134+
}
135+
136+
public static int FindFoldStart(string existingSummary, ChatMessage[] history, int splitExclusive)
137+
{
138+
if (string.IsNullOrWhiteSpace(existingSummary) || history == null || splitExclusive <= 0)
139+
{
140+
return 0;
119141
}
120142

121-
sb.AppendLine("Previous conversation summary:");
122-
for (int i = 0; i < splitExclusive; i++)
143+
for (int i = splitExclusive - 1; i >= 0; i--)
123144
{
124-
string role = string.IsNullOrWhiteSpace(history[i].Role) ? "unknown" : history[i].Role.Trim();
125-
string content = history[i].Content ?? "";
126-
if (content.Length > 280)
145+
if (existingSummary.IndexOf(FormatMessage(history[i]), StringComparison.Ordinal) >= 0)
127146
{
128-
content = content.Substring(0, 280).TrimEnd() + "...";
147+
return i + 1;
129148
}
149+
}
130150

131-
sb.Append("- ").Append(role).Append(": ").AppendLine(content);
151+
return 0;
152+
}
153+
154+
private static string FormatMessage(ChatMessage message)
155+
{
156+
string role = string.IsNullOrWhiteSpace(message.Role) ? "unknown" : message.Role.Trim();
157+
string content = message.Content ?? "";
158+
if (content.Length > 280)
159+
{
160+
content = content.Substring(0, 280).TrimEnd() + "...";
132161
}
133162

134-
return sb.ToString().Trim();
163+
return "- " + role + ": " + content;
135164
}
136165
}
137166
}

Assets/CoreAI/Runtime/Core/Features/AgentMemory/ConversationRolledSummaryLimiter.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ public static class ConversationRolledSummaryLimiter
77
{
88
/// <summary>
99
/// Returns <paramref name="text"/> unchanged when <paramref name="maxTokens"/> is unset or empty text.
10-
/// Otherwise trims to the longest prefix whose <see cref="ITokenEstimator.EstimateText"/> is at most <paramref name="maxTokens"/>, then appends an ellipsis when trimmed.
10+
/// Otherwise keeps the longest newest suffix whose <see cref="ITokenEstimator.EstimateText"/> is at most <paramref name="maxTokens"/>, then prefixes an ellipsis when trimmed.
1111
/// </summary>
1212
public static string Apply(string text, ITokenEstimator estimator, int maxTokens)
1313
{
@@ -32,7 +32,7 @@ public static string Apply(string text, ITokenEstimator estimator, int maxTokens
3232
while (lo < hi)
3333
{
3434
int mid = (lo + hi + 1) / 2;
35-
if (estimator.EstimateText(trimmed.Substring(0, mid)) <= maxTokens)
35+
if (estimator.EstimateText(trimmed.Substring(trimmed.Length - mid)) <= maxTokens)
3636
{
3737
lo = mid;
3838
}
@@ -47,8 +47,8 @@ public static string Apply(string text, ITokenEstimator estimator, int maxTokens
4747
return "…";
4848
}
4949

50-
string prefix = trimmed.Substring(0, lo).TrimEnd();
51-
return string.IsNullOrEmpty(prefix) ? "…" : prefix + "…";
50+
string suffix = trimmed.Substring(trimmed.Length - lo).TrimStart();
51+
return string.IsNullOrEmpty(suffix) ? "…" : "…" + suffix;
5252
}
5353
}
5454
}

Assets/CoreAI/Runtime/Core/Features/AgentMemory/DeterministicConversationContextManager.cs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,17 +72,30 @@ public ConversationContextSnapshot BuildSnapshot(
7272
};
7373
}
7474

75+
int foldStart = ConversationBulletSummary.FindFoldStart(storedSummary, history, splitExclusive);
7576
string compactedSummary = LimitSummaryIfNeeded(
76-
ConversationBulletSummary.Format(storedSummary, history, splitExclusive),
77+
ConversationBulletSummary.Format(storedSummary, history, splitExclusive, foldStart),
7778
buildArgs);
78-
_summaryStore.SaveSummary(roleId, compactedSummary);
79-
80-
return new ConversationContextSnapshot
79+
ConversationContextSnapshot snapshot = new()
8180
{
8281
Summary = compactedSummary,
8382
RecentMessages = recent.ToArray(),
8483
WasCompacted = true
8584
};
85+
86+
if (foldStart < splitExclusive)
87+
{
88+
if (buildArgs?.DeferSummaryPersistence == true)
89+
{
90+
snapshot.CommitSummary = () => _summaryStore.SaveSummary(roleId, compactedSummary);
91+
}
92+
else
93+
{
94+
_summaryStore.SaveSummary(roleId, compactedSummary);
95+
}
96+
}
97+
98+
return snapshot;
8699
}
87100

88101
/// <inheritdoc />

Assets/CoreAI/Runtime/Core/Features/AgentMemory/LlmAssistedConversationContextManager.cs

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,25 @@ public async Task<ConversationContextSnapshot> BuildSnapshotAsync(
111111
};
112112
}
113113

114+
int foldStart = ConversationBulletSummary.FindFoldStart(storedSummary, history, splitExclusive);
115+
if (foldStart >= splitExclusive)
116+
{
117+
return new ConversationContextSnapshot
118+
{
119+
Summary = LimitSummaryIfNeeded(storedSummary, buildArgs),
120+
RecentMessages = recent.ToArray(),
121+
WasCompacted = true
122+
};
123+
}
124+
114125
string compactedSummary;
115126
try
116127
{
117128
compactedSummary = await SummarizeViaLlmAsync(
118129
storedSummary,
119130
history,
120131
splitExclusive,
132+
foldStart,
121133
orchestrationTraceId ?? "t",
122134
cancellationToken).ConfigureAwait(false);
123135
}
@@ -130,34 +142,56 @@ public async Task<ConversationContextSnapshot> BuildSnapshotAsync(
130142
Log.Instance.Warn(
131143
$"[LlmAssistedConversationContextManager] LLM compaction failed; using bullet fallback: {ex.Message}",
132144
LogTag.Llm);
133-
compactedSummary = ConversationBulletSummary.Format(storedSummary, history, splitExclusive);
145+
compactedSummary = ConversationBulletSummary.Format(
146+
storedSummary, history, splitExclusive, foldStart);
134147
}
135148

136149
if (string.IsNullOrWhiteSpace(compactedSummary))
137150
{
138-
compactedSummary = ConversationBulletSummary.Format(storedSummary, history, splitExclusive);
151+
compactedSummary = ConversationBulletSummary.Format(
152+
storedSummary, history, splitExclusive, foldStart);
139153
}
140154

141155
compactedSummary = LimitSummaryIfNeeded(compactedSummary, buildArgs);
142-
_summaryStore.SaveSummary(roleId, compactedSummary);
143156

144-
return new ConversationContextSnapshot
157+
// WHY: FindFoldStart re-detects the already-folded prefix by locating the last folded
158+
// message's bullet inside the STORED summary. The watermark bullet is stamped only into the
159+
// persisted text; the snapshot keeps the clean LLM summary (and honors MaxSummaryChars).
160+
string persistedSummary =
161+
ConversationBulletSummary.FindFoldStart(compactedSummary, history, splitExclusive) >= splitExclusive
162+
? compactedSummary
163+
: ConversationBulletSummary.Format(compactedSummary, history, splitExclusive, splitExclusive - 1);
164+
165+
ConversationContextSnapshot snapshot = new()
145166
{
146167
Summary = compactedSummary,
147168
RecentMessages = recent.ToArray(),
148169
WasCompacted = true
149170
};
171+
172+
if (buildArgs?.DeferSummaryPersistence == true)
173+
{
174+
snapshot.CommitSummary = () => _summaryStore.SaveSummary(roleId, persistedSummary);
175+
}
176+
else
177+
{
178+
_summaryStore.SaveSummary(roleId, persistedSummary);
179+
}
180+
181+
return snapshot;
150182
}
151183

152184
private async Task<string> SummarizeViaLlmAsync(
153185
string priorSummary,
154186
ChatMessage[] history,
155187
int splitExclusive,
188+
int startInclusive,
156189
string traceIdBase,
157190
CancellationToken cancellationToken)
158191
{
159192
// Compactor-only prompts: never the main role system (Teacher, Creator, tool contract, etc.).
160-
string userPayload = BuildCompactionUserPayload(priorSummary, history, splitExclusive, _options);
193+
string userPayload = BuildCompactionUserPayload(
194+
priorSummary, history, splitExclusive, startInclusive, _options);
161195
string compactTrace = $"{traceIdBase.Trim()}:compact";
162196

163197
LlmCompletionResult result = await _compactionLlm.CompleteAsync(
@@ -185,7 +219,11 @@ private async Task<string> SummarizeViaLlmAsync(
185219
return NormalizeSummaryText(result.Content, _options.MaxSummaryChars);
186220
}
187221

188-
private static string BuildCompactionUserPayload(string priorSummary, ChatMessage[] history, int splitExclusive,
222+
private static string BuildCompactionUserPayload(
223+
string priorSummary,
224+
ChatMessage[] history,
225+
int splitExclusive,
226+
int startInclusive,
189227
LlmContextCompactionOptions options)
190228
{
191229
int maxChars = options.MaxPayloadChars;
@@ -195,7 +233,7 @@ private static string BuildCompactionUserPayload(string priorSummary, ChatMessag
195233
sb.AppendLine(string.IsNullOrWhiteSpace(priorSummary) ? "(none)" : priorSummary.Trim());
196234
sb.AppendLine();
197235
sb.AppendLine("## Dialogue lines to fold into the rolling summary (older than the live tail)");
198-
for (int i = 0; i < splitExclusive; i++)
236+
for (int i = Math.Max(0, startInclusive); i < splitExclusive; i++)
199237
{
200238
string role = string.IsNullOrWhiteSpace(history[i].Role) ? "unknown" : history[i].Role.Trim();
201239
string content = history[i].Content ?? "";

0 commit comments

Comments
 (0)