package loop import ( "context" "fmt" "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/trace" ) // stepOutcome is the loop's internal view of one model step, normalized from the // agent's *agent.StepResult (non-streaming) or the equivalent built by the // streaming path. type stepOutcome struct { final bool text string toolCalls []agent.ToolCall stopReason string // modelStopReason is the provider's raw stop/finish reason for the model // call (Anthropic max_tokens / end_turn / tool_use), recorded on the llm_call // span so a truncation is visible even when the step itself ended normally. modelStopReason string } // step runs a single model call. When an OnStream callback is configured it // drives the adapter's ChatStream directly, threading deltas to OnStream and // applying the loop's guardrails; otherwise it delegates to the agent's RunStep, // which runs the agent's own guardrails and records its own CallRecord. The whole // call is wrapped in an llm_call span so the M9 panel sees each LLM invocation. func (l *Loop) step(ctx context.Context, sessionID string, messages []model.Message) (*stepOutcome, error) { _, llmSpan := l.span(ctx, "llm.call", trace.SpanKindLLM) var ( out *stepOutcome err error ) // Muted (internal autonomous pass) bypasses streaming: it neither streams to // the UI nor pays the streaming path's extra overhead. Normal chat streams. if l.onStream != nil && !isMutedBroadcast(ctx) { out, err = l.streamStep(ctx, messages) } else { out, err = l.agentStep(ctx, sessionID, messages) } if err != nil { l.spanProp(llmSpan, "error", err.Error()) l.finishSpan(llmSpan, trace.SpanStatusError) return nil, fmt.Errorf("loop: model step: %w", err) } l.spanProp(llmSpan, "stop_reason", out.stopReason) if out.modelStopReason != "" { l.spanProp(llmSpan, "model_stop_reason", out.modelStopReason) } l.spanProp(llmSpan, "tool_calls", len(out.toolCalls)) l.finishSpan(llmSpan, trace.SpanStatusOK) return out, nil } // agentStep delegates a non-streaming step to the agent, which runs input and // output guardrails, calls the model and decodes any tool calls. func (l *Loop) agentStep(ctx context.Context, sessionID string, messages []model.Message) (*stepOutcome, error) { sr, err := l.agent.RunStep(ctx, sessionID, messages) if err != nil { return nil, fmt.Errorf("loop: agent step: %w", err) } return &stepOutcome{ final: sr.Final, text: sr.Text, toolCalls: sr.ToolCalls, stopReason: sr.StopReason, modelStopReason: sr.ModelStopReason, }, nil }