package core

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"regexp"
	"strings"
	"sync"
	"time"

	"nudgebee/llm/agents/prompts_repo"
	"nudgebee/llm/common"
	"nudgebee/llm/config"
	nbprompts "nudgebee/llm/prompts"
	"nudgebee/llm/security"
	toolcore "nudgebee/llm/tools/core"

	"github.com/google/uuid"
	"github.com/samber/lo"
	"github.com/tmc/langchaingo/llms"
	"github.com/tmc/langchaingo/prompts"
)

// reActActionBlockRegex extracts individual <action>...</action> blocks from
// within an <actions> container. Uses (?s) for multiline matching.
var reActActionBlockRegex = regexp.MustCompile(`(?s)<action>(.*?)</action>`)

// NBReActPlanner3 is a hybrid ReAct planner that supports parallel tool actions.
// It extends the ReAct loop (thought → action → observation) with an <actions>
// (plural) XML element that allows the LLM to declare multiple independent
// tool calls in a single step.
type NBReActPlanner3 struct {
	ctx                   *security.RequestContext
	llm                   llms.Model
	prompt                prompts.FormatPrompter
	request               NBAgentRequest
	nbAgent               NBAgent
	summaryToolName       string
	retryConfig           RetryConfig
	refinementAttempts    int
	maxRefinementAttempts int
	refinementData        []struct {
		feedback string
		answer   string
	}
	postRefinementToolIndex int
	tools                   []toolcore.NBTool
	enableCritique          bool
	Notebook                string

	// Notebook telemetry — for visibility into whether the agent is
	// actually exercising the notebook discipline the prompt asks for.
	// These are NOT part of planner correctness; they exist so operators
	// can grep logs / replay sessions and validate notebook usage.
	notebookUpdateCount      int // total <update_notebook> emissions seen
	notebookLastUpdateTurn   int // turn index (0-based) of last update; -1 if never
	notebookFirstUpdateTurn  int // turn index of first update; -1 if never
	notebookStaleWarningsLog int // count of stale-notebook warnings emitted

	// notebookAgentID is the llm_conversation_agent row id used to
	// persist the notebook to the DB (mirrors how rewoo stores its
	// generated plan). Created lazily on the first update; subsequent
	// updates patch the same row so the UI sees a single notebook
	// entry that evolves over the course of the investigation.
	notebookAgentID string

	compressionTracker *CompressionTracker
	// llmSummarizationDisabled is set to true after the first LLM summarization
	// failure. Once set, prewarmSummaries skips LLM calls entirely and lets the
	// scratchpad builder fall back to byte truncation. This avoids wasting time
	// on retries + fallback model attempts when the LLM is unavailable.
	llmSummarizationDisabled bool
	// cachedOlderPortionLen and cachedSummaryPrefix avoid redundant LLM
	// summarization calls. When the older portion length hasn't changed, the
	// same summary is reused. This helps both within-iteration (multiple
	// buildScratchpad calls) and across iterations where the split point is stable.
	cachedOlderPortionLen int
	cachedSummaryPrefix   string

	// stepCount is a monotonically increasing counter used to assign
	// human-readable sequential display IDs (E1, E2, E3 ...) to every
	// tool action generated by this planner.  Each call to
	// processToolAction or parseParallelActions increments the counter
	// before assigning DisplayID so the IDs are stable across retries
	// within a single Plan() call.
	stepCount int
}

// notebookDummyAgent is the agent name used when persisting the
// react_3 notebook to llm_conversation_agent. It mirrors how rewoo
// uses plannerDummyTool ("planner") for its generated plan record.
// This row is written via the DAO only — it never enters the
// action queue or scratchpad, so there is no risk of the executor
// trying to invoke it as a real tool.
const notebookDummyAgent = "notebook"

// isNotebookToolName returns true when toolName is a hallucinated variant
// of the notebook update tag. LLMs sometimes emit the notebook content as
// a tool call instead of the inline <update_notebook> XML tag. This
// function covers the observed variant ("update_notebook") plus plausible
// aliases that an LLM might generate from the prompt instructions.
func isNotebookToolName(toolName string) bool {
	lower := strings.ToLower(strings.TrimSpace(toolName))
	switch lower {
	case "update_notebook",
		"notebook",
		"create_notebook",
		"write_notebook",
		"save_notebook",
		"notebook_update",
		"edit_notebook":
		return true
	}
	return false
}

// saveCritique persists the results of a critique cycle to the database.
func (o *NBReActPlanner3) saveCritique(critiqueType, input, critiquedContent, feedback, decision string) error {
	conversationDAO := GetConversationDao()
	var feedbackPtr *string
	if feedback != "" {
		feedbackPtr = &feedback
	}
	return conversationDAO.SaveCritique(&ConversationCritique{
		ConversationID:   o.request.ConversationId,
		MessageID:        o.request.MessageId,
		AccountID:        o.request.AccountId,
		AgentName:        o.nbAgent.GetName(),
		CritiqueType:     critiqueType,
		Input:            input,
		CritiquedContent: critiquedContent,
		Feedback:         feedbackPtr,
		Decision:         decision,
	})
}

// notebookSectionEnabled returns whether the notebook discipline is in
// effect for this planner instance. Mirrors ResolveAgentNotebookEnabled
// so we can early-return from notebook-handling paths (system_nudge
// injection, hallucination capture) when the agent opted out via
// NBAgentNotebookSectionProvider. When nbAgent is nil (test struct
// literals) the default is true — matching pre-opt-out behavior so
// existing tests aren't disturbed.
func (o *NBReActPlanner3) notebookSectionEnabled() bool {
	if o.nbAgent == nil {
		return true
	}
	return ResolveAgentNotebookEnabled(o.nbAgent)
}

// notebookStaleTurnThreshold is the number of turns without a notebook
// update before the planner considers the notebook stale and injects a
// system nudge / logs a warning. Centralised here so the threshold is
// consistent across buildScratchpad and processNotebookUpdate.
const notebookStaleTurnThreshold = 2

// isTopLevelAgent returns true when this planner instance is the
// top-level orchestrator (not a sub-agent like postgres or gcp).
// Sub-agents have a ParentAgentId pointing to the orchestrator;
// top-level agents either have it empty or self-referencing.
func (o *NBReActPlanner3) isTopLevelAgent() bool {
	return o.request.ParentAgentId == "" || o.request.ParentAgentId == o.request.AgentId
}

// notebookStaleInfo returns whether the notebook is stale and how many
// turns have elapsed since the last update (-1 if never updated).
func (o *NBReActPlanner3) notebookStaleInfo(turnIdx int) (stale bool, turnsSinceUpdate int) {
	turnsSinceUpdate = -1
	if o.notebookLastUpdateTurn >= 0 {
		turnsSinceUpdate = turnIdx - o.notebookLastUpdateTurn
	}
	stale = (o.notebookUpdateCount > 0 && turnsSinceUpdate >= notebookStaleTurnThreshold) ||
		(o.notebookUpdateCount == 0 && turnIdx >= notebookStaleTurnThreshold)
	return
}

// stripNestedXMLTags removes common nested XML tags that leak into the thought
// string when the LLM wraps everything inside <thought_action>.
func stripNestedXMLTags(thought string) string {
	for _, tag := range []string{"actions", "action", "tool_name", "tool_input"} {
		thought = strings.ReplaceAll(thought, "<"+tag+">", "")
		thought = strings.ReplaceAll(thought, "</"+tag+">", "")
	}
	return strings.TrimSpace(thought)
}

// GetTools returns the list of tools available to the planner.
func (o *NBReActPlanner3) GetTools() []toolcore.NBTool {
	return o.tools
}

// GetNotebook returns the current content of the agent's notebook.
func (o *NBReActPlanner3) GetNotebook() string {
	return o.Notebook
}

// Marshal serializes the planner state into JSON.
func (o *NBReActPlanner3) Marshal() ([]byte, error) {
	state := map[string]any{
		"notebook":                 o.Notebook,
		"refinementAttempts":       o.refinementAttempts,
		"postRefinementToolIndex":  o.postRefinementToolIndex,
		"notebookUpdateCount":      o.notebookUpdateCount,
		"notebookLastUpdateTurn":   o.notebookLastUpdateTurn,
		"notebookFirstUpdateTurn":  o.notebookFirstUpdateTurn,
		"notebookStaleWarningsLog": o.notebookStaleWarningsLog,
		"notebookAgentID":          o.notebookAgentID,
	}
	return common.MarshalJson(state)
}

// Unmarshal restores the planner state from JSON.
func (o *NBReActPlanner3) Unmarshal(data []byte) error {
	var state struct {
		Notebook                 string `json:"notebook"`
		RefinementAttempts       int    `json:"refinementAttempts"`
		PostRefinementToolIndex  int    `json:"postRefinementToolIndex"`
		NotebookUpdateCount      int    `json:"notebookUpdateCount"`
		NotebookLastUpdateTurn   *int   `json:"notebookLastUpdateTurn"`
		NotebookFirstUpdateTurn  *int   `json:"notebookFirstUpdateTurn"`
		NotebookStaleWarningsLog int    `json:"notebookStaleWarningsLog"`
		NotebookAgentID          string `json:"notebookAgentID"`
	}
	err := common.UnmarshalJson(data, &state)
	if err != nil {
		return err
	}
	o.Notebook = state.Notebook
	o.refinementAttempts = state.RefinementAttempts
	o.postRefinementToolIndex = state.PostRefinementToolIndex
	o.notebookUpdateCount = state.NotebookUpdateCount
	o.notebookStaleWarningsLog = state.NotebookStaleWarningsLog
	o.notebookAgentID = state.NotebookAgentID
	// Preserve -1 "never" sentinels for older state blobs that predate
	// these fields — absent fields decode to nil, meaning "unknown".
	if state.NotebookLastUpdateTurn != nil {
		o.notebookLastUpdateTurn = *state.NotebookLastUpdateTurn
	} else {
		o.notebookLastUpdateTurn = -1
	}
	if state.NotebookFirstUpdateTurn != nil {
		o.notebookFirstUpdateTurn = *state.NotebookFirstUpdateTurn
	} else {
		o.notebookFirstUpdateTurn = -1
	}
	return nil
}

// buildScratchpad constructs the XML-formatted history of tool calls and observations
// for the LLM. It groups parallel actions (multiple actions from the same iteration)
// together under a single thought, and applies semantic compression to older steps.
//
// postRefinementToolIndex controls compression granularity, NOT filtering:
// steps before the index are always compressed (truncated), steps after get full context
// up to the recent-steps window. This ensures the LLM always sees ALL tool observations
// (even from before critique rejection) while keeping token usage manageable.
func (o *NBReActPlanner3) buildScratchpad(intermediateSteps []NBAgentPlannerToolActionStep) string {
	totalSteps := len(intermediateSteps)

	// Parallel prewarm: fire LLM summarizations for non-recent steps concurrently
	// to avoid a sequential latency spike when compression first kicks in.
	o.prewarmSummaries(intermediateSteps)

	var history strings.Builder
	stepIndex := 0

	i := 0
	for i < len(intermediateSteps) {
		step := intermediateSteps[i]
		stepIndex++
		// Steps before postRefinementToolIndex are always compressed (pre-refinement).
		// Steps after use the standard recent-window heuristic.
		isRecent := i >= o.postRefinementToolIndex && (totalSteps-stepIndex) < recentStepsFullContext

		// Detect parallel group: consecutive steps with the same Log (thought).
		// When react_3 emits multiple actions, they all share the same thought.
		groupEnd := i + 1
		for groupEnd < len(intermediateSteps) &&
			intermediateSteps[groupEnd].Action.Log == step.Action.Log &&
			groupEnd-i < 10 { // safety cap
			groupEnd++
		}
		groupSize := groupEnd - i

		if groupSize > 1 {
			// Parallel group — render with <actions> (plural)
			history.WriteString("<thought_action>\n")
			fmt.Fprintf(&history, "<thought><![CDATA[%s]]></thought>\n", step.Action.Log)
			history.WriteString("<actions>\n")
			for j := i; j < groupEnd; j++ {
				s := intermediateSteps[j]
				history.WriteString("<action>\n")
				fmt.Fprintf(&history, "    <tool_name>%s</tool_name>\n", s.Action.Tool)
				fmt.Fprintf(&history, "    <tool_input><![CDATA[%s]]></tool_input>\n", s.Action.ToolInput)
				history.WriteString("</action>\n")
			}
			history.WriteString("</actions>\n")
			history.WriteString("</thought_action>\n")

			// Write observations for each action in the group
			for j := i; j < groupEnd; j++ {
				toolResponse := o.getToolResponse(&intermediateSteps[j], isRecent)
				history.WriteString(o.formatObservation(intermediateSteps[j], toolResponse))
				history.WriteString("\n")
				// Count each additional step in the group for compression tracking
				if j > i {
					stepIndex++
				}
			}
			i = groupEnd
		} else {
			// Single action — render with <action> (singular), same as react_2
			history.WriteString("<thought_action>\n")
			fmt.Fprintf(&history, "<thought><![CDATA[%s]]></thought>\n", step.Action.Log)
			history.WriteString("<action>\n")
			fmt.Fprintf(&history, "    <tool_name>%s</tool_name>\n", step.Action.Tool)
			fmt.Fprintf(&history, "    <tool_input><![CDATA[%s]]></tool_input>\n", step.Action.ToolInput)
			history.WriteString("</action>\n")
			history.WriteString("</thought_action>\n")

			toolResponse := o.getToolResponse(&intermediateSteps[i], isRecent)
			history.WriteString(o.formatObservation(step, toolResponse))
			history.WriteString("\n")
			i++
		}
	}

	// Inject a notebook-discipline nudge when the top-level agent has
	// gone 2+ turns without updating the notebook during an active
	// investigation. This is a system-level reminder the model cannot
	// ignore — prompt-only guidance proved insufficient (see logs:
	// k8s_debug went 7 turns between updates in production).
	// Skip entirely for agents that opted out of the notebook section
	// via NBAgentNotebookSectionProvider — nudging them about a
	// non-existent notebook would confuse the model and contradict
	// the prompt (where the NOTEBOOK section is template-suppressed).
	if o.notebookSectionEnabled() && o.isTopLevelAgent() {
		if stale, turnsSinceUpdate := o.notebookStaleInfo(totalSteps); stale {
			history.WriteString("\n<system_nudge>")
			history.WriteString("⚠ NOTEBOOK STALE — You have completed ")
			fmt.Fprintf(&history, "%d tool actions since your last <update_notebook>. ", lo.Ternary(turnsSinceUpdate >= 0, turnsSinceUpdate, totalSteps))
			history.WriteString("You MUST include an <update_notebook> block in your NEXT response. ")
			history.WriteString("Record what you found, what it means, and update your plan status markers ([DONE]/[DOING]/[NEXT]).")
			history.WriteString("</system_nudge>\n")
		}
	}

	scratchpad := ""
	if history.Len() > 0 {
		scratchpad = fmt.Sprintf("<scratchpad>\n%s</scratchpad>", history.String())
	}

	// Aggregate budget: cap total scratchpad size.
	maxChars := config.Config.LlmServerAgentMaxScratchpadChars
	if maxChars > 0 && len(scratchpad) > maxChars {
		scratchpad = o.compressScratchpad(scratchpad, maxChars, intermediateSteps)
	}
	return scratchpad
}

// compressScratchpad reduces a scratchpad that exceeds the budget by splitting it
// into an older portion and a recent portion, then LLM-summarizing the older portion.
// When the feature flag is off or the LLM call fails, it falls back to truncateTail.
func (o *NBReActPlanner3) compressScratchpad(scratchpad string, maxChars int, steps []NBAgentPlannerToolActionStep) string {
	// Feature flag: use LLM-based whole-context summarization when enabled.
	if config.Config.LlmServerScratchpadSummarizationEnabled && o.ctx != nil && !o.llmSummarizationDisabled {
		compressed := o.llmCompressScratchpad(scratchpad, maxChars, steps)
		if compressed != "" {
			return compressed
		}
		// LLM compression failed — disable for remaining iterations (circuit breaker).
		o.llmSummarizationDisabled = true
	}

	// Fallback: blind byte truncation.
	const tagOverhead = 500
	effectiveLimit := maxChars - tagOverhead
	if effectiveLimit < 0 {
		effectiveLimit = 0
	}
	result := "<scratchpad>\n[earlier turns truncated]\n" + truncateTail(scratchpad, effectiveLimit)
	if !strings.HasSuffix(result, "</scratchpad>") {
		result += "</scratchpad>"
	}
	return result
}

// llmCompressScratchpad performs whole-context LLM summarization of the older
// portion of a scratchpad. It keeps the most recent ~70% verbatim and summarizes
// the older ~30% into a compact block. Returns "" if the LLM call fails.
func (o *NBReActPlanner3) llmCompressScratchpad(scratchpad string, maxChars int, steps []NBAgentPlannerToolActionStep) string {
	// Dynamic split: keep as much recent content as possible. We only need to
	// compress (scratchpad - maxChars) bytes. The summary target is fixed at 30%
	// of budget; everything else goes to the recent portion.
	const overhead = 200 // tags + "[context summary]" marker
	targetSummaryLen := maxChars * 30 / 100
	if targetSummaryLen < 200 {
		targetSummaryLen = 200
	}
	recentKeep := maxChars - targetSummaryLen - overhead
	if recentKeep > len(scratchpad) {
		recentKeep = len(scratchpad)
	}
	if recentKeep < 0 {
		recentKeep = 0
	}
	splitPoint := len(scratchpad) - recentKeep
	olderPortion := scratchpad[:splitPoint]
	recentPortion := scratchpad[splitPoint:]

	// Cache hit: if the older portion length hasn't changed, the same LLM summary
	// applies. This catches both within-iteration redundancy (buildScratchpad called
	// 3x per iteration) and cross-iteration cases where the split point is stable.
	if o.cachedOlderPortionLen == len(olderPortion) && o.cachedSummaryPrefix != "" {
		o.ctx.GetLogger().Debug("scratchpad: reusing cached LLM summary",
			"older_len", len(olderPortion),
			"recent_len", len(recentPortion),
		)
		result := o.cachedSummaryPrefix + "\n\n" + recentPortion
		if !strings.HasSuffix(result, "</scratchpad>") {
			result += "</scratchpad>"
		}
		return result
	}

	targetLen := targetSummaryLen

	prompt := prompts_repo.GetPrompt(prompts_repo.PromptScratchpadContextSummarizer, len(olderPortion), olderPortion, targetLen)

	timeoutCtx, cancel := context.WithTimeout(
		context.WithValue(context.WithoutCancel(o.ctx.GetContext()), ContextKeyModelTier, ModelTierSummary),
		getScratchpadSummaryTimeout(),
	)
	defer cancel()

	summaryCtx := security.NewRequestContext(
		timeoutCtx,
		o.ctx.GetSecurityContext(),
		o.ctx.GetLogger(),
		o.ctx.GetTracer(),
		o.ctx.GetMeter(),
	)

	t0 := time.Now()
	completion, err := GenerateAndTrackLLMContent(
		summaryCtx,
		o.request.UserId,
		o.request.AccountId,
		o.request.ConversationId,
		o.request.MessageId,
		o.request.AgentId,
		false,
		[]llms.MessageContent{
			llms.TextParts(llms.ChatMessageTypeHuman, prompt),
		},
		false,
		llms.WithTemperature(0.0),
		WithThinkingLevel(ThinkingLevelFastTask),
	)
	latency := time.Since(t0).Seconds()

	if err != nil {
		o.ctx.GetLogger().Warn("scratchpad: context summarization failed, falling back to truncation",
			"error", err,
			"older_portion_len", len(olderPortion),
			"latency_s", latency,
		)
		return ""
	}

	summary := ""
	if len(completion.Choices) > 0 {
		summary = completion.Choices[0].Content
	}
	if summary == "" {
		o.ctx.GetLogger().Warn("scratchpad: context summarization returned empty",
			"latency_s", latency,
		)
		return ""
	}

	// Enforce target length.
	if len(summary) > targetLen {
		summary = TruncateHead(summary, targetLen)
	}

	o.ctx.GetLogger().Info("scratchpad: context summarized via LLM",
		"original_len", len(scratchpad),
		"older_len", len(olderPortion),
		"summary_len", len(summary),
		"recent_len", len(recentPortion),
		"final_len", overhead+len(summary)+len(recentPortion),
		"latency_s", latency,
	)

	// Record compression on the first step only (whole-context summarization is a
	// single event, not per-step). This drives the visibility card count.
	if len(steps) > 0 && steps[0].CompressedObservation == "" {
		steps[0].CompressedObservation = fmt.Sprintf("[summarized from %d chars] context-level LLM compression", len(olderPortion))
	}

	// Cache the summary prefix so subsequent iterations with the same older portion
	// can skip the LLM call entirely.
	summaryPrefix := "<scratchpad>\n[earlier context summarized by LLM]\n" + summary
	o.cachedOlderPortionLen = len(olderPortion)
	o.cachedSummaryPrefix = summaryPrefix

	result := summaryPrefix + "\n\n" + recentPortion
	if !strings.HasSuffix(result, "</scratchpad>") {
		result += "</scratchpad>"
	}
	return result
}

// resolveToolResponse applies the agent's optional response handler and returns
// the observation text as it will be rendered into the scratchpad, before
// semantic compression. No mutation of the step occurs.
func (o *NBReActPlanner3) resolveToolResponse(step NBAgentPlannerToolActionStep) string {
	toolResponse := step.Observation
	if responseHandler, ok := o.nbAgent.(NBAgentExecutorToolResponseHandler); ok {
		toolResponse = responseHandler.UpdateToolResponseForPlanner(step.Action, step.Observation)
	}
	if toolResponse == "" {
		toolResponse = step.Observation
	}
	if toolResponse == "" {
		toolResponse = "No Data Found"
	}
	return toolResponse
}

// getToolResponse processes the observation for a step and applies semantic
// compression. When summarization is enabled and the step is not recent, it
// generates an LLM-backed summary instead of blind byte truncation.
func (o *NBReActPlanner3) getToolResponse(step *NBAgentPlannerToolActionStep, isRecent bool) string {
	toolResponse := o.resolveToolResponse(*step)

	if isRecent {
		if len(toolResponse) > getMaxObservationChars() {
			toolResponse = TruncateMiddle(toolResponse, 2048, getMaxObservationChars()-2048)
		}
		return toolResponse
	}
	return SummarizeObservation(o.ctx, step, o.request, toolResponse)
}

// prewarmSummaries fires LLM summarizations in parallel for all non-recent steps
// whose observation is large enough to benefit. This avoids a sequential latency
// spike when multiple older steps need compression at once. The result is cached
// in each step's CompressedObservation field.
func (o *NBReActPlanner3) prewarmSummaries(intermediateSteps []NBAgentPlannerToolActionStep) {
	if !config.Config.LlmServerScratchpadSummarizationEnabled || o.ctx == nil {
		return
	}

	// Circuit breaker: if a previous iteration's LLM calls all failed, skip
	// the expensive prewarm entirely. Byte truncation will handle compression.
	if o.llmSummarizationDisabled {
		return
	}

	minBytes := getScratchpadSummaryMinBytes()
	totalSteps := len(intermediateSteps)

	// Shared cancel context: on the first LLM failure, cancel remaining
	// goroutines so they fall back to byte truncation immediately.
	prewarmCtx, cancelPrewarm := context.WithCancel(o.ctx.GetContext())
	defer cancelPrewarm()

	wrappedCtx := security.NewRequestContext(
		prewarmCtx,
		o.ctx.GetSecurityContext(),
		o.ctx.GetLogger(),
		o.ctx.GetTracer(),
		o.ctx.GetMeter(),
	)

	var failOnce sync.Once

	// Mirror buildScratchpad's step counting (accounting for parallel action grouping)
	// to determine which steps are non-recent and will be compressed.
	var wg sync.WaitGroup
	stepIndex := 0
	i := 0
	for i < len(intermediateSteps) {
		step := intermediateSteps[i]
		stepIndex++
		isRecent := i >= o.postRefinementToolIndex && (totalSteps-stepIndex) < recentStepsFullContext

		groupEnd := i + 1
		for groupEnd < len(intermediateSteps) &&
			intermediateSteps[groupEnd].Action.Log == step.Action.Log &&
			groupEnd-i < 10 {
			groupEnd++
		}

		for j := i; j < groupEnd; j++ {
			if j > i {
				stepIndex++
			}
			if isRecent {
				continue
			}
			s := &intermediateSteps[j]
			if s.CompressedObservation != "" {
				continue
			}
			obs := o.resolveToolResponse(*s)
			if len(obs) < minBytes {
				continue
			}
			wg.Add(1)
			go func(stepPtr *NBAgentPlannerToolActionStep, processedObs string) {
				defer wg.Done()
				defer func() {
					if r := recover(); r != nil {
						o.ctx.GetLogger().Error("scratchpad: prewarm summarization panic", "recover", r)
					}
				}()
				// Check if a sibling goroutine already failed before starting the LLM call.
				if prewarmCtx.Err() != nil {
					result := compressObservation(processedObs)
					stepPtr.CompressedObservation = result
					return
				}
				result := SummarizeObservation(wrappedCtx, stepPtr, o.request, processedObs)
				// If the result is byte-truncated (not an LLM summary), a sibling or this
				// goroutine hit an LLM error. Cancel remaining goroutines.
				if !strings.HasPrefix(result, "[summarized from") {
					failOnce.Do(func() {
						o.ctx.GetLogger().Info("scratchpad: LLM summarization failed, cancelling remaining prewarm goroutines")
						cancelPrewarm()
					})
				}
			}(s, obs)
		}
		i = groupEnd
	}
	wg.Wait()

	// If the prewarm context was cancelled (LLM failure), disable LLM
	// summarization for future iterations to avoid repeated retry overhead.
	if prewarmCtx.Err() != nil {
		o.llmSummarizationDisabled = true
		o.ctx.GetLogger().Info("scratchpad: disabling LLM summarization for remaining iterations (circuit breaker tripped)")
	}
}

// parseOutput delegates output parsing to the internal parser and applies
// optional custom response handlers from the agent.
func (o *NBReActPlanner3) parseOutput(contentResp *llms.ContentResponse, intermediateSteps []NBAgentPlannerToolActionStep) ([]NBAgentPlannerToolAction, *NBAgentPlannerFinishAction, error) {
	action, finish, err := o.parseOutputInternal(contentResp, intermediateSteps)
	if _, ok := o.nbAgent.(NBAgentExecutorLlmResponseHandler); ok {
		handler := o.nbAgent.(NBAgentExecutorLlmResponseHandler)
		actionCopy := make([]NBAgentPlannerToolAction, len(action))
		copy(actionCopy, action)
		actionCopy, finish, err = handler.UpdateExecutorLlmResponse(actionCopy, finish, err)
		if actionCopy != nil {
			action = make([]NBAgentPlannerToolAction, len(actionCopy))
			copy(action, actionCopy)
		}
		if action == nil && finish == nil && err == nil {
			return nil, nil, ErrParseFailure
		}
	}
	return action, finish, err
}

// parseOutputInternal parses the raw LLM string into structured actions,
// final answers, or clarifications. Supports both singular <action> and
// plural <actions> for parallel execution.
//
// Parsing strategy (multi-stage for robustness against malformed LLM XML):
//  1. Try direct extraction on raw output (handles well-formed XML + CDATA)
//  2. If direct extraction fails, sanitize the output (close truncated tags,
//     fix common typos) and retry extraction
//  3. If still failing, escape bare ampersands and retry
//  4. If still failing, attempt to fix mismatched closing tags and retry
//  5. Diagnose failure and return actionable error for retry prompt
func (o *NBReActPlanner3) parseOutputInternal(contentResp *llms.ContentResponse, intermediateSteps []NBAgentPlannerToolActionStep) ([]NBAgentPlannerToolAction, *NBAgentPlannerFinishAction, error) {
	if len(contentResp.Choices) == 0 || contentResp.Choices[0].Content == "" {
		return nil, nil, ErrParseFailure
	}

	output := contentResp.Choices[0].Content

	// Update notebook if tag is present. Pass the current turn index so
	// we can emit per-turn telemetry (updated vs stale) for visibility.
	o.processNotebookUpdate(output, len(intermediateSteps))

	// Stage 1: Try direct extraction on raw output.
	if actions, finish := o.tryExtractResponse(output); actions != nil || finish != nil {
		return actions, finish, nil
	}

	// Stage 2: Sanitize truncated XML (close unclosed tags, fix typos) and retry.
	sanitized := common.XmlSanitize(output, "thought_action")
	if sanitized != output {
		if actions, finish := o.tryExtractResponse(sanitized); actions != nil || finish != nil {
			return actions, finish, nil
		}
	}
	// Also try sanitizing as final_answer root
	if strings.Contains(output, "<final_answer>") {
		sanitizedFA := common.XmlSanitize(output, "final_answer")
		if sanitizedFA != output && sanitizedFA != sanitized {
			if actions, finish := o.tryExtractResponse(sanitizedFA); actions != nil || finish != nil {
				return actions, finish, nil
			}
		}
	}

	// Stage 3: Escape bare ampersands (common LLM issue: "foo & bar") and retry.
	escaped := common.XmlEscapeAmpersands(output)
	if escaped != output {
		if actions, finish := o.tryExtractResponse(escaped); actions != nil || finish != nil {
			return actions, finish, nil
		}
	}

	// Stage 4: Fix mismatched closing tags using diagnostic info and retry.
	reason := o.diagnoseParseFailure(output)
	if strings.Contains(reason, "missing closing") {
		// Attempt to identify and fix the mismatch via common patterns
		fixed, didFix := common.XmlFixMismatchedTag(output, "element <thought_action> closed by </thought>")
		if !didFix {
			fixed, didFix = common.XmlFixMismatchedTag(output, "element <action> closed by </tool_input>")
		}
		if didFix {
			if actions, finish := o.tryExtractResponse(fixed); actions != nil || finish != nil {
				return actions, finish, nil
			}
		}
	}

	// All stages failed — return diagnostic error for retry prompt
	if reason != "" {
		return nil, nil, fmt.Errorf("%w: %s", ErrParseFailure, reason)
	}
	return nil, nil, ErrParseFailure
}

// tryExtractResponse attempts to extract actions or final answers from the output.
// Returns (nil, nil) if nothing could be extracted.
func (o *NBReActPlanner3) tryExtractResponse(output string) ([]NBAgentPlannerToolAction, *NBAgentPlannerFinishAction) {
	if actions := o.processToolActions(output); actions != nil {
		return actions, nil
	}
	if action := o.processToolAction(output); action != nil {
		return action, nil
	}
	if finish := o.processFinalAnswer(output); finish != nil {
		return nil, finish
	}
	if clarification := o.processClarification(output); clarification != nil {
		return nil, clarification
	}
	return nil, nil
}

// notebookStats captures shape/discipline signals for an update_notebook
// block. Used for telemetry only — never affects planner behaviour.
type notebookStats struct {
	ContentLength  int
	LineCount      int
	HasPlanSection bool
	HasFindings    bool
	DoneCount      int
	DoingCount     int
	NextCount      int
	TodoCount      int
	BlockedCount   int
	SkipCount      int
	TotalMarkers   int
}

// analyzeNotebook extracts lightweight discipline signals from a notebook
// blob. Counts are cheap substring scans — we intentionally do not parse
// markdown structure.
func analyzeNotebook(content string) notebookStats {
	lower := strings.ToLower(content)
	s := notebookStats{
		ContentLength:  len(content),
		LineCount:      strings.Count(content, "\n") + 1,
		HasPlanSection: strings.Contains(lower, "investigation plan") || strings.Contains(lower, "## plan"),
		HasFindings:    strings.Contains(lower, "key findings") || strings.Contains(lower, "## findings"),
		DoneCount:      strings.Count(content, "[DONE]"),
		DoingCount:     strings.Count(content, "[DOING]"),
		NextCount:      strings.Count(content, "[NEXT]"),
		TodoCount:      strings.Count(content, "[ ]"),
		BlockedCount:   strings.Count(content, "[BLOCKED]"),
		SkipCount:      strings.Count(content, "[SKIP]"),
	}
	s.TotalMarkers = s.DoneCount + s.DoingCount + s.NextCount + s.TodoCount + s.BlockedCount + s.SkipCount
	return s
}

// processNotebookUpdate extracts content for notebook update and emits
// per-turn telemetry so operators can validate whether the agent is
// actually exercising the notebook discipline prescribed by the prompt.
//
// turnIdx is the 0-based index of the current planning turn (i.e. the
// number of intermediate steps already completed). This lets us detect
// stale notebooks — turns where the LLM made a tool call but did not
// emit an <update_notebook> block even though the investigation is
// ongoing.
func (o *NBReActPlanner3) processNotebookUpdate(output string, turnIdx int) {
	// Notebook is opt-out for time-constrained imperative agents. When
	// disabled, skip both content capture AND staleness telemetry — capturing
	// hallucinated <update_notebook> emissions would surface them again via
	// the {{if .notebook}} block in the human prompt, defeating opt-out.
	if !o.notebookSectionEnabled() {
		return
	}
	logger := func() *slog.Logger {
		if o.ctx != nil && o.ctx.GetLogger() != nil {
			return o.ctx.GetLogger()
		}
		return nil
	}()

	agentName := ""
	if o.nbAgent != nil {
		agentName = o.nbAgent.GetName()
	}

	hasTag := strings.Contains(output, "<update_notebook>")
	var notebookContent string
	if hasTag {
		notebookContent = common.XmlExtractTagContent(output, "update_notebook")
	}

	// Fallback: the LLM may mistakenly emit the notebook update as a tool
	// call (<tool_name>update_notebook</tool_name><tool_input>...</tool_input>)
	// instead of the inline <update_notebook> tag. Detect this pattern and
	// extract the content from tool_input so it is still processed.
	if !hasTag || notebookContent == "" {
		toolNameTag := common.XmlExtractTagContent(output, "tool_name")
		if isNotebookToolName(toolNameTag) {
			candidate := common.XmlExtractTagContent(output, "tool_input")
			if candidate != "" {
				notebookContent = candidate
				hasTag = true
			}
		}
	}

	if hasTag && notebookContent != "" {
		o.Notebook = notebookContent
		o.notebookUpdateCount++
		if o.notebookUpdateCount == 1 {
			o.notebookFirstUpdateTurn = turnIdx
		}
		o.notebookLastUpdateTurn = turnIdx

		stats := analyzeNotebook(notebookContent)

		// Persist the notebook to the DB so the UI can render it as
		// a dedicated agent entry that evolves each turn — same
		// pattern as rewoo's plan record.
		o.persistNotebook(notebookContent, turnIdx, stats)

		if logger != nil {
			logger.Info("reactagent3: notebook updated",
				"agent", agentName,
				"turn_idx", turnIdx,
				"update_count", o.notebookUpdateCount,
				"first_update_turn", o.notebookFirstUpdateTurn,
				"content_length", stats.ContentLength,
				"line_count", stats.LineCount,
				"has_plan_section", stats.HasPlanSection,
				"has_findings_section", stats.HasFindings,
				"marker_done", stats.DoneCount,
				"marker_doing", stats.DoingCount,
				"marker_next", stats.NextCount,
				"marker_todo", stats.TodoCount,
				"marker_blocked", stats.BlockedCount,
				"marker_skip", stats.SkipCount,
				"marker_total", stats.TotalMarkers,
			)
		}
		return
	}

	// Stale tracking only applies to top-level agents — sub-agents
	// (postgres, gcp, etc.) don't receive notebook instructions in
	// their system prompt, so warning about them is just noise.
	if !o.isTopLevelAgent() {
		return
	}

	stale, turnsSinceUpdate := o.notebookStaleInfo(turnIdx)
	if logger != nil {
		logger.Debug("reactagent3: notebook not updated this turn",
			"agent", agentName,
			"turn_idx", turnIdx,
			"update_count", o.notebookUpdateCount,
			"turns_since_update", turnsSinceUpdate,
		)
		if stale {
			o.notebookStaleWarningsLog++
			logger.Info("reactagent3: notebook stale",
				"agent", agentName,
				"turn_idx", turnIdx,
				"update_count", o.notebookUpdateCount,
				"turns_since_update", turnsSinceUpdate,
				"stale_warnings_total", o.notebookStaleWarningsLog,
			)
		}
	}
}

// captureNotebookFromToolCall handles the case where the LLM emitted
// notebook content as a tool call instead of the inline <update_notebook>
// tag. processNotebookUpdate may have already captured the content when
// it was the first <tool_name> in the output; this method ensures it is
// also captured when it appears as a non-first action in a parallel block
// or when processNotebookUpdate's fallback missed it for any reason.
// No-op if the content is empty or identical to the current notebook.
func (o *NBReActPlanner3) captureNotebookFromToolCall(content string) {
	if content == "" || content == o.Notebook {
		return
	}
	o.Notebook = content
	o.notebookUpdateCount++
	// Use last known turn + 1 as a best-effort turn index. The exact
	// value only affects telemetry and stale-notebook tracking, not
	// correctness.
	turnIdx := 0
	if o.notebookLastUpdateTurn >= 0 {
		turnIdx = o.notebookLastUpdateTurn + 1
	}
	if o.notebookUpdateCount == 1 {
		o.notebookFirstUpdateTurn = turnIdx
	}
	o.notebookLastUpdateTurn = turnIdx
	stats := analyzeNotebook(content)
	o.persistNotebook(content, turnIdx, stats)
}

// persistNotebook writes the current notebook content to
// llm_conversation_agent. On the first call it creates a fresh row
// (notebookDummyAgent) and stores its id in o.notebookAgentID. On
// subsequent calls it patches the same row so the UI always sees a
// single, evolving notebook entry per react_3 session.
//
// All errors are logged and swallowed — notebook persistence is a
// visibility feature and must never fail the planning loop.
func (o *NBReActPlanner3) persistNotebook(content string, turnIdx int, stats notebookStats) {
	if o.request.ConversationId == "" || o.request.MessageId == "" {
		// No conversation context — running outside a real session
		// (e.g. unit tests). Skip persistence silently.
		return
	}
	conversationDAO := GetConversationDao()
	if conversationDAO == nil {
		return
	}

	logger := func() *slog.Logger {
		if o.ctx != nil && o.ctx.GetLogger() != nil {
			return o.ctx.GetLogger()
		}
		return nil
	}()

	// response_summary doubles as a per-turn breadcrumb so operators
	// can see at a glance how many turns the notebook has covered
	// and what shape it's in (markers, sections). We intentionally
	// use response_summary (not thought) so the create and update
	// paths land the breadcrumb in the same column.
	breadcrumb := fmt.Sprintf(
		"turn=%d updates=%d done=%d doing=%d next=%d todo=%d blocked=%d skip=%d plan=%t findings=%t",
		turnIdx, o.notebookUpdateCount,
		stats.DoneCount, stats.DoingCount, stats.NextCount, stats.TodoCount,
		stats.BlockedCount, stats.SkipCount,
		stats.HasPlanSection, stats.HasFindings,
	)

	if o.notebookAgentID == "" {
		// Use SaveCompletedConversationAgentCall so the row is
		// created with status=Success immediately. Using the
		// InProgress-by-default SaveConversationAgentCall would
		// leave the record stuck in InProgress if the agent
		// finishes after a single notebook update.
		agentID, err := conversationDAO.SaveCompletedConversationAgentCall(
			uuid.Nil,
			o.request.ConversationId,
			o.request.MessageId,
			o.request.AccountId,
			o.request.UserId,
			notebookDummyAgent,
			o.request.ParentAgentId,
			o.request.Query,
			"", // thought column unused — breadcrumb lives in response_summary
			content,
			o.request.ConversationContext,
			o.request.QueryConfig,
			AgentExecutionStatusSuccess,
			breadcrumb,
		)
		if err != nil {
			if logger != nil {
				logger.Error("reactagent3: failed to save notebook agent record",
					"error", err.Error(), "turn_idx", turnIdx)
			}
			return
		}
		o.notebookAgentID = agentID.String()
		if logger != nil {
			logger.Info("reactagent3: notebook record created",
				"notebook_agent_id", o.notebookAgentID, "turn_idx", turnIdx)
		}
		return
	}

	// Subsequent updates: patch the same row.
	if err := conversationDAO.UpdateConversationAgentResponse(
		o.notebookAgentID,
		content,
		AgentExecutionStatusSuccess,
		"", breadcrumb, "", "",
	); err != nil {
		if logger != nil {
			logger.Error("reactagent3: failed to update notebook agent record",
				"error", err.Error(),
				"notebook_agent_id", o.notebookAgentID,
				"turn_idx", turnIdx)
		}
	}
}

// processToolActions extracts MULTIPLE tool actions from the <actions> (plural)
// block for parallel execution. Returns nil if no <actions> block found.
//
// Parsing strategy (multi-stage for robustness):
// 1. Extract <actions> block using XmlExtractTagContent (handles CDATA + entity unescape)
// 2. Use regex to find all <action>...</action> blocks inside
// 3. For each block, extract <tool_name> and <tool_input> with fallback logic
// 4. Apply macro substitution and input normalization
func (o *NBReActPlanner3) processToolActions(output string) []NBAgentPlannerToolAction {
	// Must have <actions> (plural) to trigger parallel parsing
	if !strings.Contains(output, "<actions>") {
		return nil
	}

	// Use raw extraction for the container tag — XmlExtractTagContent calls
	// XmlExtractCDATA which would strip nested CDATA from child <action> blocks,
	// destroying the structure when tool_input contains CDATA sections.
	actionsStart := strings.Index(output, "<actions>")
	actionsEnd := strings.LastIndex(output, "</actions>")
	if actionsStart == -1 || actionsEnd == -1 || actionsEnd <= actionsStart {
		return nil
	}
	actionsBlock := strings.TrimSpace(output[actionsStart+len("<actions>") : actionsEnd])
	if actionsBlock == "" {
		return nil
	}

	// Extract individual <action> blocks using regex (robust against malformed XML)
	actionMatches := reActActionBlockRegex.FindAllStringSubmatch(actionsBlock, -1)
	if len(actionMatches) < 2 {
		// Less than 2 actions — not worth parallel, fall through to singular parsing
		return nil
	}

	thought := common.XmlExtractTagContent(output, "thought")
	if thought == "" {
		thought = common.XmlExtractTagContent(output, "thought_action")
		if thought != "" {
			thought = stripNestedXMLTags(thought)
		}
	}
	if thought == "" {
		thought = "Executing multiple tools in parallel."
	}

	var actions []NBAgentPlannerToolAction
	for _, match := range actionMatches {
		actionContent := match[1]

		toolName := common.XmlExtractTagContent(actionContent, "tool_name")
		toolInput := ""

		if toolName == "" {
			// Fallback: LLM used tool name as tag directly
			for _, t := range o.tools {
				tName := t.Name()
				if strings.Contains(actionContent, "<"+tName+">") {
					toolName = tName
					toolInput = common.XmlExtractTagContent(actionContent, tName)
					break
				}
			}
		} else {
			toolInput = common.XmlExtractTagContent(actionContent, "tool_input")
		}

		if toolName == "" || isInvalidToolName(toolName) {
			continue // Skip malformed action blocks
		}

		// Notebook tool call: capture content and skip execution.
		// processNotebookUpdate may have missed this when update_notebook
		// wasn't the first <tool_name> in the parallel block.
		// When notebook is disabled for this agent, still skip the
		// hallucinated tool call (it isn't a real tool) but do not
		// capture the content — capture would re-inject it via the
		// {{if .notebook}} block in the human prompt, defeating opt-out.
		if isNotebookToolName(toolName) {
			if o.notebookSectionEnabled() {
				o.captureNotebookFromToolCall(toolInput)
			}
			continue
		}

		if toolInput == "" && (strings.EqualFold(toolName, o.summaryToolName) || strings.EqualFold(toolName, "llm")) {
			toolInput = o.request.Query
		}

		toolInput = common.SubstituteDateMacros(toolInput)
		toolInput = o.normalizeToolInput(toolName, toolInput)

		o.stepCount++
		actions = append(actions, NBAgentPlannerToolAction{
			Tool:      toolName,
			ToolInput: toolInput,
			Log:       thought, // All parallel actions share the same thought
			ToolID:    generateToolId(toolName, toolInput+fmt.Sprintf("_%d", len(actions))),
			DisplayID: fmt.Sprintf("E%d", o.stepCount),
		})
	}

	if len(actions) < 2 {
		// After filtering, if less than 2 valid actions remain, fall through to singular
		return nil
	}

	return actions
}

// processToolAction extracts a SINGLE tool action from the LLM output.
// Same logic as react_2 for backward compatibility.
func (o *NBReActPlanner3) processToolAction(output string) []NBAgentPlannerToolAction {
	if !strings.Contains(output, "<thought_action>") && !strings.Contains(output, "<tool_name>") && !strings.Contains(output, "<action>") {
		return nil
	}

	toolName := common.XmlExtractTagContent(output, "tool_name")
	toolInput := ""

	if toolName == "" {
		// Robust check: LLM used tool name as tag directly inside <action> or <thought_action>
		actionBlock := common.XmlExtractTagContent(output, "action")
		if actionBlock == "" {
			actionBlock = common.XmlExtractTagContent(output, "thought_action")
		}

		if actionBlock != "" {
			for _, t := range o.tools {
				tName := t.Name()
				if strings.Contains(actionBlock, "<"+tName+">") {
					toolName = tName
					toolInput = common.XmlExtractTagContent(actionBlock, tName)
					break
				}
			}
		}
	} else {
		toolInput = common.XmlExtractTagContent(output, "tool_input")
	}

	if toolName == "" {
		return nil
	}

	// Notebook tool call: capture content and skip execution.
	// See processToolActions for why capture is gated when notebook is
	// disabled for this agent.
	if isNotebookToolName(toolName) {
		if o.notebookSectionEnabled() {
			o.captureNotebookFromToolCall(toolInput)
		}
		return nil
	}

	if toolInput == "" && (strings.EqualFold(toolName, o.summaryToolName) || strings.EqualFold(toolName, "llm")) {
		toolInput = o.request.Query
	}

	toolInput = common.SubstituteDateMacros(toolInput)
	toolInput = o.normalizeToolInput(toolName, toolInput)

	thought := common.XmlExtractTagContent(output, "thought")
	if thought == "" {
		thought = common.XmlExtractTagContent(output, "thought_action")
		if thought != "" {
			thought = stripNestedXMLTags(thought)
		}
	}
	if thought == "" {
		thought = output
	}

	o.stepCount++
	return []NBAgentPlannerToolAction{
		{
			Tool:      toolName,
			ToolInput: toolInput,
			Log:       thought,
			ToolID:    generateToolId(toolName, toolInput),
			DisplayID: fmt.Sprintf("E%d", o.stepCount),
		},
	}
}

// normalizeToolInput delegates to the package-level normalizer so all planners
// share a single implementation. See normalizeToolInputForTool in executor_planner.go.
func (o *NBReActPlanner3) normalizeToolInput(toolName, input string) string {
	return normalizeToolInputByName(o.tools, toolName, input)
}

// processFinalAnswer extracts content and thought for a final answer.
func (o *NBReActPlanner3) processFinalAnswer(output string) *NBAgentPlannerFinishAction {
	if !strings.Contains(output, "<final_answer>") {
		return nil
	}
	content := common.XmlExtractTagContent(output, "content")
	thought := common.XmlExtractTagContent(output, "thought")

	if content == "" {
		content = common.XmlExtractTagContent(output, "final_answer")
		if content == "" {
			return nil
		}
	}

	return &NBAgentPlannerFinishAction{
		Data: content,
		Log:  thought,
	}
}

// processClarification handles clarification requests (kept for interface compatibility).
func (o *NBReActPlanner3) processClarification(_ string) *NBAgentPlannerFinishAction {
	return nil
}

// diagnoseParseFailure inspects the output to provide specific feedback on why parsing failed.
func (o *NBReActPlanner3) diagnoseParseFailure(output string) string {
	if strings.Contains(output, "<thought_action>") {
		if !strings.Contains(output, "</thought_action>") {
			return "missing closing </thought_action> tag"
		}
		if strings.Contains(output, "<actions>") {
			if !strings.Contains(output, "</actions>") {
				return "missing closing </actions> tag"
			}
			if !strings.Contains(output, "<action>") {
				return "missing <action> blocks inside <actions>"
			}
		} else {
			if !strings.Contains(output, "<tool_name>") {
				return "missing <tool_name> tag inside <thought_action>"
			}
			if !strings.Contains(output, "<tool_input>") {
				return "missing <tool_input> tag inside <thought_action>"
			}
			if !strings.Contains(output, "</tool_name>") {
				return "missing closing </tool_name> tag"
			}
			if !strings.Contains(output, "</tool_input>") {
				return "missing closing </tool_input> tag"
			}
		}
	} else if strings.Contains(output, "<final_answer>") {
		if !strings.Contains(output, "</final_answer>") {
			return "missing closing </final_answer> tag"
		}
		if !strings.Contains(output, "<content>") {
			return "missing <content> tag inside <final_answer>"
		}
	} else {
		return "no <thought_action> or <final_answer> block found"
	}
	return ""
}

// formatObservation builds the XML observation block for a step, including references.
// When the action has a DisplayID (e.g. "E1", "E2") it is included as a step= attribute
// so the LLM can reference it by that ID when producing its final answer.
func (o *NBReActPlanner3) formatObservation(step NBAgentPlannerToolActionStep, toolResponse string) string {
	id := step.Action.DisplayID
	if len(step.References) > 0 {
		var refsBuilder strings.Builder
		refsBuilder.WriteString("<references>\n")
		for _, ref := range step.References {
			fmt.Fprintf(&refsBuilder, "  <reference text=%q url=%q type=%q description=%q />\n", ref.Text, ref.Url, ref.Type, ref.Description)
		}
		refsBuilder.WriteString("</references>\n")
		if id != "" {
			return fmt.Sprintf(
				"<observation step=%q tool=%q>\n<![CDATA[%s]]>\n%s</observation>",
				id, step.Action.Tool, toolResponse, refsBuilder.String(),
			)
		}
		return fmt.Sprintf(
			"<observation tool=%q>\n<![CDATA[%s]]>\n%s</observation>",
			step.Action.Tool, toolResponse, refsBuilder.String(),
		)
	}
	if id != "" {
		return fmt.Sprintf(
			"<observation step=%q tool=%q>\n<![CDATA[%s]]>\n</observation>",
			id, step.Action.Tool, toolResponse,
		)
	}
	return fmt.Sprintf(
		"<observation tool=%q>\n<![CDATA[%s]]>\n</observation>",
		step.Action.Tool, toolResponse,
	)
}

// Plan executes the ReAct logic to determine the next action(s) or final answer.
// Supports both singular and parallel tool actions.
func (o *NBReActPlanner3) Plan(
	ctx context.Context,
	intermediateSteps []NBAgentPlannerToolActionStep,
	input string,
) ([]NBAgentPlannerToolAction, *NBAgentPlannerFinishAction, error) {
	// Initialise stepCount from the existing history on the very first call.
	// This handles resumed conversations where intermediateSteps already
	// contains prior tool invocations: new DisplayIDs will continue from
	// where the previous session left off rather than restarting at E1.
	if o.stepCount == 0 && len(intermediateSteps) > 0 {
		o.stepCount = len(intermediateSteps)
	}

	agentName := o.nbAgent.GetName()
	requestID := o.request.AgentId
	logger := o.ctx.GetLogger().With("agent", agentName, "agent_id", requestID)

	// Optimization: Check for direct summarization after a tool call.
	if len(intermediateSteps) > 0 && o.summaryToolName != "" {
		lastStep := intermediateSteps[len(intermediateSteps)-1]
		if lastStep.Status == ToolStatusSuccess && len(o.refinementData) == 0 && !strings.EqualFold(lastStep.Action.Tool, o.summaryToolName) {
			if summarizer, ok := o.nbAgent.(NBAgentDirectSummarization); ok {
				if summarizer.ShouldSummarizeNow(lastStep.Action.Tool, lastStep.Observation) {
					logger.Info("reactagent3: direct summarization triggered", "tool", lastStep.Action.Tool, "summary_tool", o.summaryToolName)
					o.stepCount++
					return []NBAgentPlannerToolAction{
						{
							Tool:      o.summaryToolName,
							ToolInput: input,
							Log:       fmt.Sprintf("The previous tool %s succeeded. Calling summary tool %s.", lastStep.Action.Tool, o.summaryToolName),
							ToolID:    generateToolId(o.summaryToolName, input),
							DisplayID: fmt.Sprintf("E%d", o.stepCount),
						},
					}, nil, nil
				}
			}
		}
	}

	o.refinementAttempts = 0
	o.refinementData = nil

	var lastErr error
	isSummaryToolUsed := len(intermediateSteps) > 0 && strings.EqualFold(intermediateSteps[len(intermediateSteps)-1].Action.Tool, o.summaryToolName)

	// Determine cache scope and create context once per plan
	cacheScope := CacheScopeConversation
	if cacheProvider, ok := o.nbAgent.(NBAgentCacheScopeProvider); ok {
		cacheScope = cacheProvider.GetCacheScope()
	}
	// ClientTools (Local Tools) vary per chat session and are injected into
	// the cacheable tool list + system instructions, so they cannot share an
	// Account-scope cache across sessions. Downgrade to Conversation scope.
	if len(o.request.ClientTools) > 0 && cacheScope != CacheScopeConversation {
		o.ctx.GetLogger().Debug("reactagent3: downgrading cache scope to conversation due to client tools",
			"agent", o.nbAgent.GetName(), "from", cacheScope)
		cacheScope = CacheScopeConversation
	}

	agentCtx := security.NewRequestContext(
		context.WithValue(
			context.WithValue(o.ctx.GetContext(), ContextKeyCacheScope, cacheScope),
			ContextKeyCapabilities, o.request.Capabilities,
		),
		o.ctx.GetSecurityContext(),
		o.ctx.GetLogger(),
		o.ctx.GetTracer(),
		o.ctx.GetMeter(),
	)

	const maxOuterIterations = 5
	reactPlanStart := time.Now()
	for range maxOuterIterations {
		var finish *NBAgentPlannerFinishAction
		scratchpadStart := time.Now()
		scratchpad := o.buildScratchpad(intermediateSteps)
		SaveCompressionVisibility(o.ctx, o.request, intermediateSteps, o.compressionTracker)
		logger.Info("reactagent3: scratchpad built", "duration", time.Since(scratchpadStart).String(), "length", len(scratchpad))

		if isSummaryToolUsed {
			finish = &NBAgentPlannerFinishAction{
				Data: intermediateSteps[len(intermediateSteps)-1].Observation,
			}
		} else {
			mcList := []llms.MessageContent{}
			if len(o.refinementData) > 0 {
				fullInputs := map[string]any{
					"input":      input,
					"scratchpad": "",
					"notebook":   o.Notebook,
				}
				prompt, err := o.prompt.FormatPrompt(fullInputs)
				if err != nil {
					return nil, nil, err
				}
				for _, msg := range prompt.Messages() {
					mcList = append(mcList, llms.MessageContent{
						Role:  msg.GetType(),
						Parts: []llms.ContentPart{llms.TextContent{Text: msg.GetContent()}},
					})
				}
				for _, rd := range o.refinementData {
					mcList = append(mcList, llms.MessageContent{
						Role:  llms.ChatMessageTypeAI,
						Parts: []llms.ContentPart{llms.TextContent{Text: rd.answer}},
					})
					// Refinement message: present the critique feedback without
					// offering a rewrite-vs-tool opt-out. The critique prompt is
					// responsible for being specific (and naming a tool when one
					// should be called); this message simply tells the LLM to
					// follow it. When no tool action is appropriate (format /
					// hallucination / no-execution-path), the critique feedback
					// itself will guide a rewrite.
					feedbackMsg := fmt.Sprintf("The previous final answer was: %s\nThe critique feedback was: %s\nFollow the critique feedback exactly. If it names a specific tool to call, you MUST issue that tool action now via <thought_action> and produce no <final_answer> this turn. Otherwise, refine the answer to address the feedback. Refer to previous conversation/messages for better context.", rd.answer, rd.feedback)
					mcList = append(mcList, llms.MessageContent{
						Role:  llms.ChatMessageTypeHuman,
						Parts: []llms.ContentPart{llms.TextContent{Text: feedbackMsg}},
					})
				}
				if scratchpad != "" {
					mcList = append(mcList, llms.MessageContent{
						Role:  llms.ChatMessageTypeHuman,
						Parts: []llms.ContentPart{llms.TextContent{Text: fmt.Sprintf("Here is the latest scratchpad for reference:\n\n%s", scratchpad)}},
					})
				}
			} else {
				fullInputs := map[string]any{
					"input":      input,
					"scratchpad": scratchpad,
					"notebook":   o.Notebook,
				}
				prompt, err := o.prompt.FormatPrompt(fullInputs)
				if err != nil {
					return nil, nil, err
				}
				for _, msg := range prompt.Messages() {
					mcList = append(mcList, llms.MessageContent{
						Role:  msg.GetType(),
						Parts: []llms.ContentPart{llms.TextContent{Text: msg.GetContent()}},
					})
				}
			}

			callOptions := []llms.CallOption{llms.WithTemperature(0.0)}
			provider := GetLLMProvider(o.ctx, o.request.AccountId, o.nbAgent.GetName(), true, o.request.ConversationId)
			model := GetLLMModelName(o.ctx, o.request.AccountId, provider, o.nbAgent.GetName(), true, o.request.ConversationId)

			if !IsOpenAIModelWithoutStopSupport(provider, model) {
				callOptions = append(callOptions, llms.WithStopWords([]string{"<observation>"}))
			}

			llmCallStart := time.Now()
			result, err := GenerateAndTrackLLMContent(agentCtx, o.request.UserId, o.request.AccountId, o.request.ConversationId, o.request.MessageId, o.request.AgentId, false, mcList, true, callOptions...)
			logger.Info("reactagent3: LLM call complete", "duration", time.Since(llmCallStart).String(), "total_plan_duration", time.Since(reactPlanStart).String())
			if err != nil {
				logger.Error("reactagent3: main llm call failed", "error", err)
				lastErr = err
				break
			}

			parseStart := time.Now()
			actions, finish1, err := o.parseOutput(result, intermediateSteps)
			logger.Info("reactagent3: output parsed", "duration", time.Since(parseStart).String(), "actionsCount", len(actions), "isParallel", len(actions) > 1)
			finish = finish1
			if err != nil {
				logger.Warn("reactagent3: failed to parse llm output", "error", err, "data", result.Choices[0].Content)
				if !errors.Is(err, ErrParseFailure) {
					lastErr = err
					break
				}
				// Retry with reformat prompts
				actions, finish, err = o.retryParsing(mcList, result, intermediateSteps, err)
				if err != nil {
					lastErr = err
					break
				}
			}

			if finish == nil {
				if len(actions) == 0 {
					logger.Warn("reactagent3: parsed no action and no final answer from LLM")
					lastErr = fmt.Errorf("no action or final answer parsed from LLM response")
					break
				}
				return actions, nil, nil
			}

			if o.refinementAttempts >= o.maxRefinementAttempts {
				logger.Warn("reactagent3: max refinement attempts reached, accepting current answer", "attempts", o.refinementAttempts)
				return nil, finish, nil
			}
		}

		// Critique the final answer
		if finish != nil {
			// Auto-critique only fires for top-level agents to avoid
			// slowing down sub-agents like kubectl, logs, etc.
			topLevel := o.isTopLevelAgent()
			isInvestigation := IsInvestigationRequestTask(o.request.Query)
			critiqueAllowed := o.enableCritique || (config.Config.LlmServerReActCritiqueEnabled && topLevel && isInvestigation)
			if agent, ok := o.nbAgent.(NBAgentReActPlannerCritiqueSupport); ok {
				critiqueAllowed = critiqueAllowed && agent.CritiqueEnabled()
			}

			if !critiqueAllowed {
				logger.Info("reactagent3: skipping critique", "enableCritique", o.enableCritique, "isTopLevel", topLevel, "isInvestigation", isInvestigation, "autoCritiqueEnabled", config.Config.LlmServerReActCritiqueEnabled)
				return nil, finish, nil
			}

			decision, feedback := o.runCritique(input, scratchpad, finish.Data, intermediateSteps, logger)
			if decision == "" || strings.EqualFold(decision, "accept") {
				logger.Info("reactagent3: critique accepted answer", "decision", decision)
				return nil, finish, nil
			}

			// Answer rejected — refine
			o.refinementAttempts++
			o.postRefinementToolIndex = len(intermediateSteps)
			logger.Info("reactagent3: answer rejected by critique, refining", "feedback", feedback, "attempt", o.refinementAttempts)
			o.refinementData = append(o.refinementData, struct {
				feedback string
				answer   string
			}{
				feedback: feedback,
				answer:   finish.Data,
			})
			isSummaryToolUsed = false
		}
	}

	// Fallback summarization if loop broke with error
	if lastErr != nil && len(intermediateSteps) > 0 {
		logger.Info("reactagent3: loop broken, attempting to summarize", "error", lastErr)
		scratchpad := o.buildScratchpad(intermediateSteps)
		SaveCompressionVisibility(o.ctx, o.request, intermediateSteps, o.compressionTracker)
		prompt := fmt.Sprintf(`<question>%s</question> \n\n%s`, input, scratchpad)

		summaryMessages := []llms.MessageContent{
			{
				Role:  llms.ChatMessageTypeSystem,
				Parts: []llms.ContentPart{llms.TextContent{Text: "You are an expert assistant. Synthesize the following technical data into a clear summary for the user."}},
			},
			{
				Role:  llms.ChatMessageTypeHuman,
				Parts: []llms.ContentPart{llms.TextContent{Text: prompt}},
			},
		}

		summaryResult, summaryErr := GenerateAndTrackLLMContent(o.ctx, o.request.UserId, o.request.AccountId, o.request.ConversationId, o.request.MessageId, o.request.ParentAgentId, false, summaryMessages, true, llms.WithTemperature(0.0), WithThinkingLevel(ThinkingLevelFastTask))
		if summaryErr != nil || len(summaryResult.Choices) == 0 {
			return nil, &NBAgentPlannerFinishAction{Data: scratchpad}, nil
		}
		return nil, &NBAgentPlannerFinishAction{Data: summaryResult.Choices[0].Content}, nil
	}

	if lastErr != nil && len(intermediateSteps) == 0 {
		logger.Error("reactagent3: agent failed without making any tool calls", "error", lastErr)
		// Surface a clear cause for known failure modes (429/quota, oversized
		// request, transient outage) by throwing a typed, HTTP-coded error —
		// the executor reports it as a failed turn with the clean message. Only
		// fall back to the generic message for unclassified errors so we never
		// leak raw provider internals.
		if classified := classifyUserFacingLLMError(lastErr); classified != nil {
			return nil, nil, classified
		}
		return nil, &NBAgentPlannerFinishAction{
			Data:   "I was unable to process your request due to an internal error. Please try again or rephrase your question.",
			Status: ConversationStatusFailed,
		}, nil
	}

	// If the loop exhausted without error and without a finish, this means
	// critique kept rejecting and we ran out of inner iterations. Attempt
	// fallback summarization if we have any steps.
	if lastErr == nil && len(intermediateSteps) > 0 {
		logger.Warn("reactagent3: inner loop exhausted without finish, attempting fallback summarization")
		scratchpad := o.buildScratchpad(intermediateSteps)
		SaveCompressionVisibility(o.ctx, o.request, intermediateSteps, o.compressionTracker)
		prompt := fmt.Sprintf(`<question>%s</question> \n\n%s`, input, scratchpad)
		summaryMessages := []llms.MessageContent{
			{
				Role:  llms.ChatMessageTypeSystem,
				Parts: []llms.ContentPart{llms.TextContent{Text: "You are an expert assistant. Synthesize the following technical data into a clear summary for the user."}},
			},
			{
				Role:  llms.ChatMessageTypeHuman,
				Parts: []llms.ContentPart{llms.TextContent{Text: prompt}},
			},
		}
		summaryResult, summaryErr := GenerateAndTrackLLMContent(o.ctx, o.request.UserId, o.request.AccountId, o.request.ConversationId, o.request.MessageId, o.request.ParentAgentId, false, summaryMessages, true, llms.WithTemperature(0.0), WithThinkingLevel(ThinkingLevelFastTask))
		if summaryErr == nil && len(summaryResult.Choices) > 0 {
			return nil, &NBAgentPlannerFinishAction{Data: summaryResult.Choices[0].Content}, nil
		}
		return nil, &NBAgentPlannerFinishAction{Data: scratchpad}, nil
	}

	return nil, nil, lastErr
}

// retryParsing attempts to recover from parse failures by asking the LLM to reformat.
func (o *NBReActPlanner3) retryParsing(
	mcList []llms.MessageContent,
	result *llms.ContentResponse,
	intermediateSteps []NBAgentPlannerToolActionStep,
	originalErr error,
) ([]NBAgentPlannerToolAction, *NBAgentPlannerFinishAction, error) {
	logger := o.ctx.GetLogger()
	retryPrompts := o.retryConfig.Prompts
	if len(retryPrompts) == 0 {
		return nil, nil, originalErr
	}

	priorContent := ""
	if len(result.Choices) > 0 {
		priorContent = result.Choices[0].Content
	}

	toolNames := reActPromptToolNames(o.tools)
	retryCount := 0
	for retryCount < o.retryConfig.MaxRetries {
		retryPrompt := retryPrompts[retryCount%len(retryPrompts)]

		var recoveryInstruction string
		if priorContent == "" {
			recoveryInstruction = "Your previous response was empty. You MUST provide a valid tool call or final answer now."
		} else {
			parseReason := ""
			if strings.Contains(originalErr.Error(), ": ") {
				parseReason = " Reason for failure: " + strings.SplitN(originalErr.Error(), ": ", 2)[1]
			}
			recoveryInstruction = fmt.Sprintf("Your previous response was: %q. It was malformed or incomplete.%s Please provide the FULL and CORRECTED XML response now.", priorContent, parseReason)
		}

		retryPromptRefined := fmt.Sprintf(`%s

%s

STRICT CONSTRAINTS:
1. RESPONSE FORMAT: You MUST use <thought_action> block for tool calls or <final_answer> block for the final result.
2. TOOL CALL STRUCTURE: For a single tool: <thought_action><thought>...</thought><action><tool_name>...</tool_name><tool_input>...</tool_input></action></thought_action>. For parallel tools: <thought_action><thought>...</thought><actions><action>...</action><action>...</action></actions></thought_action>
3. AVAILABLE TOOLS: You are strictly limited to these tools: [%s].
4. NO HALLUCINATIONS: Do not use tools like 'list_tools'. They do not exist.
5. ONE TURN: Provide exactly one XML block.`, retryPrompt, recoveryInstruction, toolNames)

		retryMessages := make([]llms.MessageContent, len(mcList))
		copy(retryMessages, mcList)

		if priorContent != "" {
			retryMessages = append(retryMessages, llms.MessageContent{
				Role:  llms.ChatMessageTypeAI,
				Parts: []llms.ContentPart{llms.TextContent{Text: priorContent}},
			})
		}

		retryMessages = append(retryMessages, llms.MessageContent{
			Role:  llms.ChatMessageTypeHuman,
			Parts: []llms.ContentPart{llms.TextContent{Text: retryPromptRefined}},
		})

		retryResult, retryErr := GenerateAndTrackLLMContent(o.ctx, o.request.UserId, o.request.AccountId, o.request.ConversationId, o.request.MessageId, o.request.ParentAgentId, false, retryMessages, true, llms.WithTemperature(0.0))
		if retryErr != nil {
			logger.Warn("reactagent3: retry llm call failed", "error", retryErr, "attempt", retryCount+1)
			retryCount++
			time.Sleep(time.Duration(retryCount) * time.Second)
			continue
		}

		if len(retryResult.Choices) == 0 || retryResult.Choices[0].Content == "" {
			logger.Warn("reactagent3: received empty response on retry, breaking early", "attempt", retryCount+1)
			return nil, nil, ErrParseFailure
		}

		actions, finish, err := o.parseOutput(retryResult, intermediateSteps)
		if err == nil {
			return actions, finish, nil
		}
		logger.Info("reactagent3: retry parsing failed", "attempt", retryCount+1, "error", err)
		retryCount++
		time.Sleep(time.Duration(retryCount) * time.Second)
	}
	return nil, nil, originalErr
}

// runCritique runs the critique LLM to evaluate the final answer quality.
// Returns (decision, feedback). Empty decision means critique failed — accept the answer.
func (o *NBReActPlanner3) runCritique(input, scratchpad, finalAnswer string, intermediateSteps []NBAgentPlannerToolActionStep, logger interface {
	Info(msg string, args ...any)
	Warn(msg string, args ...any)
	Error(msg string, args ...any)
	Debug(msg string, args ...any)
}) (string, string) {
	critiquePrompt := prompts.NewPromptTemplate(
		prompts_repo.GetPrompt(prompts_repo.PromptPlannerReactCritiquer),
		[]string{"input", "scratchpad", "final_answer", "question_type", "tool_names", "tool_descriptions", "shell_tool_enabled", "tools_invoked"},
	)
	critiquePromptStr, promptErr := critiquePrompt.Format(map[string]any{
		"input":              input,
		"scratchpad":         scratchpad,
		"final_answer":       finalAnswer,
		"today":              time.Now().Format(time.RFC1123),
		"notebook":           o.Notebook,
		"question_type":      lo.Ternary(IsInvestigationRequestTask(o.request.Query), "investigation", "query"),
		"tool_names":         reActPromptToolNames(o.tools),
		"tool_descriptions":  reActPromptToolDescriptions(o.tools),
		"shell_tool_enabled": config.Config.LlmServerShellToolEnabled && HasShellTool(o.tools),
		"tools_invoked":      extractToolsInvoked(intermediateSteps),
	})
	if promptErr != nil {
		logger.Error("reactagent3: failed to format critique prompt, accepting answer", "error", promptErr)
		return "", ""
	}

	// Critique is a reasoning-heavy sub-operation; tag the context so it
	// resolves the Reasoning-tier model regardless of the host agent's category.
	critiqueCtx := security.NewRequestContext(
		context.WithValue(o.ctx.GetContext(), ContextKeyModelTier, ModelTierReasoning),
		o.ctx.GetSecurityContext(),
		o.ctx.GetLogger(),
		o.ctx.GetTracer(),
		o.ctx.GetMeter(),
	)

	critiqueMessages := []llms.MessageContent{{Role: llms.ChatMessageTypeHuman, Parts: []llms.ContentPart{llms.TextContent{Text: critiquePromptStr}}}}
	critiqueResult, critiqueErr := GenerateAndTrackLLMContent(critiqueCtx, o.request.UserId, o.request.AccountId, o.request.ConversationId, o.request.MessageId, o.request.AgentId, false, critiqueMessages, true, llms.WithTemperature(0.0))

	if critiqueErr != nil {
		logger.Error("reactagent3: critique llm call failed, accepting answer", "error", critiqueErr)
		return "", ""
	}

	critiqueContentEmpty := len(critiqueResult.Choices) == 0 || strings.TrimSpace(critiqueResult.Choices[0].Content) == ""
	if critiqueContentEmpty {
		logger.Warn("reactagent3: critique response empty, accepting answer")
		return "", ""
	}

	decision := common.XmlExtractTagContent(critiqueResult.Choices[0].Content, "decision")

	// Retry if decision is empty
	if decision == "" {
		instruction := "The previous critique response was empty or malformed. Please reply with XML containing exactly a <decision> tag with value 'accept' or 'refine', and a <feedback> tag with concise feedback."
		for retryCount := 0; retryCount < o.retryConfig.MaxRetries; retryCount++ {
			retryMessages := []llms.MessageContent{
				{Role: llms.ChatMessageTypeSystem, Parts: []llms.ContentPart{llms.TextContent{Text: instruction}}},
				{Role: llms.ChatMessageTypeHuman, Parts: []llms.ContentPart{llms.TextContent{Text: critiquePromptStr}}},
			}
			retryCritique, retryErr := GenerateAndTrackLLMContent(critiqueCtx, o.request.UserId, o.request.AccountId, o.request.ConversationId, o.request.MessageId, o.request.AgentId, false, retryMessages, true, llms.WithTemperature(0.0))
			if retryErr != nil {
				continue
			}
			if len(retryCritique.Choices) > 0 {
				if d := common.XmlExtractTagContent(retryCritique.Choices[0].Content, "decision"); d != "" {
					critiqueResult = retryCritique
					decision = d
					break
				}
			}
		}
	}

	if decision == "" {
		logger.Error("reactagent3: critique failed after retries, accepting answer")
		return "", ""
	}

	feedback := common.XmlExtractTagContent(critiqueResult.Choices[0].Content, "feedback")

	err := o.saveCritique("react_answer", input, finalAnswer, critiqueResult.Choices[0].Content, decision)
	if err != nil {
		logger.Error("reactagent3: failed to save critique", "error", err)
	}

	// When the critique rejects the answer, persist it as a dummy tool call so
	// the UI can surface it in the agent's Tool Details drawer — giving the end
	// user visibility that the system is refining its answer.
	if !strings.EqualFold(decision, "accept") && feedback != "" {
		if toolCallErr := o.saveCritiqueAsToolCall(feedback); toolCallErr != nil {
			logger.Error("reactagent3: failed to save critique as tool call", "error", toolCallErr)
		}
	}

	return decision, feedback
}

// saveCritiqueAsToolCall writes a synthetic tool-call record so that the
// critique feedback surfaces in the UI's Tool Details drawer. Errors are
// swallowed — this is a visibility feature and must never fail the planner.
func (o *NBReActPlanner3) saveCritiqueAsToolCall(feedback string) error {
	if o.request.AgentId == "" || o.request.ConversationId == "" {
		return nil
	}
	conversationDAO := GetConversationDao()
	toolId := uuid.New().String()
	return conversationDAO.SaveConversationToolCall(
		o.request.ConversationId,
		o.request.AccountId,
		o.request.UserId,
		o.request.MessageId,
		o.request.AgentId,
		toolId,
		"react_critique",
		"Answer rejected by critique — refining for better quality",   // title shown on the UI card
		"Critiquing the final answer to ensure quality and accuracy.", // thought shown in Tool Details drawer
		"", // no SQL
		feedback,
		toolcore.NBToolResponseStatusSuccess,
		toolcore.NBToolTypeTool,
		nil,
		nil,
	)
}

// reActCreatePrompt3 builds the chat prompt template for the react_3 planner.
func reActCreatePrompt3(ctx *security.RequestContext, agentPrompt string, toolsIn []toolcore.NBTool, conversationContext string, previousMessages []prompts.MessageFormatter, request NBAgentRequest, agent NBAgent) (prompts.ChatPromptTemplate, []toolcore.NBTool) {
	tools := make([]toolcore.NBTool, len(toolsIn))
	copy(tools, toolsIn)

	reactBasePrompt := nbprompts.GetPrompt(ctx.GetContext(), nbprompts.PromptReact3Base, request.AccountId)
	if reactBasePrompt == "" {
		reactBasePrompt = prompts_repo.GetPrompt(prompts_repo.PromptPlannerReactBase3)
	}

	// Only declare template variables actually referenced in planner_react_3_base.txt.
	// Dynamic vars (history, conversation_context, input, scratchpad) are in the human
	// message to keep the system prefix stable for caching.
	// today is in the human message (not here) so the cached prefix survives date rollover.
	messageFormatters := []prompts.MessageFormatter{
		prompts.NewSystemMessagePromptTemplate(
			reactBasePrompt,
			[]string{
				"tool_names",
				"tool_descriptions",
				"workspace_enabled",
				"shell_tool_enabled",
				"delegate_agent_enabled",
				"notebook_enabled",
				"conversation_context_enabled",
				"context_management_rules",
				"time_handling_rules",
				"data_protection_rules",
				"code_analysis_rules",
				"security_rules",
			},
		),
	}

	if len(request.ClientTools) > 0 {
		var clientTools []toolcore.NBTool
		clientToolNames := []string{}
		for _, ct := range request.ClientTools {
			clientTools = append(clientTools, toolcore.NewClientToolWrapper(ct))
			clientToolNames = append(clientToolNames, fmt.Sprintf("'%s'", ct.Name))
		}
		tools = append(clientTools, tools...)

		priorityInstruction := fmt.Sprintf(
			`IMPORTANT: The user has provided "Local Tools" which execute directly on their machine: %s.
			If the user's request involves their local environment (files, shell, local processes), you MUST prioritize these Local Tools over server-side counterparts.`,
			strings.Join(clientToolNames, ", "))

		messageFormatters = append(messageFormatters, LiteralSystemMessage{Content: priorityInstruction})
	}

	// AccountPrompt is intentionally NOT added as a system message: it is only
	// populated by the event-analysis path, so injecting it here would alternate
	// the cacheable prefix per entry-point and bust the Account-scope cache.
	// It is rendered into the human-message <global_preferences> block below.

	agentAdditionalPrompt, configuredTools, _ := AgentAdditionalInstructionsAndToolsAndConfigs(ctx, request.AccountId, agent.GetName())
	if agentAdditionalPrompt != "" {
		additionalAdditionalPrompt := fmt.Sprintf(`
		<additional_agent_prompt>
		%s
		</additional_agent_prompt>`, agentAdditionalPrompt)
		messageFormatters = append(messageFormatters, LiteralSystemMessage{Content: additionalAdditionalPrompt})
	}

	if len(configuredTools) > 0 {
		for _, ct := range configuredTools {
			if t, ok := toolcore.GetNBTool(request.AccountId, ct); ok {
				tools = append(tools, t)
			} else {
				ctx.GetLogger().Warn("reactagent3: configured tool not found, skipping", "tool", ct)
			}
		}
	}

	// SkillListsMenu is appended to the detection string so load_skills is still
	// injected when the menu lives in the human message (KB pre-step path)
	// rather than the system prompt (legacy path).
	tools = FilterAndInjectDefaultTools(request.AccountId, agent, agentPrompt+request.SkillListsMenu, tools, request.Capabilities)

	// Agent prompt as system message so it falls within the cacheable prefix
	// for Global/Account cache scopes. Dynamic parts (history, context, input, scratchpad) stay as human message.
	// Use LiteralSystemMessage: agentPrompt is already rendered by GetPromptTemplate.Format()
	// in executor.go and must not pass through Go's text/template a second time — literal
	// `{{ ... }}` content (e.g. WorkflowAgent's `{{ Configs.key_name }}`) would be re-parsed
	// as a template action and fail. Skip empty/whitespace to keep Bedrock Converse happy.
	if strings.TrimSpace(agentPrompt) != "" {
		messageFormatters = append(messageFormatters, LiteralSystemMessage{Content: agentPrompt})
	}

	// Move all dynamic context to the final Human message so the system prefix is stable.
	// today is placed here (not in the system message) so the cached system prefix
	// does not expire on date rollover.
	// global_preferences_block carries AccountPrompt (event-analysis path only) so
	// the cacheable system prefix does not flip between entry points.
	dynamicPrompt := `
**TODAY's Date:** {{.today}}
{{.kb_prestep_content}}
{{.skill_lists_menu}}
{{.global_preferences_block}}
<task_context>
**Previous Conversation Context:** {{.conversation_context}}
**Previous Messages (History):**
{{.history}}
</task_context>

{{if .notebook}}<notebook_content>
{{.notebook}}
</notebook_content>
{{end}}
<question>{{.input}}</question>

{{.scratchpad}}`

	messageFormatters = append(messageFormatters, prompts.NewHumanMessagePromptTemplate(dynamicPrompt, []string{
		"today",
		"conversation_context",
		"history",
		"input",
		"scratchpad",
		"notebook",
		"global_preferences_block",
		"kb_prestep_content",
		"skill_lists_menu",
	}))

	tools = FilterTools(tools, request.Capabilities)

	tmpl := prompts.NewChatPromptTemplate(messageFormatters)

	previousMessageStr := messageFormatterToString(previousMessages)
	tmpl.PartialVariables = map[string]any{
		// System message template vars (stable — cached across conversations)
		"tool_names":                   reActPromptToolNames(tools),
		"tool_descriptions":            reActPromptToolDescriptions(tools),
		"workspace_enabled":            config.Config.LlmServerWorkspaceEnabled,
		"shell_tool_enabled":           config.Config.LlmServerShellToolEnabled && HasShellTool(tools),
		"delegate_agent_enabled":       HasDelegateAgentTool(tools),
		"notebook_enabled":             ResolveAgentNotebookEnabled(agent),
		"conversation_context_enabled": config.Config.ConversationContextEnabled,
		"context_management_rules":     prompts_repo.GetPrompt(prompts_repo.PromptContextContinuity),
		"time_handling_rules":          prompts_repo.GetPrompt(prompts_repo.PromptSharedTimeHandlingRules),
		"data_protection_rules":        prompts_repo.GetPrompt(prompts_repo.PromptSharedDataProtectionRules),
		"code_analysis_rules":          prompts_repo.GetPrompt(prompts_repo.PromptSharedCodeAnalysisRules),
		"security_rules":               prompts_repo.GetPrompt(prompts_repo.PromptSharedSecurityRules),
		// Human message template vars (dynamic — change per conversation/iteration)
		"today":                    time.Now().Format("January 02, 2006"),
		"history":                  previousMessageStr,
		"conversation_context":     conversationContext,
		"scratchpad":               "", // default; overridden per-iteration in fullInputs
		"global_preferences_block": renderGlobalPreferencesBlock(request.AccountPrompt),
		// KB pre-step output — empty on the legacy path; populated into the human
		// message (above the scratchpad, so compression never drops it) when the
		// KB pre-step is enabled.
		"kb_prestep_content": request.KBPrestepContent,
		"skill_lists_menu":   request.SkillListsMenu,
	}
	return tmpl, tools
}

// NewReActAgent3 initializes a new instance of the react_3 planner.
func NewReActAgent3(ctx *security.RequestContext, request NBAgentRequest, nbAgent NBAgent, systemMessage string, extraMessages []prompts.MessageFormatter, initialNotebook string) (*NBReActPlanner3, error) {
	model, err := GetLlmModel(ctx, nbAgent.GetName(), request.AccountId, request.ConversationId)
	if err != nil {
		return nil, err
	}
	summaryToolName := ""
	if tool, ok := nbAgent.(NBAgentReActPlannerSummaryToolProvider); ok {
		summaryToolName = tool.GetSummaryToolName()
	}

	retryConfig := RetryConfig{
		MaxRetries: 2,
		Prompts: []string{
			"Your response could not be parsed. You MUST respond with ONLY a valid XML block. Use <thought_action> for tool calls (single <action> or parallel <actions>) or <final_answer> for the result. Do NOT write anything outside XML tags.",
			"Your previous response was still not in the correct format. YOU MUST output ONLY XML. Pick a tool from your available tools and call it, or provide a final answer. Example:\n<thought_action>\n<thought>I need to check status</thought>\n<action>\n    <tool_name>kubectl_execute</tool_name>\n    <tool_input>kubectl get deploy -n default</tool_input>\n</action>\n</thought_action>\n\nDo NOT apologize or explain. Just output the XML.",
		},
	}

	prompt, tools := reActCreatePrompt3(ctx, systemMessage, nbAgent.GetSupportedTools(ctx), request.ConversationContext, extraMessages, request, nbAgent)

	return &NBReActPlanner3{
		ctx:                   ctx,
		llm:                   model,
		prompt:                prompt,
		request:               request,
		nbAgent:               nbAgent,
		summaryToolName:       summaryToolName,
		retryConfig:           retryConfig,
		refinementAttempts:    0,
		maxRefinementAttempts: 2,
		tools:                 tools,
		enableCritique:        request.EnableCritique,
		Notebook:              initialNotebook,
		// -1 sentinels mean "no notebook update yet observed".
		notebookLastUpdateTurn:  -1,
		notebookFirstUpdateTurn: -1,
		compressionTracker:      NewCompressionTracker(),
	}, nil
}
