Skip to content

Commit f367f13

Browse files
committed
fix(llm): SSE connect-phase timeout + Unity extractor parity with portable core
1 parent 490769f commit f367f13

6 files changed

Lines changed: 387 additions & 101 deletions

File tree

Assets/CoreAI/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
### Fixed (2026-07-11 audit wave)
66

7+
- **SSE connect phase honors `TransportTimeoutSeconds`.** `HttpClientOpenAiTransport` now bounds the
8+
headers-not-yet-received phase of `OpenSseResponseStreamAsync` with the configured transport timeout
9+
(the linked CTS is disposed once headers arrive, so the streaming body itself stays unbounded); a backend
10+
that accepts the socket but never answers fails fast instead of eating the whole turn budget.
711
- **Lua sandbox: nested guarded calls can no longer disarm the outer guard.** `LuaCsExecutionGuard` keeps a
812
per-`LuaState` stack of installed hooks; leaving a nested `mods_call` restores the caller's hook instead of
913
removing it, so step/time/alloc budgets stay armed across direct and indirect (`A→B→A`) cross-mod calls.

Assets/CoreAI/Runtime/Core/Features/Llm/HttpClientOpenAiTransport.cs

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,15 +95,34 @@ public async Task<OpenAiHttpSseOpenResult> OpenSseResponseStreamAsync(OpenAiHttp
9595
httpRequest.Content = new StringContent(request.JsonBody ?? "", Encoding.UTF8, "application/json");
9696
ApplyHeaders(httpRequest, request.Headers, request.AcceptEventStream);
9797

98-
HttpResponseMessage response =
99-
await client.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
98+
// WHY: The streaming client's Timeout is infinite (SSE bodies are long-lived), so a backend
99+
// that accepts TCP but never sends response headers used to hang the turn until external
100+
// cancellation. Bound ONLY the time-to-headers phase with TransportTimeoutSeconds; the linked
101+
// CTS is disposed the moment headers arrive so it never bounds the streaming body (idle-stall
102+
// detection there stays with the caller). 0/unset keeps the legacy unbounded behavior.
103+
// TimeoutLlmClientDecorator still bounds the WHOLE request at a higher layer; this shorter
104+
// header bound only makes a headerless backend fail fast instead of eating the turn budget.
105+
HttpResponseMessage response;
106+
if (request.TransportTimeoutSeconds > 0)
107+
{
108+
using CancellationTokenSource headerCts =
109+
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
110+
headerCts.CancelAfter(TimeSpan.FromSeconds(request.TransportTimeoutSeconds));
111+
response = await client.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead,
112+
headerCts.Token);
113+
}
114+
else
115+
{
116+
response = await client.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead,
117+
cancellationToken);
118+
}
100119

101120
int statusCode = (int)response.StatusCode;
102121
IReadOnlyDictionary<string, IEnumerable<string>> headers = CopyHeaders(response);
103122

104123
if (!response.IsSuccessStatusCode)
105124
{
106-
string errBody = await response.Content.ReadAsStringAsync();
125+
string errBody = await ReadBodyTextAsync(response.Content, cancellationToken);
107126
response.Dispose();
108127
return new OpenAiHttpSseOpenResult
109128
{
@@ -122,6 +141,21 @@ public async Task<OpenAiHttpSseOpenResult> OpenSseResponseStreamAsync(OpenAiHttp
122141
}.WithStreamResponse(stream, response);
123142
}
124143

144+
/// <summary>
145+
/// Reads a response body as UTF-8 text with cancellation support. This profile has no
146+
/// <c>ReadAsStringAsync(CancellationToken)</c> overload, and after
147+
/// <see cref="HttpCompletionOption.ResponseHeadersRead"/> the body still comes from the
148+
/// network, so an uncancellable read could hang on a stalled backend.
149+
/// </summary>
150+
private static async Task<string> ReadBodyTextAsync(HttpContent content,
151+
CancellationToken cancellationToken)
152+
{
153+
using Stream stream = await content.ReadAsStreamAsync();
154+
using MemoryStream buffer = new();
155+
await stream.CopyToAsync(buffer, 81920, cancellationToken);
156+
return Encoding.UTF8.GetString(buffer.ToArray());
157+
}
158+
125159
private static void ApplyHeaders(HttpRequestMessage httpRequest,
126160
IReadOnlyList<KeyValuePair<string, string>> headers, bool acceptEventStream)
127161
{

Assets/CoreAiUnity/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ Unity host: **CoreAI.Source** build, EditMode / PlayMode tests, Editor menus, do
1717
reload no longer reach dead subscribers from previous scenes (duplicate world mutations).
1818
- **`world_command destroy` reports failure for missing targets** (was: unconditional success, so the model
1919
could never self-correct a typo'd object name).
20+
- **`MeaiLlmClient` text tool-call extraction now delegates to the portable `LlmToolCallTextExtractor`**, so the
21+
Unity path gets the same hardening as core: exact call shape required, backtick/quote-cited examples and fenced
22+
code blocks never execute, balanced-parenthesis scanning, and the full fallback syntax set (XML, function-call,
23+
pseudo `Action=`); nested argument objects are still normalized for MEAI.
2024

2125
### Added
2226

Assets/CoreAiUnity/Runtime/Source/Features/Llm/Infrastructure/MeaiLlmClient.cs

Lines changed: 17 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1715,101 +1715,21 @@ internal static bool TryBuildMalformedTextToolCall(
17151715
}
17161716

17171717
/// <summary>
1718-
/// Pattern-aware tool call extraction from text.
1719-
/// Matches JSON objects that contain both "name" and "arguments" keys (outside fenced ``` blocks),
1720-
/// then falls back to <see cref="LlmToolCallTextExtractor"/> for pseudo-syntax memory writes
1721-
/// (e.g. Qwen: <c>Action=write content="..."</c>).
1718+
/// Pattern-aware tool call extraction from text. Delegates to the portable
1719+
/// <see cref="LlmToolCallTextExtractor"/> so both layers share one parser: exact call shape
1720+
/// (top-level <c>"name"</c> string plus <c>"arguments"</c> object / <c>"arguments_json"</c>
1721+
/// string), backtick/quote-cited spans and fenced ``` blocks skipped, and pseudo-syntax
1722+
/// memory writes (e.g. Qwen: <c>Action=write content="..."</c>) picked up as fallback.
17221723
/// </summary>
17231724
internal static bool TryExtractToolCallsFromText(
17241725
string text,
17251726
out List<MEAI.FunctionCallContent> toolCalls,
17261727
out string cleanedText)
17271728
{
1728-
toolCalls = new List<MEAI.FunctionCallContent>();
1729-
cleanedText = text ?? string.Empty;
1730-
if (string.IsNullOrWhiteSpace(text))
1731-
{
1732-
return false;
1733-
}
1734-
1735-
// Strip fenced code blocks to avoid matching JSON inside them when giving **non-tool** examples.
1736-
string textForSearch = StripCodeBlocks(text);
1737-
1738-
// Find all balanced JSON objects that look like tool calls (only outside fenced ``` blocks;
1739-
// StripCodeBlocks blanks ```...``` so examples like ```json {"name":...} ``` are not matched).
1740-
List<JsonSpan> candidates = FindToolCallJsonSpans(textForSearch);
1741-
1742-
if (candidates.Count == 0)
1743-
{
1744-
return TryPortableToolExtract(text, out toolCalls, out cleanedText);
1745-
}
1746-
1747-
// Build cleaned text by removing all found tool-call JSON spans (from original text)
1748-
// *hidden* from search, the positions still correspond to the original text.
1749-
System.Text.StringBuilder cleanBuilder = new(text.Length);
1750-
int lastEnd = 0;
1751-
foreach (JsonSpan span in candidates)
1752-
{
1753-
// Verify span is valid in original text too
1754-
if (span.Start >= text.Length || span.Start + span.Length > text.Length)
1755-
{
1756-
continue;
1757-
}
1758-
1759-
string originalFragment = text.Substring(span.Start, span.Length);
1760-
1761-
// Re-validate the fragment in the original text
1762-
if (!IsValidToolCallJson(originalFragment))
1763-
{
1764-
continue;
1765-
}
1766-
1767-
try
1768-
{
1769-
JObject json = JObject.Parse(originalFragment);
1770-
string functionName = json["name"]?.ToString()?.Trim();
1771-
// Support both "arguments" and "arguments_json" (Qwen3.5 via LLMUnity).
1772-
JToken argsToken = json["arguments"] ?? json["arguments_json"];
1773-
if (string.IsNullOrWhiteSpace(functionName) || argsToken == null)
1774-
{
1775-
continue;
1776-
}
1777-
1778-
// If args is a string (e.g. "arguments_json": "{...}"), parse it as JSON.
1779-
string argsStr = argsToken.Type == JTokenType.String
1780-
? argsToken.ToString()
1781-
: argsToken.ToString(Formatting.None);
1782-
1783-
Dictionary<string, object?> arguments =
1784-
JsonConvert.DeserializeObject<Dictionary<string, object?>>(argsStr)
1785-
?? new Dictionary<string, object?>();
1786-
1787-
// Normalize JObject/JArray values to strings for MEAI compatibility.
1788-
NormalizeJTokenValues(arguments);
1789-
1790-
string callId = $"stream_call_{functionName}_{Guid.NewGuid():N}";
1791-
toolCalls.Add(new MEAI.FunctionCallContent(callId, functionName, arguments));
1792-
1793-
cleanBuilder.Append(text, lastEnd, span.Start - lastEnd);
1794-
lastEnd = span.Start + span.Length;
1795-
}
1796-
catch
1797-
{
1798-
}
1799-
}
1800-
1801-
if (toolCalls.Count == 0)
1802-
{
1803-
return TryPortableToolExtract(text, out toolCalls, out cleanedText);
1804-
}
1805-
1806-
if (lastEnd < text.Length)
1807-
{
1808-
cleanBuilder.Append(text, lastEnd, text.Length - lastEnd);
1809-
}
1810-
1811-
cleanedText = cleanBuilder.ToString().Trim();
1812-
return true;
1729+
// WHY: This layer used to keep its own, laxer parser that executed cited schema
1730+
// examples (inline-code/quoted JSON, placeholder tool names). Delegating to the
1731+
// hardened portable extractor closes that gap instead of duplicating its guards.
1732+
return TryPortableToolExtract(text, out toolCalls, out cleanedText);
18131733
}
18141734

18151735
/// <summary>
@@ -1837,6 +1757,9 @@ private static bool TryPortableToolExtract(
18371757
JsonConvert.DeserializeObject<Dictionary<string, object?>>(m.ArgumentsJson)
18381758
?? new Dictionary<string, object?>();
18391759

1760+
// Normalize JObject/JArray values to strings for MEAI compatibility.
1761+
NormalizeJTokenValues(arguments);
1762+
18401763
string callId = $"stream_call_{m.Name}_{Guid.NewGuid():N}";
18411764
toolCalls.Add(new MEAI.FunctionCallContent(callId, m.Name, arguments));
18421765
}
@@ -1875,17 +1798,13 @@ internal static string StripCodeBlocks(string text)
18751798
return Regex.Replace(text, @"```[\s\S]*?```", m => new string(' ', m.Length));
18761799
}
18771800

1878-
/// <summary>Checks if a JSON string looks like a tool call (has "name" and "arguments" or "arguments_json").</summary>
1801+
/// <summary>Checks if a JSON string has the exact tool-call shape (top-level "name" string plus "arguments" object or "arguments_json" string).</summary>
18791802
internal static bool IsValidToolCallJson(string json)
18801803
{
1881-
if (string.IsNullOrWhiteSpace(json))
1882-
{
1883-
return false;
1884-
}
1885-
1886-
// Quick heuristic before parsing: must contain both key patterns
1887-
return json.Contains("\"name\"") &&
1888-
(json.Contains("\"arguments\"") || json.Contains("\"arguments_json\""));
1804+
// WHY: Shared with the portable extractor so the hybrid-hold span detection and the
1805+
// execution path agree on what counts as a tool call (substring hits alone treated
1806+
// quoted schema examples as commands).
1807+
return LlmToolCallTextExtractor.LooksLikeToolCallJson(json);
18891808
}
18901809

18911810
/// <summary>

Assets/CoreAiUnity/Tests/EditMode/MeaiLlmClientEditModeTests.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,6 +1200,58 @@ public void ToolCallWithArrayArguments_ExtractedCorrectly()
12001200
Assert.AreEqual("batch_tool", calls[0].Name);
12011201
}
12021202

1203+
[Test]
1204+
public void BacktickCitedSchemaExample_NotExtracted()
1205+
{
1206+
// Parity with the portable extractor: inline-code JSON is a citation, not a command.
1207+
string text =
1208+
"Use `{\"name\":\"memory\",\"arguments\":{\"action\":\"clear\"}}` to clear your memory.";
1209+
bool found = MeaiLlmClient.TryExtractToolCallsFromText(text, out List<MEAI.FunctionCallContent> calls,
1210+
out string _);
1211+
1212+
Assert.IsFalse(found, "Backtick-cited JSON must not execute.");
1213+
Assert.AreEqual(0, calls.Count);
1214+
}
1215+
1216+
[Test]
1217+
public void QuoteWrappedSchemaExample_NotExtracted()
1218+
{
1219+
string text =
1220+
"The docs show '{\"name\":\"memory\",\"arguments\":{\"action\":\"clear\"}}' as the format.";
1221+
bool found = MeaiLlmClient.TryExtractToolCallsFromText(text, out List<MEAI.FunctionCallContent> calls,
1222+
out string _);
1223+
1224+
Assert.IsFalse(found, "JSON wrapped in matching quotes is a citation, not a command.");
1225+
Assert.AreEqual(0, calls.Count);
1226+
}
1227+
1228+
[Test]
1229+
public void PlaceholderToolName_NotExtracted()
1230+
{
1231+
string text = "Reply with {\"name\":\"<tool_name>\",\"arguments\":{}} to call a tool.";
1232+
bool found = MeaiLlmClient.TryExtractToolCallsFromText(text, out List<MEAI.FunctionCallContent> calls,
1233+
out string _);
1234+
1235+
Assert.IsFalse(found, "Placeholder tool names are schema examples, not commands.");
1236+
Assert.IsFalse(MeaiLlmClient.IsValidToolCallJson("{\"name\":\"<tool_name>\",\"arguments\":{}}"),
1237+
"IsValidToolCallJson must require the exact tool-call shape (identifier-like name).");
1238+
}
1239+
1240+
[Test]
1241+
public void CitedExamplePlusRealCall_OnlyRealCallExtracted()
1242+
{
1243+
string text =
1244+
"Example: `{\"name\":\"memory\",\"arguments\":{\"action\":\"read\"}}` and now " +
1245+
"{\"name\":\"memory\",\"arguments\":{\"action\":\"write\",\"content\":\"real\"}}";
1246+
bool found = MeaiLlmClient.TryExtractToolCallsFromText(text, out List<MEAI.FunctionCallContent> calls,
1247+
out string cleaned);
1248+
1249+
Assert.IsTrue(found);
1250+
Assert.AreEqual(1, calls.Count, "Only the non-cited call should be extracted.");
1251+
Assert.AreEqual("write", calls[0].Arguments?["action"]?.ToString());
1252+
Assert.That(cleaned, Does.Contain("`"), "The cited example must stay in the cleaned text.");
1253+
}
1254+
12031255
[Test]
12041256
public void CleanedText_IsTrimmable_NoLeadingTrailingJson()
12051257
{

0 commit comments

Comments
 (0)