Skip to content

Commit fa37a52

Browse files
committed
fix: harden Qwen spell tool semantics
1 parent 9268144 commit fa37a52

9 files changed

Lines changed: 143 additions & 52 deletions

File tree

Assets/CoreAI.Demos/QwenDemo/QwenDemoShared.cs

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,36 @@ public string HudLine()
7878
/// </summary>
7979
public static class LlmMeter
8080
{
81-
/// <summary>Both Qwen demos require a native tool call instead of accepting a text-only answer.</summary>
81+
/// <summary>Fallback used when a demo turn permits more than one tool.</summary>
8282
public static LlmToolChoiceMode ToolChoiceMode => LlmToolChoiceMode.RequireAny;
8383

84+
/// <summary>Builds the strict native-tool request used by the Qwen demos.</summary>
85+
public static AiTaskRequest BuildTaskRequest(
86+
string roleId,
87+
string hint,
88+
int maxOutputTokens,
89+
IReadOnlyList<string> allowedToolNames)
90+
{
91+
string[] validToolNames = allowedToolNames?
92+
.Where(name => !string.IsNullOrWhiteSpace(name))
93+
.Select(name => name.Trim())
94+
.Distinct(StringComparer.Ordinal)
95+
.ToArray() ?? Array.Empty<string>();
96+
string requiredTool = validToolNames.Length == 1 ? validToolNames[0] : null;
97+
98+
return new AiTaskRequest
99+
{
100+
RoleId = roleId,
101+
Hint = hint,
102+
MaxOutputTokens = maxOutputTokens,
103+
CancellationScope = roleId,
104+
ForcedToolMode = string.IsNullOrEmpty(requiredTool)
105+
? ToolChoiceMode
106+
: LlmToolChoiceMode.RequireSpecific,
107+
RequiredToolName = requiredTool ?? ""
108+
};
109+
}
110+
84111
public static async Task<LlmRunResult> RunAsync(
85112
string roleId,
86113
string hint,
@@ -102,14 +129,7 @@ public static async Task<LlmRunResult> RunAsync(
102129

103130
try
104131
{
105-
AiTaskRequest task = new()
106-
{
107-
RoleId = roleId,
108-
Hint = hint,
109-
MaxOutputTokens = maxOutputTokens,
110-
CancellationScope = roleId,
111-
ForcedToolMode = ToolChoiceMode
112-
};
132+
AiTaskRequest task = BuildTaskRequest(roleId, hint, maxOutputTokens, allowedToolNames);
113133

114134
await foreach (LlmStreamChunk ch in orchestrator.RunStreamingAsync(task, CancellationToken.None))
115135
{

Assets/CoreAI.Demos/QwenDemo/README.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,22 @@ Both scenes contain their own `CoreAILifetimeScope`, camera, light, and demo con
2020

2121
The HUD reports first-token latency, total latency, token usage, and executed tool calls. The model chooses an action; C# remains authoritative over wish charges, mana, value clamps, and Unity object access.
2222
Both agents use `AgentMode.ToolsOnly`, disable chat history, and send
23-
`LlmToolChoiceMode.RequireAny` on every turn so a text-only response cannot count as a successful action.
23+
`LlmToolChoiceMode.RequireSpecific` with their single expected tool so CoreAI narrows the native tool
24+
contract and a text-only response cannot count as a successful action. Spellcraft also gives the compact
25+
0.8B model explicit RU/EN element aliases; for example, `мега молния` maps to `storm` power `3`.
2426
The demo accepts exactly one successful expected tool call per turn. Zero calls, multiple calls, an
2527
unexpected tool, or a failed tool is rejected instead of being displayed as a successful cast. The
2628
determinism button repeats this same real tool-call path; it does not infer a decision from assistant text.
2729

30+
The release smoke also checks the actual tool arguments for the Russian element presets. The compact model
31+
must return `storm|3` for `мега молния`, `fire|2` for `стена огня`, `poison|1` for
32+
`ядовитый туман над врагом`, and `frost|2` for `заморозь их до костей`. Effects are not generated by the
33+
model: each supported element selects a predefined C# gameplay/VFX branch, and C# clamps power and mana.
34+
2835
## Verification
2936

30-
- `QwenDemoScenesEditModeTests` checks scene composition, the `ToolsOnly`/`RequireAny` contract, the
31-
exact-one validator, and compact HUD layout.
37+
- `QwenDemoScenesEditModeTests` checks scene composition, the `ToolsOnly`/`RequireSpecific` contract,
38+
Russian lightning semantics, the exact-one validator, and compact HUD layout.
3239
- `QwenDemoSafetyPlayModeTests` checks that parallel native tool callbacks can apply exactly one side
3340
effect per model turn and that the guard resets for the next turn. EditMode tests cover the readiness
3441
lock fields and invalid tool-call outcomes without loading a model.

Assets/CoreAI.Demos/QwenDemo/SpellcraftDemo.cs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,17 @@ public sealed class SpellcraftDemo : MonoBehaviour
2323
private const string RoleId = "DemoMage";
2424

2525
private const string SystemPrompt =
26-
"You interpret spells for a wizard. The player describes a spell in their own words (any " +
27-
"language, including Russian). Call cast_spell EXACTLY ONCE with the closest element " +
28-
"(one of: fire, frost, storm, poison, arcane) and an integer power from 1 to 3 reflecting how " +
29-
"strong the description sounds. After the call, reply with ONE short line naming the spell.";
26+
"You classify a wizard's spell description and MUST call cast_spell exactly once. " +
27+
"Never answer with element/power as text. Use these exact semantic mappings: " +
28+
"fire = fire, flame, burn, огонь, пламя; " +
29+
"frost = ice, freeze, cold, лёд, лед, мороз, заморозить; " +
30+
"storm = lightning, thunder, storm, молния, гром, гроза; " +
31+
"poison = poison, toxic, venom, яд, ядовитый, токсичный; " +
32+
"arcane = arcane, magic, mana, магия, мана. " +
33+
"Power is an integer: 1 weak, 2 strong, 3 huge or mega. " +
34+
"Examples: 'мега молния' => cast_spell(element='storm', power=3); " +
35+
"'стена огня' => cast_spell(element='fire', power=2); " +
36+
"'заморозь их до костей' => cast_spell(element='frost', power=2).";
3037

3138
private static readonly string[] Presets =
3239
{
@@ -91,8 +98,10 @@ private async void Start()
9198
.WithoutChatHistory()
9299
.WithTemperature(0f)
93100
.WithTool(new DelegateLlmTool("cast_spell",
94-
"Cast a spell. element is one of fire, frost, storm, poison, arcane. power is 1 (weak) " +
95-
"to 3 (mighty). Choose the element and power that best fit the player's description.",
101+
"Required action. Call exactly once. element must be fire, frost, storm, poison, or " +
102+
"arcane. Russian молния/гром/гроза means storm; огонь/пламя means fire; " +
103+
"лёд/лед/мороз means frost; яд/ядовитый means poison; магия/мана means arcane. " +
104+
"power is 1 weak, 2 strong, or 3 huge/mega.",
96105
new Func<string, int, string>(CastSpell)))
97106
.Build();
98107

Assets/CoreAiUnity/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ Unity host: **CoreAI.Source** build, EditMode / PlayMode tests, Editor menus, do
4444

4545
### Fixed
4646

47+
- Qwen Spellcraft now requires its single `cast_spell` tool by name and gives the 0.8B model explicit
48+
bilingual element aliases and examples, preventing Russian lightning prompts such as `мега молния`
49+
from being classified as frost or returned as plain text.
50+
- Chat request and streaming timeouts now use real time, so pausing the game with `Time.timeScale = 0`
51+
cannot leave an LLM request waiting forever.
4752
- Removed hard-coded readiness requests from the endpoint factory and the unreachable direct-`HttpClient`
4853
registry fallback, preventing normal startup and hot switching from applying different route policies.
4954
- Readiness rejects unsafe base URIs and redirects instead of accepting an unrelated handler or forwarding

Assets/CoreAiUnity/Runtime/Source/Features/Chat/CoreAiChatService.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,8 @@ public async System.Threading.Tasks.Task<string> SendMessageAsync(
170170
if (timeoutSec > 0)
171171
{
172172
timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
173-
timerHandle = timeoutCts.CancelAfterSlim(TimeSpan.FromSeconds(timeoutSec));
173+
timerHandle = timeoutCts.CancelAfterSlim(
174+
TimeSpan.FromSeconds(timeoutSec), DelayType.Realtime);
174175
effectiveCt = timeoutCts.Token;
175176
}
176177

@@ -239,7 +240,8 @@ public async IAsyncEnumerable<LlmStreamChunk> SendMessageStreamingAsync(
239240
if (timeoutSec > 0)
240241
{
241242
timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
242-
timerHandle = timeoutCts.CancelAfterSlim(TimeSpan.FromSeconds(timeoutSec));
243+
timerHandle = timeoutCts.CancelAfterSlim(
244+
TimeSpan.FromSeconds(timeoutSec), DelayType.Realtime);
243245
effectiveCt = timeoutCts.Token;
244246
}
245247

@@ -672,7 +674,8 @@ private async System.Threading.Tasks.Task<string> SendUserImageMessageAsync(
672674
if (timeoutSec > 0)
673675
{
674676
timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
675-
timerHandle = timeoutCts.CancelAfterSlim(TimeSpan.FromSeconds(timeoutSec));
677+
timerHandle = timeoutCts.CancelAfterSlim(
678+
TimeSpan.FromSeconds(timeoutSec), DelayType.Realtime);
676679
effectiveCt = timeoutCts.Token;
677680
}
678681

Assets/CoreAiUnity/Tests/EditMode/CoreAiChatServiceEditModeTests.cs

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -380,33 +380,41 @@ public async Task SendMessageAsync_NullResult_ReturnsEmptyString()
380380
[Timeout(20000)]
381381
public IEnumerator SendMessageAsync_TimeoutWhenOrchestratorBlocks_ThrowsLlmOperationTimeoutException()
382382
{
383-
BlockUntilCancelledOrchestrator orchestrator = new();
384-
StubSettings settings = new() { LlmRequestTimeoutSecondsOverride = 0.2f };
385-
CoreAiChatService service = new(orchestrator, settings: settings);
383+
float previousTimeScale = Time.timeScale;
384+
Time.timeScale = 0f;
385+
try
386+
{
387+
BlockUntilCancelledOrchestrator orchestrator = new();
388+
StubSettings settings = new() { LlmRequestTimeoutSecondsOverride = 0.2f };
389+
CoreAiChatService service = new(orchestrator, settings: settings);
386390

387-
Task task = service.SendMessageAsync("hi", "TestRole");
391+
Task task = service.SendMessageAsync("hi", "TestRole");
388392

389-
float deadline = Time.realtimeSinceStartup + 15f;
390-
while (!task.IsCompleted && Time.realtimeSinceStartup < deadline)
391-
{
392-
yield return null;
393-
}
393+
float deadline = Time.realtimeSinceStartup + 15f;
394+
while (!task.IsCompleted && Time.realtimeSinceStartup < deadline)
395+
{
396+
yield return null;
397+
}
394398

395-
Assert.IsTrue(task.IsCompleted, "SendMessageAsync should complete once the timeout token fires.");
396-
// LlmOperationTimeoutException inherits OperationCanceledException — async Task reports
397-
// TaskStatus.Canceled (not Faulted) and task.Exception is null; unwrap via GetResult().
398-
try
399-
{
400-
task.GetAwaiter().GetResult();
401-
Assert.Fail("Expected LlmOperationTimeoutException after timeout.");
402-
}
403-
catch (LlmOperationTimeoutException ex)
404-
{
405-
Assert.That(ex.Message, Does.Contain("timed out"));
399+
Assert.IsTrue(task.IsCompleted,
400+
"SendMessageAsync should complete in real time while the game is paused.");
401+
try
402+
{
403+
task.GetAwaiter().GetResult();
404+
Assert.Fail("Expected LlmOperationTimeoutException after timeout.");
405+
}
406+
catch (LlmOperationTimeoutException ex)
407+
{
408+
Assert.That(ex.Message, Does.Contain("timed out"));
409+
}
410+
catch (Exception ex)
411+
{
412+
Assert.Fail($"Expected LlmOperationTimeoutException, got {ex.GetType().Name}: {ex.Message}");
413+
}
406414
}
407-
catch (Exception ex)
415+
finally
408416
{
409-
Assert.Fail($"Expected LlmOperationTimeoutException, got {ex.GetType().Name}: {ex.Message}");
417+
Time.timeScale = previousTimeScale;
410418
}
411419
}
412420

Assets/CoreAiUnity/Tests/EditMode/CoreAiRoutingUiEditModeTests.cs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,14 @@ public void ChatApiSelector_AutomaticClearsPersistedAgentOverride()
7474

7575
panel.EnableApiSwitching();
7676
DropdownField dropdown = header.Q<DropdownField>(className: "coreai-chat-api-dropdown");
77-
dropdown.value = dropdown.choices[0];
77+
string automaticLabel = dropdown.choices[0];
78+
using (ChangeEvent<string> evt = ChangeEvent<string>.GetPooled(dropdown.value, automaticLabel))
79+
{
80+
dropdown.SetValueWithoutNotify(automaticLabel);
81+
typeof(CoreAiChatPanel).GetMethod("OnApiProfileChanged",
82+
BindingFlags.Instance | BindingFlags.NonPublic)
83+
?.Invoke(panel, new object[] { evt });
84+
}
7885

7986
Assert.AreEqual(string.Empty, _controller.GetProfileForRole("SmartChat"));
8087
Assert.AreEqual(string.Empty, panel.SelectedRoutingProfileId);
@@ -167,11 +174,14 @@ public void HubEndpointEditor_HidesSessionKeyClearOutsideHttpEndpoints()
167174
Button clearKey = FindButton(root, "Clear saved session key");
168175

169176
Assert.AreEqual(DisplayStyle.Flex, clearKey.style.display.value);
170-
kind.value = "LLMUnity";
177+
kind.SetValueWithoutNotify("LLMUnity");
178+
InvokePage(page, "RefreshEndpointEditorVisibility");
171179
Assert.AreEqual(DisplayStyle.None, clearKey.style.display.value);
172-
kind.value = "Offline";
180+
kind.SetValueWithoutNotify("Offline");
181+
InvokePage(page, "RefreshEndpointEditorVisibility");
173182
Assert.AreEqual(DisplayStyle.None, clearKey.style.display.value);
174-
kind.value = "HTTP API";
183+
kind.SetValueWithoutNotify("HTTP API");
184+
InvokePage(page, "RefreshEndpointEditorVisibility");
175185
Assert.AreEqual(DisplayStyle.Flex, clearKey.style.display.value);
176186
page.OnDestroyed();
177187
}

Assets/CoreAiUnity/Tests/EditMode/QwenDemoScenesEditModeTests.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,31 @@ public void QwenDemos_RequireNativeToolCalls()
2424
Type meter = Type.GetType("CoreAI.ExampleGame.QwenDemo.LlmMeter, CoreAI.Demos", true);
2525
object mode = meter.GetProperty("ToolChoiceMode")?.GetValue(null);
2626
Assert.AreEqual(LlmToolChoiceMode.RequireAny, mode);
27+
28+
MethodInfo build = meter.GetMethod("BuildTaskRequest");
29+
AiTaskRequest request = (AiTaskRequest)build.Invoke(null,
30+
new object[] { "DemoMage", "мега молния", 160, new[] { "cast_spell" } });
31+
Assert.AreEqual(LlmToolChoiceMode.RequireSpecific, request.ForcedToolMode);
32+
Assert.AreEqual("cast_spell", request.RequiredToolName);
33+
34+
AiTaskRequest multiToolRequest = (AiTaskRequest)build.Invoke(null,
35+
new object[] { "Demo", "choose", 160, new[] { "first", "second" } });
36+
Assert.AreEqual(LlmToolChoiceMode.RequireAny, multiToolRequest.ForcedToolMode);
37+
Assert.AreEqual(string.Empty, multiToolRequest.RequiredToolName);
38+
}
39+
40+
[Test]
41+
public void SpellcraftPrompt_MapsRussianLightningToStorm()
42+
{
43+
Type spellcraft = Type.GetType(
44+
"CoreAI.ExampleGame.QwenDemo.SpellcraftDemo, CoreAI.Demos", true);
45+
FieldInfo promptField = spellcraft.GetField("SystemPrompt",
46+
BindingFlags.Static | BindingFlags.NonPublic);
47+
string prompt = (string)promptField.GetRawConstantValue();
48+
49+
StringAssert.Contains("молния, гром, гроза", prompt);
50+
StringAssert.Contains("'мега молния' => cast_spell(element='storm', power=3)", prompt);
51+
StringAssert.Contains("Never answer with element/power as text", prompt);
2752
}
2853

2954
[TestCase(null, false)]

TODO.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
> Updated 2026-07-15. Tracks open work by priority. Shipped work is in `CHANGELOG.md` (both packages);
44
> non-blocking future work in `Assets/CoreAiUnity/Docs/BACKLOG.md`.
55
> Released: 5.8.10 (2026-07-13, all five packages in lockstep). Unreleased packages are aligned at 5.9.0.
6-
> Last full verification 2026-07-15 in the interactive editor: CoreAI EditMode 1675 passed / 0 failed
7-
> (1645 host + 30 core/example), PlayMode `FastNoLlm` 73 passed / 0 failed, and six Unity-generated
8-
> `dotnet build` compile gates completed with 0 errors. Live Qwen3.5-0.8B LLMUnity smokes called
9-
> Genie `grant_gold` in 6481 ms and Spellcraft `cast_spell` in 6112 ms. Spellcraft determinism passed 5/5
10-
> as `storm|1` (0 errors, 6379 ms average); Hub/Chat started with 0 warnings/errors.
6+
> Last full verification 2026-07-15 in the interactive editor: EditMode 1691 passed / 0 failed /
7+
> 4 ignored optional Neoxider Pages tests, PlayMode `FastNoLlm` 73 passed / 0 failed, and six
8+
> Unity-generated `dotnet build` compile gates completed with 0 errors. Live Qwen3.5-0.8B LLMUnity
9+
> smokes called Genie `grant_gold`; Spellcraft classified `мега молния` as `storm|3`, `стена огня`
10+
> as `fire|2`, `ядовитый туман` as `poison|1`, and `заморозь их до костей` as `frost|2`, each through
11+
> native `cast_spell` with no ToolsOnly error. Hub/Chat started with 0 warnings/errors.
1112
1213
## [A6] Deep audit wave (2026-07-13) — runtime / architecture / tests / security, all 22 findings fixed
1314

@@ -116,6 +117,9 @@
116117
`/v1/chat/completions` connection probe, and
117118
reject any result other than exactly one successful expected tool call. EditMode and no-model PlayMode
118119
regressions cover the contract.
120+
- [x] Qwen Spellcraft single-tool turns use `RequireSpecific(cast_spell)` and an explicit bilingual
121+
element contract; all four Russian element presets have regression coverage and passed live-model
122+
smoke on the compact 0.8B model.
119123
- [x] Deterministic startup smoke for all ten published scenes (including Skills and
120124
LiveMechanicsModsChat): no missing scripts, scope/camera present, supported shaders, no
121125
unexpected startup errors. Fixed missing Mods scopes, Mirror remnants, Hub wiring, and Wave URP color.

0 commit comments

Comments
 (0)