Skip to content

Commit 27a1357

Browse files
committed
refactor(realtime): bound the Manage Mode tool-loop + preserve assistant tools
Fallout from a review pass on the Manage Mode patches: - Bound the server-side agentic loop. triggerResponse used to recurse on executedAssistantTool with no cap — a model that kept calling tools would blow the goroutine stack. New maxAssistantToolTurns = 10 (mirrors useChat.js's maxToolTurns). Public triggerResponse is now a thin shim over triggerResponseAtTurn(toolTurn int); recursion increments the counter and stops at the cap with an xlog.Warn. - Preserve Manage Mode tools across client session.update. The handler used to blindly overwrite session.Tools, so toggling a client MCP server mid-session silently wiped the in-process admin tools. Session now caches the original AssistantTools slice at session creation and the session.update handler merges them back in (client names win on collision — the client is explicit). - strconv.ParseBool for the localai_assistant query param instead of hand-rolled "1" || "true". Mirrors LocalAIAssistantFromMetadata. - Talk.jsx: render both tool_call and tool_result on response.output_item.done instead of splitting them across .added and .done. The server's event pairing (added → done) stays correct; the UI just doesn't need to inspect both phases of the same item. One switch case instead of two, no behavioural change. Out of scope (noted for follow-ups): extract a shared assistant-tools helper between chat.go and realtime.go (duplication is small enough that two parallel implementations stay readable for now), and an i18n key for the Manage Mode helper text (Talk.jsx doesn't use i18n anywhere else yet). Assisted-by: claude-code:claude-opus-4-7-1m [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent 698be5c commit 27a1357

2 files changed

Lines changed: 61 additions & 14 deletions

File tree

core/http/endpoints/openai/realtime.go

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"math"
1010
"os"
11+
"strconv"
1112
"sync"
1213
"time"
1314

@@ -96,6 +97,12 @@ type Session struct {
9697
// path.
9798
AssistantExecutor mcpTools.ToolExecutor
9899

100+
// AssistantTools is the cached ToolUnion slice we injected at session
101+
// creation. Re-applied after every client session.update so a
102+
// client-driven tool refresh (e.g. toggling a client MCP server) doesn't
103+
// silently strip Manage Mode's tools.
104+
AssistantTools []types.ToolUnion
105+
99106
// Response cancellation: protects activeResponseCancel/activeResponseDone
100107
responseMu sync.Mutex
101108
activeResponseCancel context.CancelFunc
@@ -247,8 +254,9 @@ func Realtime(application *application.Application) echo.HandlerFunc {
247254

248255
// Extract query parameters from Echo context before passing to websocket handler
249256
model := c.QueryParam("model")
257+
assistantFlag, _ := strconv.ParseBool(c.QueryParam("localai_assistant"))
250258
opts := RealtimeSessionOptions{
251-
LocalAIAssistant: c.QueryParam("localai_assistant") == "1" || c.QueryParam("localai_assistant") == "true",
259+
LocalAIAssistant: assistantFlag,
252260
IsAdmin: isCurrentUserAdmin(c, application),
253261
}
254262

@@ -424,6 +432,7 @@ func runRealtimeSession(application *application.Application, t Transport, model
424432
Instructions: instructions,
425433
ModelConfig: cfg,
426434
Tools: assistantTools,
435+
AssistantTools: assistantTools,
427436
AssistantExecutor: assistantExecutor,
428437
TurnDetection: &types.TurnDetectionUnion{
429438
ServerVad: &types.ServerVad{
@@ -975,7 +984,28 @@ func updateSession(session *Session, update *types.SessionUnion, cl *config.Mode
975984
}
976985

977986
if rt.Tools != nil {
978-
session.Tools = rt.Tools
987+
// Manage Mode tools survive a client-driven session.update — the
988+
// alternative is silently dropping them whenever the user toggles
989+
// a client MCP server, which would break the modality mid-session.
990+
// Names from rt.Tools win on collision (the client is explicit;
991+
// we preserve, we don't override).
992+
merged := append([]types.ToolUnion(nil), rt.Tools...)
993+
seen := make(map[string]struct{}, len(merged))
994+
for _, t := range merged {
995+
if t.Function != nil {
996+
seen[t.Function.Name] = struct{}{}
997+
}
998+
}
999+
for _, t := range session.AssistantTools {
1000+
if t.Function == nil {
1001+
continue
1002+
}
1003+
if _, ok := seen[t.Function.Name]; ok {
1004+
continue
1005+
}
1006+
merged = append(merged, t)
1007+
}
1008+
session.Tools = merged
9791009
}
9801010
if rt.ToolChoice != nil {
9811011
session.ToolChoice = rt.ToolChoice
@@ -1269,7 +1299,17 @@ func generateResponse(ctx context.Context, session *Session, utt []byte, transcr
12691299
triggerResponse(ctx, session, conv, t, nil)
12701300
}
12711301

1302+
// maxAssistantToolTurns caps the server-side agentic loop. Mirrors the
1303+
// chat-page maxToolTurns:10 from useChat.js — the model gets up to this
1304+
// many consecutive tool round-trips before we return control to the user
1305+
// without another response cycle.
1306+
const maxAssistantToolTurns = 10
1307+
12721308
func triggerResponse(ctx context.Context, session *Session, conv *Conversation, t Transport, overrides *types.ResponseCreateParams) {
1309+
triggerResponseAtTurn(ctx, session, conv, t, overrides, 0)
1310+
}
1311+
1312+
func triggerResponseAtTurn(ctx context.Context, session *Session, conv *Conversation, t Transport, overrides *types.ResponseCreateParams, toolTurn int) {
12731313
config := session.ModelInterface.PredictConfig()
12741314

12751315
// Default values
@@ -1800,8 +1840,11 @@ func triggerResponse(ctx context.Context, session *Session, conv *Conversation,
18001840
conv.Lock.Lock()
18011841
conv.Items = append(conv.Items, &foItem)
18021842
conv.Lock.Unlock()
1803-
// First the call, then the output — gives the UI two distinct
1804-
// transcript entries (🔧 calling X / 📋 X returned …).
1843+
// Close the call out and emit the output as its own paired
1844+
// added/done — the OpenAI spec pairs every item-done with a
1845+
// preceding item-added, so we re-pair here for the output.
1846+
// The UI renders the transcript entry on item.done for both
1847+
// shapes (FunctionCall + FunctionCallOutput).
18051848
sendEvent(t, types.ResponseOutputItemDoneEvent{
18061849
ServerEventBase: types.ServerEventBase{},
18071850
ResponseID: responseID,
@@ -1862,9 +1905,15 @@ func triggerResponse(ctx context.Context, session *Session, conv *Conversation,
18621905

18631906
// If we executed any assistant tools inproc, run another response cycle
18641907
// so the model can speak the result. Mirrors the chat-side agentic loop
1865-
// but driven server-side rather than by client round-trip.
1908+
// but driven server-side rather than by client round-trip. Bounded so a
1909+
// degenerate "model keeps calling tools" doesn't blow the stack.
18661910
if executedAssistantTool {
1867-
triggerResponse(ctx, session, conv, t, nil)
1911+
if toolTurn+1 >= maxAssistantToolTurns {
1912+
xlog.Warn("realtime: assistant tool-turn limit reached, stopping the agentic loop",
1913+
"limit", maxAssistantToolTurns, "model", session.Model)
1914+
return
1915+
}
1916+
triggerResponseAtTurn(ctx, session, conv, t, nil, toolTurn+1)
18681917
}
18691918
}
18701919

core/http/react-ui/src/pages/Talk.jsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -264,26 +264,24 @@ export default function Talk() {
264264
case 'response.output_audio.delta':
265265
updateStatus('speaking', 'Speaking...')
266266
break
267-
case 'response.output_item.added':
268267
case 'response.output_item.done': {
269268
// Server-executed tools (Manage Mode) surface as output items —
270-
// a FunctionCall when the model decides to invoke a tool, and a
271-
// FunctionCallOutput once the server has run it. We only render
272-
// 'done' for the call (so it shows up once with its arguments)
273-
// and 'added' for the output (rendered immediately on arrival).
269+
// FunctionCall when the model invokes a tool, FunctionCallOutput
270+
// once the server has run it. Render both on `done` so we get
271+
// each transcript entry exactly once.
274272
const item = event.item
275273
if (!item) break
276-
if (event.type === 'response.output_item.done' && item.FunctionCall) {
274+
if (item.FunctionCall) {
277275
setTranscript(prev => [...prev, {
278276
role: 'tool_call',
279277
text: `${item.FunctionCall.name}(${item.FunctionCall.arguments || ''})`,
280278
}])
281-
} else if (event.type === 'response.output_item.added' && item.FunctionCallOutput) {
279+
} else if (item.FunctionCallOutput) {
282280
let preview = item.FunctionCallOutput.output || ''
283281
// Pretty-print JSON for readability; fall back to raw string.
284282
try { preview = JSON.stringify(JSON.parse(preview), null, 2) } catch (_) { /* keep raw */ }
285283
setTranscript(prev => [...prev, { role: 'tool_result', text: preview }])
286-
streamingRef.current = null // assistant turn is now fresh after the tool
284+
streamingRef.current = null // tool result ends the current assistant text run
287285
}
288286
break
289287
}

0 commit comments

Comments
 (0)