package loop import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" ) // buildRequest assembles a model.Request from the agent's public surface, // mirroring agent.buildRequest so the streaming path behaves identically to // RunStep (leading instruction message, tool schemas, model routing). func (l *Loop) buildRequest(messages []model.Message) model.Request { req := model.Request{ Model: l.agent.Model(), Messages: l.assembleMessages(messages), Temperature: l.temperature(), MaxTokens: l.agent.MaxTokens(), Purpose: l.agent.Purpose(), Tools: l.toolSchemas(), } if l.agent.Router() != nil { if m, _ := l.agent.Router().RouteRequest(req); m != "" { req.Model = m } } return req } // assembleMessages prepends the agent's instructions as a leading system message // when they are non-empty, otherwise returns the input slice unchanged. func (l *Loop) assembleMessages(messages []model.Message) []model.Message { instr := l.agent.Instructions() if instr == "" { return messages } out := make([]model.Message, 0, len(messages)+1) out = append(out, model.TextMessage(model.RoleSystem, instr)) out = append(out, messages...) return out } // temperature returns a pointer to the configured temperature, mapping zero to // nil so the provider default applies (matching the agent package). func (l *Loop) temperature() *float64 { t := l.agent.Temperature() if t == 0 { return nil } return &t } // toolSchemas advertises the registered tool schemas to the model, or nil when no // dispatcher is configured. func (l *Loop) toolSchemas() []model.ToolSpec { d := l.dispatcher() if d == nil { return nil } return d.ToolSchemas() } // decodeToolCalls decodes tool calls from a model.Response using the same two // conventions as the agent package: the normalized RoleTool placeholder, then a // JSON envelope of the form {"tool_calls":[...]} in Content. It reports ok=true // (with a non-empty slice) when a call is decoded. func decodeToolCalls(resp model.Response) ([]agent.ToolCall, bool) { if resp.Message.Role == model.RoleAssistant && resp.Message.ToolCallName != "" { return []agent.ToolCall{{Name: resp.Message.ToolCallName, Args: map[string]any{}}}, true } var payload struct { ToolCalls []agent.ToolCall `json:"tool_calls"` } // Lenient decode repairs common model-emitted malformations (code fences, // trailing commas) so a slightly broken tool-call envelope does not turn into // a text answer; an unrecoverable payload still yields ok=false and the round // proceeds as text. if err := tool.LenientJSONUnmarshal([]byte(resp.Content), &payload); err != nil { return nil, false } if len(payload.ToolCalls) == 0 { return nil, false } return payload.ToolCalls, true }