package loop import ( "context" "fmt" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // streamStep drives a streaming model call. It is the streaming counterpart of // agent.RunStep: it builds the request from the agent's public surface, runs the // loop's input guardrail, streams deltas through OnStream, runs the output // guardrail and decodes any tool calls. Because RunStep does not expose a stream // path, this method is owned by the loop; it mirrors the agent's semantics so the // two behave identically apart from the delta callback. func (l *Loop) streamStep(ctx context.Context, messages []model.Message) (*stepOutcome, error) { adapter := l.agent.Adapter() if adapter == nil { return nil, fmt.Errorf("loop: stream step: %w", ErrNoAdapter) } req := l.buildRequest(ctx, messages) if l.guards != nil { verdict, err := l.guards.CheckInput(ctx, &req) if err != nil { return nil, fmt.Errorf("loop: check input: %w", err) } if verdict != nil && !verdict.Allow { return &stepOutcome{final: true, text: verdict.Reason, stopReason: agent.StopReasonGuardrailInput}, nil } } resp, err := adapter.ChatStream(ctx, req, func(ev model.StreamEvent) { // Stamp the session/turn identity at the loop boundary: the adapter runs // below the loop and cannot know which conversation the stream belongs to, // so the loop fills it from the context it was driven with (design/16 ยง4.2) // and the frontend can route the delta to the active session. ev.SessionID = loopSession(ctx) ev.TurnID = loopTurn(ctx) // Suppress UI broadcast for internal autonomous passes (backthink), // whose intermediate reasoning must not leak into the chat stream, and for // thinking frames when the bound persona turned them off (showToUser: // false). Both gates are broadcast-only: the response the model produced is // unchanged, so display and persistence stay decoupled. if l.onStream != nil && !isMutedBroadcast(ctx) && l.broadcasts(ev) { l.onStream(ev) } }) if err != nil { return nil, fmt.Errorf("loop: chat stream: %w", err) } if l.guards != nil { verdict, err := l.guards.CheckOutput(ctx, &resp) if err != nil { return nil, fmt.Errorf("loop: check output: %w", err) } if verdict != nil && !verdict.Allow { return &stepOutcome{final: true, text: verdict.Reason, stopReason: agent.StopReasonGuardrailOutput}, nil } } if calls, ok := decodeToolCalls(resp); ok { return &stepOutcome{final: false, toolCalls: calls, stopReason: agent.StopReasonToolCalls, modelStopReason: resp.StopReason, model: resp.Model}, nil } return &stepOutcome{final: true, text: resp.Content, stopReason: agent.StopReasonStop, modelStopReason: resp.StopReason, model: resp.Model}, nil } // broadcasts reports whether a streamed event may be pushed to the UI. Only // thinking frames are ever withheld, and only when the wiring resolved the // persona's showToUser to false; text, tool-call and tool-progress frames are // never affected, so turning thinking off cannot change what the client sees of // the answer itself. func (l *Loop) broadcasts(ev model.StreamEvent) bool { if !isThinkingKind(ev.Kind) { return true } return l.thinkingShow == nil || *l.thinkingShow } // isThinkingKind reports whether kind is one of the reasoning segment events. func isThinkingKind(kind model.StreamKind) bool { switch kind { case model.StreamKindThinkingStart, model.StreamKindThinkingDelta, model.StreamKindThinkingEnd: return true default: return false } }