// Package component — Agent (T1).
//
// Multi-turn ReAct agent powered by eino's flow/agent/react package.
// Uses the RAGFlow model layer (models.EinoChatModel) as a
// ToolCallingChatModel, delegating the ReAct loop to eino's
// production-grade implementation.
//
// Public outputs (content / tool_calls / artifacts) match the
// plan-specified shape. The agent now wires AgentParam.Tools into
// eino's native react.AgentConfig.ToolsConfig; when no tools are
// configured the ReAct loop naturally degenerates to one model call.
package component

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"ragflow/internal/dao"
	"sort"
	"strings"
	"time"

	einotool "github.com/cloudwego/eino/components/tool"
	"github.com/cloudwego/eino/compose"
	"github.com/cloudwego/eino/flow/agent/react"
	"github.com/cloudwego/eino/schema"
	"gorm.io/gorm"

	"ragflow/internal/agent/component/prompts"
	"ragflow/internal/agent/runtime"
	agenttool "ragflow/internal/agent/tool"
	"ragflow/internal/common"
	"ragflow/internal/entity/models"

	"go.uber.org/zap"
)

const maxSubAgentDepth = 8
const defaultAgentDeferredTimeout = 10 * time.Minute

// agentPatternless matches `<model>@<provider>` and
// `<model>@<instance>@<provider>` (the trailing `@<provider>` is
// always the last segment — the segment just before the last `@`
// is treated as the bare model name for upstream API calls). The
// browser component has the same idea at browser.go:88-92, but
// keeps the regex greedy for its 2-part fixture; we keep both
// behaviors here via the in-function split below.

// agentProviderLastSegmentSplit takes a composite llm_id and
// returns (bareModelName, providerName, true) — or ("", "", false)
// when no `@<provider>` suffix exists. The bare model name is
// always `parts[0]` (the FIRST `@`-delimited segment); the
// provider is `parts[1]` for the 2-part shape and `parts[2]` for
// the 3+ shape. Any middle `@<seg>` segments (the "instance" in
// Python's split_model_name) are intentionally dropped — the Go
// drivers and the tenant_llm lookup both key on the bare model
// name + factory, not on the instance.
//
// Mirrors Python's split_model_name at
// api/db/joint_services/tenant_model_service.py:163-178:
//   - "model"                     → ("model", "",       false)
//   - "model@provider"            → ("model", "provider", true)
//   - "model@instance@provider"   → ("model", "provider", true)
//   - 4+ parts                    → ("parts[0]", "parts[2]", true) —
//     the trailing segment wins, anything between instance and
//     provider is dropped (Python uses parts[2] unconditionally).
func agentProviderLastSegmentSplit(s string) (modelName, providerName string, hasProvider bool) {
	return splitCompositeLLMID(s)
}

// AgentComponent is a multi-turn ReAct agent.
type AgentComponent struct {
	param AgentParam
}

// AgentParam captures the (resolved) DSL parameters for an Agent node.
type AgentParam struct {
	ModelID                  string
	Description              string
	SystemPrompt             string
	UserPrompt               string
	Thinking                 string
	TopP                     *float64
	Tools                    []string                  // Agent-visible tool names resolved into Eino BaseTool instances
	ToolParams               map[string]map[string]any // node-level tool constructor params keyed by tool name
	SubAgents                []SubAgentTool
	MaxRounds                int
	MessageHistoryWindowSize int // number of prior conversation turns to include; zero disables history
	OptimizeMultiTurn        bool
	OptimizeHistoryWindow    int
	// Meta is the OpenAI-style function-call schema the Agent exposes
	// when it is itself called as a tool by a parent component. Mirrors
	// Python's `meta: ToolMeta` field — describes the Agent's own
	// inputs (user_prompt / reasoning / context) for callers.
	Meta AgentMeta
	// Cite enables post-stream citation grounding. When true,
	// the Agent reads the chunks recorded in
	// state.Retrieval["chunks"] (populated by the Retrieval tool),
	// renders prompts.CitationPlusPrompt, and makes a second LLM
	// call to insert [ID:N] tags into the final content. Mirrors
	// Python's `_generate_with_citation` flow.
	Cite    bool
	Driver  string
	APIKey  string
	BaseURL string
}

// SubAgentTool is a child Agent exposed to a parent Agent as an Eino tool.
type SubAgentTool struct {
	Name        string
	Description string
	Param       AgentParam
}

const (
	agentUserPromptSchemaDefault         = "This is the order you need to send to the agent."
	defaultAgentMessageHistoryWindowSize = 13
)

// AgentMeta declares the OpenAI-style function-call interface for the
// Agent component. Mirrors ragflow Python's ToolMeta shape.
type AgentMeta struct {
	Name        string
	Description string
	// Parameters is the JSON-Schema-shaped object describing the
	// Agent's own input parameters. Each key is the parameter name
	// (e.g. "user_prompt", "reasoning", "context") and the value
	// carries type/description/required.
	Parameters map[string]AgentMetaParam
}

// AgentMetaParam is a single field in the Agent's input schema.
type AgentMetaParam struct {
	Type        string
	Description string
	Required    bool
}

// AgentOutput mirrors the outputs map (per plan §2.11.3 row 8):
//
//	"content"     string
//	"tool_calls"  []map[string]any  (one entry per tool call observed)
//	"artifacts"   []map[string]any  (collected from tool responses — empty in P0)
type AgentOutput struct {
	Content   string
	ToolCalls []map[string]any
	Artifacts []map[string]any
}

// agentRunner is the package-level ReAct runner. The production value
// delegates to eino's flow/agent/react. Tests replace it with a function
// that returns canned *schema.Message values.
var agentRunner = runEinoReActAgent

// runEinoReActAgent creates an eino react agent and runs it against the
// model built from p.
func runEinoReActAgent(ctx context.Context, p AgentParam) (*schema.Message, error) {
	chatModel, err := buildAgentChatModel(ctx, p)
	if err != nil {
		return nil, fmt.Errorf("build model: %w", err)
	}
	tools, err := buildAgentTools(ctx, p)
	if err != nil {
		return nil, fmt.Errorf("build tools: %w", err)
	}
	input := buildAgentInputMessages(ctx, p)

	agent, err := react.NewAgent(ctx, &react.AgentConfig{
		ToolCallingModel: chatModel,
		ToolsConfig: compose.ToolsNodeConfig{
			Tools: tools,
		},
		MessageModifier: func(_ context.Context, msgs []*schema.Message) []*schema.Message {
			if p.SystemPrompt != "" {
				return append([]*schema.Message{schema.SystemMessage(p.SystemPrompt)}, msgs...)
			}
			return msgs
		},
		MaxStep: p.MaxRounds,
	})
	if err != nil {
		return nil, fmt.Errorf("create react agent: %w", err)
	}

	opt, future := react.WithMessageFuture()
	ctx = setArtifactCollector(ctx, future)
	stream, err := agent.Stream(ctx, input, opt)
	if err != nil {
		return nil, err
	}
	defer stream.Close()
	emitDone := emitAgentModelStreams(ctx, future)

	chunks := make([]*schema.Message, 0)
	for {
		chunk, err := stream.Recv()
		if err == io.EOF {
			break
		}
		if err != nil {
			return nil, err
		}
		if chunk == nil {
			continue
		}
		chunks = append(chunks, chunk)
	}
	if emitErr := <-emitDone; emitErr != nil {
		return nil, emitErr
	}
	if len(chunks) == 0 {
		return &schema.Message{Role: schema.Assistant}, nil
	}
	msg, err := schema.ConcatMessages(chunks)
	if err != nil {
		return nil, err
	}
	return msg, nil
}

// buildAgentInputMessages assembles the Python-compatible Agent prompt: the
// configured history window followed by the current user prompt. The current
// in-flight user entry is excluded through SnapshotPriorHistory, because the
// canvas service appends it to state before invoking the workflow.
func buildAgentInputMessages(ctx context.Context, p AgentParam) []*schema.Message {
	current := schema.Message{Role: schema.User, Content: p.UserPrompt}
	messages := []schema.Message{}
	if p.MessageHistoryWindowSize > 0 {
		if state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx); err == nil && state != nil {
			// Python takes the last 2*N entries from history, which already
			// contains the current user input, and then removes that final
			// entry before formatting the configured prompt.
			priorLimit := p.MessageHistoryWindowSize*2 - 1
			messages = prependHistory(messages, state.SnapshotPriorHistory(), priorLimit)
		}
	}
	if len(messages) > 0 && messages[len(messages)-1].Role == current.Role {
		messages[len(messages)-1] = current
	} else {
		messages = append(messages, current)
	}

	input := make([]*schema.Message, len(messages))
	for i := range messages {
		input[i] = &messages[i]
	}
	return input
}

func emitAgentModelStreams(ctx context.Context, future react.MessageFuture) <-chan error {
	done := make(chan error, 1)
	go func() {
		var firstErr error
		iter := future.GetMessageStreams()
		for {
			msgStream, hasNext, err := iter.Next()
			if err != nil {
				firstErr = err
				break
			}
			if !hasNext {
				break
			}
			if msgStream == nil {
				continue
			}
			for {
				msg, err := msgStream.Recv()
				if errors.Is(err, io.EOF) {
					break
				}
				if err != nil {
					if firstErr == nil {
						firstErr = err
					}
					break
				}
				if msg == nil {
					continue
				}
				if msg.Role != "" && msg.Role != schema.Assistant {
					continue
				}
				if msg.Content == "" && msg.ReasoningContent == "" {
					continue
				}
				if runtime.AgentMessageEventsEmitted(ctx) && !runtime.HasDeferredAgentMessageSink(ctx) {
					continue
				}
				runtime.EmitAgentMessage(ctx, msg.Content, msg.ReasoningContent)
			}
			msgStream.Close()
		}
		done <- firstErr
	}()
	return done
}

// addToolCallMemory summarizes the tool calls observed in msg via
// a small LLM call and returns a one-line history entry. Mirrors
// Python's `add_memory(user, assist, func_name, params, results,
// user_defined_prompt)` — the LLM condenses the tool usage into a
// short, memory-worthy sentence.
//
// When the LLM call fails or there are no tool calls, the function
// returns ("", nil) and the caller skips appending to history.
func addToolCallMemory(ctx context.Context, db *gorm.DB, p AgentParam, msg *schema.Message) (string, error) {
	calls := extractToolCalls(msg)
	if len(calls) == 0 {
		return "", nil
	}
	// Format a compact summary of the calls.
	var callsDesc strings.Builder
	for i, c := range calls {
		if i > 0 {
			callsDesc.WriteString("; ")
		}
		fmt.Fprintf(&callsDesc, "%s(%v)", c["name"], c["arguments"])
	}
	system := "You are a memory summarizer. Given a list of tool calls the assistant just made, output ONE short sentence (max 30 words) describing what the assistant did, suitable for a future-turn conversation history. Output ONLY the sentence, no preamble, no quotes."
	user := "Tool calls: " + callsDesc.String()
	inv := getDefaultChatInvoker()
	resp, err := inv.Invoke(ctx, db, ChatInvokeRequest{
		Driver:    p.Driver,
		ModelName: p.ModelID,
		APIKey:    p.APIKey,
		BaseURL:   p.BaseURL,
		Messages: []schema.Message{
			{Role: schema.System, Content: system},
			{Role: schema.User, Content: user},
		},
		TopP: p.TopP,
	})
	if err != nil {
		return "", err
	}
	return strings.TrimSpace(resp.Content), nil
}

// applyCitationGrounding is the post-stream citation grounding
// call. It reads the chunks recorded in state.Retrieval["chunks"]
// (populated by the Retrieval tool), renders
// prompts.CitationPlusPrompt, and makes a second LLM call asking
// the model to insert [ID:N] tags into the assistant's final
// content.
//
// Returns the grounded content on success, the original content
// unchanged when no chunks are available or the call fails. Mirrors
// Python's `cite_letter` / `generate_with_citation` flow.
func applyCitationGrounding(ctx context.Context, db *gorm.DB, p AgentParam, content string, chunks []prompts.CitationSource) (string, error) {
	if !p.Cite {
		return content, nil
	}
	if len(chunks) == 0 {
		return content, nil
	}
	if strings.TrimSpace(content) == "" {
		return content, nil
	}
	systemPrompt, _ := prompts.CitationPlusPrompt(chunks)
	inv := getDefaultChatInvoker()
	resp, err := inv.Invoke(ctx, db, ChatInvokeRequest{
		Driver:    p.Driver,
		ModelName: p.ModelID,
		APIKey:    p.APIKey,
		BaseURL:   p.BaseURL,
		Messages: []schema.Message{
			{Role: schema.System, Content: systemPrompt},
			{Role: schema.User, Content: content},
		},
		TopP: p.TopP,
	})
	if err != nil {
		// Grounding is best-effort. Return the original content
		// so the message still flows; the caller can decide
		// whether to surface the error.
		return content, err
	}
	grounded := strings.TrimSpace(resp.Content)
	if grounded == "" {
		return content, nil
	}
	return grounded, nil
}

// chunksFromState extracts the recorded retrieval chunks from
// the canvas state in ctx. Returns nil when the state or the
// chunks key is absent / empty. The returned slice is shaped
// for prompts.CitationSource — the grounding renderer.
func chunksFromState(ctx context.Context) []prompts.CitationSource {
	state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx)
	if err != nil || state == nil {
		return nil
	}
	raw := state.GetRetrievalChunks()
	if len(raw) == 0 {
		return nil
	}
	out := make([]prompts.CitationSource, 0, len(raw))
	for _, m := range raw {
		id, _ := m["id"].(string)
		content, _ := m["content"].(string)
		if id == "" || content == "" {
			continue
		}
		out = append(out, prompts.CitationSource{ID: id, Content: content})
	}
	return out
}

// GetInputForm aggregates the Agent's own user-callable parameters with each
// sub-tool's input form. Mirrors Python's `Agent.get_input_form`, which
// returns a flat field-definition map keyed by input name.
//
// Today the sub-tool input forms are aggregated via an optional
// `InputForm() map[string]any` method on the eino tool (when tools
// implement it); tools that don't expose a structured input form
// are skipped silently.
func (c *AgentComponent) GetInputForm() map[string]any {
	out := extractAgentPromptInputForm(c.param.SystemPrompt, c.param.UserPrompt)
	// GetInputForm is metadata introspection outside a Canvas run and its
	// interface has no context. Runtime tool construction uses the run ctx in
	// runEinoReActAgent above.
	metadataCtx := context.Background()
	tools, err := buildAgentTools(metadataCtx, c.param)
	if err != nil {
		return out
	}
	for _, t := range tools {
		info, ierr := t.Info(metadataCtx)
		name := ""
		if ierr == nil && info != nil {
			name = info.Name
		}
		if name == "" {
			continue
		}
		if formGetter, ok := t.(interface{ InputForm() map[string]any }); ok {
			out[name] = formGetter.InputForm()
		}
	}
	return out
}

func extractAgentPromptInputForm(systemPrompt, userPrompt string) map[string]any {
	out := map[string]any{}
	seen := map[string]struct{}{}
	matches := append(runtime.VarRefPattern.FindAllStringSubmatch(systemPrompt, -1), runtime.VarRefPattern.FindAllStringSubmatch(userPrompt, -1)...)
	for _, match := range matches {
		if len(match) < 2 {
			continue
		}
		key := strings.TrimSpace(match[1])
		if key == "" {
			continue
		}
		if _, ok := seen[key]; ok {
			continue
		}
		seen[key] = struct{}{}
		out[key] = map[string]any{
			"type":     "line",
			"name":     key,
			"optional": false,
		}
	}
	return out
}

func sortedAgentPromptInputKeys(systemPrompt, userPrompt string) []string {
	form := extractAgentPromptInputForm(systemPrompt, userPrompt)
	keys := make([]string, 0, len(form))
	for key := range form {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	return keys
}

// Reset calls Reset on every sub-tool that implements the Resetter
// interface. Mirrors Python's per-tool reset() — useful for clearing
// per-invocation state (caches, scratch buffers) between calls.
func (c *AgentComponent) Reset() {
	// Reset is a context-free lifecycle hook and does not execute a tool.
	// Runtime tool construction receives the Canvas run context.
	tools, err := buildAgentTools(context.Background(), c.param)
	if err != nil {
		return
	}
	for _, t := range tools {
		if r, ok := t.(interface{ Reset() }); ok {
			r.Reset()
		}
	}
}

// optimizeMultiTurnQuestion asks the LLM to rephrase the current user
// prompt into a self-contained question that doesn't require the
// conversation history to understand. Mirrors Python's `full_question`
// LLM pass.
//
// Returns the original prompt unchanged if:
//   - history has < 2 entries (no prior turns to fold in)
//   - the rephrase LLM call fails
//
// Window defaults to AgentParam.OptimizeHistoryWindow (3) when zero.
func optimizeMultiTurnQuestion(ctx context.Context, db *gorm.DB, p AgentParam, history []map[string]any) (string, error) {
	window := p.OptimizeHistoryWindow
	if window <= 0 {
		window = 3
	}
	if len(history) < 2 {
		return "", nil
	}
	start := 0
	if len(history) > window {
		start = len(history) - window
	}
	var histBuf strings.Builder
	for i := start; i < len(history); i++ {
		e := history[i]
		role, _ := e["role"].(string)
		content, _ := e["content"].(string)
		if role == "" || content == "" {
			continue
		}
		fmt.Fprintf(&histBuf, "%s: %s\n", role, content)
	}
	if histBuf.Len() == 0 {
		return "", nil
	}
	system := "You are a question rephraser. Given conversation history and the user's latest input, rewrite the latest input as a self-contained question that does not require the history to understand. Output ONLY the rephrased question, no preamble, no quotes."
	user := "Conversation history:\n" + histBuf.String() + "\n\nUser's latest input:\n" + p.UserPrompt
	inv := getDefaultChatInvoker()
	resp, err := inv.Invoke(ctx, db, ChatInvokeRequest{
		Driver:    p.Driver,
		ModelName: p.ModelID,
		APIKey:    p.APIKey,
		BaseURL:   p.BaseURL,
		Messages: []schema.Message{
			{Role: schema.System, Content: system},
			{Role: schema.User, Content: user},
		},
		TopP: p.TopP,
	})
	if err != nil {
		return "", err
	}
	return strings.TrimSpace(resp.Content), nil
}

func buildAgentTools(ctx context.Context, p AgentParam) ([]einotool.BaseTool, error) {
	tools, err := agenttool.BuildAll(p.Tools, p.ToolParams)
	if err != nil {
		return nil, err
	}
	toolNames := make(map[string]struct{}, len(tools)+len(p.SubAgents))
	for _, tool := range tools {
		info, err := tool.Info(ctx)
		if err != nil {
			return nil, fmt.Errorf("agent tool info: %w", err)
		}
		if info == nil || strings.TrimSpace(info.Name) == "" {
			return nil, fmt.Errorf("agent tool info: missing name")
		}
		if _, exists := toolNames[info.Name]; exists {
			return nil, fmt.Errorf("duplicate agent tool name %q", info.Name)
		}
		toolNames[info.Name] = struct{}{}
	}
	for _, subAgent := range p.SubAgents {
		name := uniqueAgentToolName(subAgent.Name, toolNames)
		toolNames[name] = struct{}{}
		tools = append(tools, &subAgentTool{name: name, spec: subAgent})
	}
	return tools, nil
}

type subAgentTool struct {
	name string
	spec SubAgentTool
}

func (t *subAgentTool) Info(_ context.Context) (*schema.ToolInfo, error) {
	params := map[string]*schema.ParameterInfo{
		"user_prompt": {
			Type:     schema.String,
			Desc:     agentUserPromptSchemaDefault,
			Required: true,
		},
		"reasoning": {
			Type:     schema.String,
			Desc:     "Supervisor's reasoning for choosing this agent. Explain why this agent is being invoked and what is expected of it.",
			Required: true,
		},
		"context": {
			Type:     schema.String,
			Desc:     "All relevant background information, prior facts, decisions, and state needed by the agent to solve the current query.",
			Required: true,
		},
	}
	name := t.name
	if name == "" {
		name = normalizeAgentToolName(t.spec.Name)
	}
	return &schema.ToolInfo{
		Name:        name,
		Desc:        subAgentToolDescription(t.spec),
		ParamsOneOf: schema.NewParamsOneOfByParams(params),
	}, nil
}

type subAgentDepthKey struct{}

func subAgentDepth(ctx context.Context) int {
	if v, ok := ctx.Value(subAgentDepthKey{}).(int); ok {
		return v
	}
	return 0
}

func (t *subAgentTool) InvokableRun(ctx context.Context, argsJSON string, _ ...einotool.Option) (string, error) {
	depth := subAgentDepth(ctx)
	if depth >= maxSubAgentDepth {
		return "", fmt.Errorf("sub-agent tool %q: max nesting depth (%d) exceeded", normalizeAgentToolName(t.spec.Name), maxSubAgentDepth)
	}

	ctx = context.WithValue(ctx, subAgentDepthKey{}, depth+1)

	inputs := map[string]any{}
	if strings.TrimSpace(argsJSON) != "" {
		if err := json.Unmarshal([]byte(argsJSON), &inputs); err != nil {
			return "", fmt.Errorf("sub-agent tool %q: decode arguments: %w", normalizeAgentToolName(t.spec.Name), err)
		}
	}

	out, err := NewAgentComponent(t.spec.Param).Invoke(ctx, dao.DB, inputs)
	if err != nil {
		return "", fmt.Errorf("sub-agent tool %q: %w", normalizeAgentToolName(t.spec.Name), err)
	}
	if content, ok := out["content"].(string); ok {
		return content, nil
	}
	payload, err := json.Marshal(out)
	if err != nil {
		return "", fmt.Errorf("sub-agent tool %q: encode output: %w", normalizeAgentToolName(t.spec.Name), err)
	}
	return string(payload), nil
}

func subAgentToolDescription(spec SubAgentTool) string {
	if strings.TrimSpace(spec.Description) != "" {
		return strings.TrimSpace(spec.Description)
	}
	if strings.TrimSpace(spec.Param.Description) != "" {
		return strings.TrimSpace(spec.Param.Description)
	}
	return "This is an agent for a specific task."
}

func uniqueAgentToolName(name string, used map[string]struct{}) string {
	base := normalizeAgentToolName(name)
	if _, exists := used[base]; !exists {
		return base
	}
	for n := 2; ; n++ {
		candidate := fmt.Sprintf("%s_%d", base, n)
		if _, exists := used[candidate]; !exists {
			return candidate
		}
	}
}

func normalizeAgentToolName(name string) string {
	name = strings.TrimSpace(name)
	if name == "" {
		return "agent"
	}

	var b strings.Builder
	lastSeparator := false
	for _, r := range name {
		valid := r == '_' || r == '-' ||
			(r >= 'a' && r <= 'z') ||
			(r >= 'A' && r <= 'Z') ||
			(r >= '0' && r <= '9')
		if valid {
			b.WriteRune(r)
			lastSeparator = false
			continue
		}
		if !lastSeparator {
			b.WriteByte('_')
			lastSeparator = true
		}
	}

	out := strings.Trim(b.String(), "_-")
	if out == "" {
		return "agent"
	}
	if out[0] >= '0' && out[0] <= '9' {
		out = "agent_" + out
	}
	return out
}

// NewAgentComponent builds an AgentComponent from raw params.
func NewAgentComponent(p AgentParam) *AgentComponent {
	if p.MaxRounds <= 0 {
		// Keep the Python Agent default (AgentParam.max_rounds = 5).
		p.MaxRounds = 5
	}
	return &AgentComponent{param: p}
}

// Name returns the registered component name.
func (c *AgentComponent) Name() string { return "Agent" }

// Invoke either returns a lazy Agent stream for a direct downstream Message,
// or executes the Agent eagerly for all other graph shapes. The mode is a
// compile-time canvas decision carried through context, not a DSL parameter.
func (c *AgentComponent) Invoke(ctx context.Context, db *gorm.DB, inputs map[string]any) (map[string]any, error) {
	if runtime.ComponentExecutionOptionsFromContext(ctx).DeferAgentToMessage {
		// Preserve the Agent-class duration installed by the node wrapper, then
		// start a fresh deadline when Message opens the lazy stream.
		timeout := defaultAgentDeferredTimeout
		if deadline, ok := ctx.Deadline(); ok {
			if remaining := time.Until(deadline); remaining > 0 {
				timeout = remaining
			}
		}
		deferred := &runtime.DeferredStream{
			Open: func(openCtx context.Context, sink runtime.AgentDeltaSink) (map[string]any, error) {
				agentCtx, cancel := context.WithTimeout(openCtx, timeout)
				defer cancel()
				return c.invokeNow(runtime.WithAgentDeltaSink(agentCtx, sink), db, inputs)
			},
		}
		return map[string]any{"content": deferred}, nil
	}
	return c.invokeNow(ctx, db, inputs)
}

// invokeNow contains the original eager Agent execution path. Deferred
// Message consumption calls this function later with an invocation-local
// delta sink, so the same ReAct/citation/tool behavior is reused once.
func (c *AgentComponent) invokeNow(ctx context.Context, db *gorm.DB, inputs map[string]any) (map[string]any, error) {
	runtime.ResetAgentMessageEmission(ctx)
	defer runtime.FinalizeAgentMessage(ctx)

	p := mergeAgentParam(c.param, inputs)
	hasRuntimeUserPrompt := false
	if v, ok := stringFrom(inputs, "user_prompt"); ok {
		hasRuntimeUserPrompt = !shouldFallbackToSysQuery(v)
	}

	var err error
	p.ModelID, p.Driver, p.APIKey, p.BaseURL, err = resolveChatModelRef(ctx, db, p.ModelID, p.Driver, p.APIKey, p.BaseURL)
	if err != nil {
		return nil, err
	}

	var state *runtime.CanvasState
	if s, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx); err == nil && s != nil {
		state = s
		if resolved, rerr := runtime.ResolveTemplate(p.SystemPrompt, state); resolved != p.SystemPrompt || rerr == nil {
			p.SystemPrompt = resolved
			if rerr != nil {
				common.Debug("agent: resolve system_prompt", zap.Error(rerr))
			}
		}
		if resolved, rerr := runtime.ResolveTemplate(p.UserPrompt, state); resolved != p.UserPrompt || rerr == nil {
			p.UserPrompt = resolved
			if rerr != nil {
				common.Debug("agent: resolve user_prompt", zap.Error(rerr))
			}
		}
	}
	if hasRuntimeUserPrompt {
		p.UserPrompt = formatAgentRuntimePrompt(inputs, p.UserPrompt)
	} else if shouldFallbackToSysQuery(p.UserPrompt) && strings.TrimSpace(p.SystemPrompt) == "" && state != nil {
		if query, ok := stringFromState(state, "query"); ok {
			p.UserPrompt = query
		}
	}

	if p.ModelID == "" {
		return nil, &ParamError{Field: "model_id", Reason: "required"}
	}
	if p.UserPrompt == "" && p.SystemPrompt == "" {
		return nil, &ParamError{Field: "user_prompt", Reason: "at least one of user_prompt or system_prompt must be set"}
	}
	// v1 fixtures sometimes ship only a system prompt. Fall back to
	// using the system text as the user message so the underlying
	// chat call still has something to send to the model.
	if p.UserPrompt == "" {
		p.UserPrompt = p.SystemPrompt
	}

	// Multi-turn conversation optimization. When the canvas state
	// carries prior history and OptimizeMultiTurn is enabled
	// explicitly, rephrase the user prompt into a self-contained question via
	// a dedicated LLM call. The rephrased prompt is what the Agent runner
	// actually consumes.
	if p.OptimizeMultiTurn {
		if state, _, sErr := runtime.GetStateFromContext[*runtime.CanvasState](ctx); sErr == nil && state != nil {
			if rephrased, err := optimizeMultiTurnQuestion(ctx, db, p, state.SnapshotPriorHistory()); err == nil && rephrased != "" {
				p.UserPrompt = rephrased
			}
		}
	}

	msg, err := agentRunner(ctx, p)
	// Tool-call memory summarization. After the ReAct loop
	// completes, summarize the tool calls via an LLM and append to
	// the canvas state's Memory. Conversation History is reserved for
	// actual user/assistant turns maintained by the canvas service.
	if err == nil && msg != nil {
		if state, _, sErr := runtime.GetStateFromContext[*runtime.CanvasState](ctx); sErr == nil && state != nil {
			if summary, sErr2 := addToolCallMemory(ctx, db, p, msg); sErr2 == nil && summary != "" {
				state.AppendMemory(p.UserPrompt, msg.Content, summary)
			}
		}
	}
	if err != nil {
		// Python's LLM layer turns model/tool execution failures into an
		// ``**ERROR**`` response, which Agent exposes as the `_ERROR`
		// component output.  Preserve cancellation as a real graph error,
		// but keep ordinary ReAct failures in the component data flow so
		// Canvas exception branches can handle them.
		if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || !isAgentGraphRunError(err) {
			return nil, fmt.Errorf("component: Agent.Invoke: %w", err)
		}
		return map[string]any{"_ERROR": "**ERROR**: " + err.Error()}, nil
	}
	// Post-stream citation grounding. When Cite is enabled and
	// the canvas state has recorded retrieval chunks (populated
	// by the Retrieval tool during the ReAct loop), make a second
	// LLM call to insert [ID:N] tags into the final content. The
	// grounding call is best-effort — on failure the original
	// content is kept and the error is surfaced under
	// outputs["grounding_error"].
	// Diagnostic sentinel (temporary — see plan): log the post-
	// agentRunner state right before the `msg.Content` deref so a
	// subsequent panic shows whether the agent returned (nil, nil).
	if msg == nil {
		common.Debug("agent.Invoke: msg is NIL after agentRunner",
			zap.String("driver", p.Driver),
			zap.String("modelID", p.ModelID),
			zap.Int("userPrompt_len", len(p.UserPrompt)),
			zap.Error(err))
		return nil, fmt.Errorf("component: Agent.Invoke: agent runner returned nil message (driver=%q modelID=%q): %w", p.Driver, p.ModelID, err)
	}
	common.Debug("agent.Invoke: msg OK",
		zap.String("driver", p.Driver),
		zap.String("modelID", p.ModelID),
		zap.Int("content_len", len(msg.Content)))
	content := msg.Content
	thinking := msg.ReasoningContent

	var groundingStatus string
	if p.Cite {
		chunks := chunksFromState(ctx)
		if len(chunks) == 0 {
			groundingStatus = "no_chunks"
		} else {
			grounded, gErr := applyCitationGrounding(ctx, db, p, content, chunks)
			if gErr == nil && grounded != content {
				content = grounded
				groundingStatus = "applied"
			} else if gErr != nil {
				groundingStatus = "error: " + gErr.Error()
			}
		}
	}
	artifacts := collectArtifactsFromToolCalls(ctx, msg)
	artifactMD := formatArtifactMarkdown(artifacts, content)
	out := map[string]any{
		"content":    content + artifactMD,
		"thinking":   thinking,
		"tool_calls": extractToolCalls(msg),
		"artifacts":  artifacts,
	}
	if groundingStatus != "" {
		out["grounding_status"] = groundingStatus
	}
	streamed := runtime.AgentMessageEventsEmitted(ctx) || runtime.DeferredAgentMessageEventsEmitted(ctx)
	if !streamed {
		runtime.EmitAgentMessage(ctx, content+artifactMD, thinking)
	}
	return out, nil
}

// isAgentGraphRunError identifies ReAct graph failures that Python exposes
// through the Agent component's _ERROR output. Construction/configuration
// errors must remain real component errors so invalid canvases still fail
// fast; only the graph execution limit/error is routed to exception branches.
func isAgentGraphRunError(err error) bool {
	if err == nil {
		return false
	}
	msg := strings.ToLower(err.Error())
	return strings.Contains(msg, "[graphrunerror]") || strings.Contains(msg, "exceeds max steps")
}

// Stream implements Component.Stream. Mirrors Invoke then pushes the
// single payload through the channel.
func (c *AgentComponent) Stream(ctx context.Context, db *gorm.DB, inputs map[string]any) (<-chan map[string]any, error) {
	out := make(chan map[string]any, 1)
	go func() {
		defer close(out)
		result, err := c.Invoke(ctx, db, inputs)
		if err != nil {
			out <- map[string]any{"error": err.Error()}
			return
		}
		out <- result
	}()
	return out, nil
}

// Inputs returns parameter metadata for tooling.
func (c *AgentComponent) Inputs() map[string]string {
	return map[string]string{
		"model_id":                "Provider-side model identifier (e.g. \"gpt-4o-mini\")",
		"system_prompt":           "Optional system prompt",
		"user_prompt":             "User prompt; supports {{cpn_id@param}} references",
		"top_p":                   "Top-p (nucleus) sampling cutoff (0.0-1.0). Optional.",
		"tools":                   "List of tool names to make available to the ReAct agent.",
		"tool_params":             "Optional node-level tool constructor params keyed by tool name (e.g. execute_sql DB config).",
		"max_rounds":              "Maximum ReAct rounds (default 3).",
		"optimize_multi_turn":     "When true, multi-turn history is condensed via a question-rewrite LLM call.",
		"optimize_history_window": "Number of history entries to include in the optimization prompt (default 3).",
		"driver":                  "Provider driver name",
		"api_key":                 "Override API key for this call.",
		"base_url":                "Override the driver default endpoint URL.",
		"cite":                    "When true, make a post-stream citation-grounding call (reads chunks from state.Retrieval).",
	}
}

// Outputs returns output metadata.
func (c *AgentComponent) Outputs() map[string]string {
	return map[string]string{
		"content":          "Final assistant content (after the ReAct loop terminates)",
		"thinking":         "Model reasoning content, when the provider returns it separately.",
		"tool_calls":       "One entry per tool call observed during the run",
		"artifacts":        "Artifacts collected from tool responses (empty in P0)",
		"grounding_status": "'applied' | 'no_chunks' | 'error: <msg>' (present when cite=true).",
	}
}

// buildAgentChatModel constructs an EinoChatModel from AgentParam by
// resolving the driver through the RAGFlow provider manager.
func buildAgentChatModel(ctx context.Context, p AgentParam) (*models.EinoChatModel, error) {
	driver := p.Driver
	modelID := p.ModelID

	// When the Agent DSL omits `driver`, derive it from the composite
	// llm_id format. The RAGFlow DSL stores the model identifier as
	// "<model>@<instance>@<provider>" (mirrors Python's
	// split_model_name at
	// api/db/joint_services/tenant_model_service.py:163-178 and the
	// Go-side SplitModelNameAndFactory at
	// internal/service/tenant.go:168). Two-part
	// "<model>@<provider>" and bare "<model>" are also accepted —
	// bare means no driver known, which falls through to the dummy
	// driver below. The trailing "@<provider>" suffix must also be
	// stripped from the model id before passing to the driver — the
	// upstream APIs (ZhipuAI, OpenAI, …) do not accept composite
	// names and would 400 on the "@<provider>" tail.
	if driver == "" && modelID != "" {
		if bareModelName, providerName, ok := splitCompositeLLMID(modelID); ok {
			driver = providerName
			modelID = bareModelName
		}
	}
	if driver == "" {
		driver = "dummy"
	}
	d, err := newChatModelDriver(driver, p.BaseURL)
	if err != nil {
		return nil, fmt.Errorf("resolve driver %q: %w", driver, err)
	}
	if d == nil {
		return nil, fmt.Errorf("no driver for %q", driver)
	}
	apiKey := p.APIKey
	cfg := &models.APIConfig{ApiKey: &apiKey}
	cm := models.NewChatModel(d, &modelID, cfg)
	// ChatConfig construction is conditional on TopP being set, unlike
	// the LLM path which always builds a ChatConfig (Temperature/MaxTokens
	// pass-through). The asymmetry is intentional: AgentParam has no
	// Temperature/MaxTokens yet, so building a zero-config ChatConfig
	// would be dead weight. When AgentParam grows Temperature/
	// MaxTokens, switch to always-build.
	var chatCfg *models.ChatConfig
	if p.TopP != nil || p.Thinking != "" {
		chatCfg = &models.ChatConfig{TopP: p.TopP}
		switch p.Thinking {
		case "enabled":
			t := true
			chatCfg.Thinking = &t
		case "disabled":
			f := false
			chatCfg.Thinking = &f
		}
	}
	return models.NewEinoChatModel(cm, chatCfg), nil
}

// artifactEntry is the shape of a single tool-returned artifact
// surfaced through the Agent's outputs["artifacts"].
type artifactEntry struct {
	Name string `json:"name"`
	URL  string `json:"url"`
}

// artifactCollectorKey is the context key used to stash the
// MessageFuture from react.WithMessageFuture() so the AgentComponent
// can collect artifacts after the ReAct loop finishes. The collector
// is created per-invocation in runEinoReActAgent.
type artifactCollectorKey struct{}

// setArtifactCollector registers the MessageFuture for this agent run
// in the context. It is called from runEinoReActAgent after
// react.WithMessageFuture() returns a future.
func setArtifactCollector(ctx context.Context, future react.MessageFuture) context.Context {
	return context.WithValue(ctx, artifactCollectorKey{}, future)
}

// getArtifactCollector retrieves the MessageFuture registered for the
// current agent run. Returns nil when no collector was registered
// (e.g., tests that stub agentRunner).
func getArtifactCollector(ctx context.Context) react.MessageFuture {
	v := ctx.Value(artifactCollectorKey{})
	if v == nil {
		return nil
	}
	if f, ok := v.(react.MessageFuture); ok {
		return f
	}
	return nil
}

// collectArtifactsFromToolCalls drains the MessageFuture stored in
// ctx (if any) and extracts artifact entries from every tool response
// message that carries a `_ARTIFACTS` payload in its Extra field.
// The final message is ignored because it is an assistant message and
// does not contain tool results. Returns a de-duplicated list ordered
// by first appearance.
//
// The expected payload shape in each tool response is:
//
//	{ "_ARTIFACTS": [{ "name": "report.pdf", "url": "https://..." }, ...] }
func collectArtifactsFromToolCalls(ctx context.Context, _ *schema.Message) []artifactEntry {
	future := getArtifactCollector(ctx)
	if future == nil {
		return nil
	}

	seen := make(map[string]struct{})
	var out []artifactEntry

	iter := future.GetMessages()
	for {
		msg, ok, err := iter.Next()
		if err != nil {
			common.Debug("agent: artifact collection iterator error", zap.Error(err))
			break
		}
		if !ok {
			break
		}
		if msg == nil || msg.Role != schema.Tool {
			continue
		}
		rawArtifacts := extractArtifactsFromToolMessage(msg)
		for _, a := range rawArtifacts {
			if a.URL == "" || a.Name == "" {
				continue
			}
			if _, exists := seen[a.URL]; exists {
				continue
			}
			seen[a.URL] = struct{}{}
			out = append(out, a)
		}
	}
	return out
}

// extractArtifactsFromToolMessage parses the JSON payload of a tool
// response message and returns the `_ARTIFACTS` list. The payload is
// read from msg.Content when it is non-empty; otherwise the first text
// element of msg.UserInputMultiContent is used. This matches the eino
// tool contract where tool results are delivered as a string.
func extractArtifactsFromToolMessage(msg *schema.Message) []artifactEntry {
	payload := msg.Content
	if payload == "" && len(msg.UserInputMultiContent) > 0 {
		payload = toolMessageTextContent(msg)
	}
	if payload == "" {
		return nil
	}

	var envelope map[string]any
	if err := json.Unmarshal([]byte(payload), &envelope); err != nil {
		return nil
	}

	raw, ok := envelope["_ARTIFACTS"].([]any)
	if !ok {
		return nil
	}

	out := make([]artifactEntry, 0, len(raw))
	for _, item := range raw {
		m, ok := item.(map[string]any)
		if !ok {
			continue
		}
		name, _ := m["name"].(string)
		url, _ := m["url"].(string)
		if name == "" || url == "" {
			continue
		}
		out = append(out, artifactEntry{Name: name, URL: url})
	}
	return out
}

// toolMessageTextContent returns the first text content part of a tool
// message, or an empty string if no text part is found.
func toolMessageTextContent(msg *schema.Message) string {
	for i := range msg.UserInputMultiContent {
		part := &msg.UserInputMultiContent[i]
		if part.Type == schema.ChatMessagePartTypeText && part.Text != "" {
			return part.Text
		}
	}
	return ""
}

// formatArtifactMarkdown renders a slice of artifacts as markdown
// links, omitting URLs already present in the existing text (Python's
// `_collect_tool_artifact_markdown` does the same de-duplication).
//
// Format:
//   - image URL  → ![name](url)
//   - other URL  → [Download name](url)
//
// Returns the empty string when no artifacts are present, so callers
// can safely concatenate without guarding.
func formatArtifactMarkdown(artifacts []artifactEntry, existingText string) string {
	if len(artifacts) == 0 {
		return ""
	}
	var sb strings.Builder
	for _, a := range artifacts {
		if a.URL == "" || a.Name == "" {
			continue
		}
		if strings.Contains(existingText, a.URL) {
			continue
		}
		lower := strings.ToLower(a.URL)
		if strings.HasSuffix(lower, ".png") || strings.HasSuffix(lower, ".jpg") ||
			strings.HasSuffix(lower, ".jpeg") || strings.HasSuffix(lower, ".gif") ||
			strings.HasSuffix(lower, ".webp") {
			fmt.Fprintf(&sb, "\n\n![%s](%s)", a.Name, a.URL)
		} else {
			fmt.Fprintf(&sb, "\n\n[Download %s](%s)", a.Name, a.URL)
		}
	}
	return sb.String()
}

// extractToolCalls converts eino ToolCalls from a message into the
// output map format.
func extractToolCalls(msg *schema.Message) []map[string]any {
	if msg == nil || len(msg.ToolCalls) == 0 {
		return nil
	}
	calls := make([]map[string]any, 0, len(msg.ToolCalls))
	for _, tc := range msg.ToolCalls {
		calls = append(calls, map[string]any{
			"id":        tc.ID,
			"type":      tc.Type,
			"name":      tc.Function.Name,
			"arguments": tc.Function.Arguments,
		})
	}
	return calls
}

// promptMessagesFromParams extracts the Python DSL `prompts` list into
// the single system/user prompt shape supported by the Go ReAct runner.
func promptMessagesFromParams(params map[string]any) (systemPrompt, userPrompt string, ok bool) {
	raw, exists := params["prompts"]
	if !exists {
		return "", "", false
	}
	switch v := raw.(type) {
	case string:
		return "", v, true
	case []any:
		var systems, users []string
		for _, item := range v {
			m, ok := item.(map[string]any)
			if !ok {
				continue
			}
			content, ok := stringFrom(m, "content")
			if !ok {
				continue
			}
			role, _ := stringFrom(m, "role")
			switch strings.ToLower(strings.TrimSpace(role)) {
			case "system":
				systems = append(systems, content)
			case "user", "":
				users = append(users, content)
			}
		}
		if len(systems) == 0 && len(users) == 0 {
			return "", "", false
		}
		return strings.Join(systems, "\n"), strings.Join(users, "\n"), true
	case []map[string]any:
		items := make([]any, 0, len(v))
		for _, item := range v {
			items = append(items, item)
		}
		return promptMessagesFromParams(map[string]any{"prompts": items})
	}
	return "", "", false
}

func appendPromptText(base, extra string) string {
	if strings.TrimSpace(extra) == "" {
		return base
	}
	if strings.TrimSpace(base) == "" {
		return extra
	}
	return base + "\n" + extra
}

func hasNonEmptyString(inputs map[string]any, name string) bool {
	v, ok := stringFrom(inputs, name)
	return ok && strings.TrimSpace(v) != ""
}

func shouldFallbackToSysQuery(prompt string) bool {
	p := strings.TrimSpace(prompt)
	return p == "" || p == agentUserPromptSchemaDefault
}

func stringFromState(state *runtime.CanvasState, name string) (string, bool) {
	if state == nil {
		return "", false
	}
	v, ok := state.Sys[name].(string)
	if !ok || strings.TrimSpace(v) == "" {
		return "", false
	}
	return v, true
}

func formatAgentRuntimePrompt(inputs map[string]any, userPrompt string) string {
	var b strings.Builder
	if reasoning, ok := stringFrom(inputs, "reasoning"); ok && reasoning != "" {
		fmt.Fprintf(&b, "\nREASONING:\n%s\n", reasoning)
	}
	if contextText, ok := stringFrom(inputs, "context"); ok && contextText != "" {
		fmt.Fprintf(&b, "\nCONTEXT:\n%s\n", contextText)
	}
	if b.Len() == 0 {
		return userPrompt
	}
	fmt.Fprintf(&b, "\nQUERY:\n%s\n", userPrompt)
	return b.String()
}

// mergeAgentParam layers raw inputs over the receiver's default param set.
//
// v1 aliases accepted alongside the v2 names: "llm_id" → "model_id",
// "sys_prompt" → "system_prompt", "base_url" → "BaseURL". v1 fixtures
// use the short forms; without these aliases the v1→v2 conversion
// step would have to run before the factory builds the component.
func mergeAgentParam(base AgentParam, inputs map[string]any) AgentParam {
	p := base
	if v, ok := stringFrom(inputs, "model_id"); ok {
		p.ModelID = v
	} else if v, ok := stringFrom(inputs, "llm_id"); ok {
		p.ModelID = v
	}
	if v, ok := stringFrom(inputs, "description"); ok {
		p.Description = v
	}
	if v, ok := stringFrom(inputs, "system_prompt"); ok {
		p.SystemPrompt = v
	} else if v, ok := stringFrom(inputs, "sys_prompt"); ok {
		p.SystemPrompt = v
	}
	if promptSystem, promptUser, ok := promptMessagesFromParams(inputs); ok {
		p.SystemPrompt = appendPromptText(p.SystemPrompt, promptSystem)
		if strings.TrimSpace(promptUser) != "" {
			p.UserPrompt = promptUser
		}
	}
	if v, ok := stringFrom(inputs, "user_prompt"); ok && !shouldFallbackToSysQuery(v) {
		p.UserPrompt = v
	}
	if v, ok := floatFrom(inputs, "top_p"); ok {
		f := v
		p.TopP = &f
	}
	if v, ok := stringFrom(inputs, "thinking"); ok && v != "" && v != "default" {
		p.Thinking = v
	}
	if v, ok := intFrom(inputs, "max_rounds"); ok {
		p.MaxRounds = v
	}
	if v, ok := intFrom(inputs, "message_history_window_size"); ok {
		p.MessageHistoryWindowSize = v
	}
	if v, ok := stringFrom(inputs, "driver"); ok {
		p.Driver = v
	}
	if v, ok := stringFrom(inputs, "api_key"); ok {
		p.APIKey = v
	}
	if v, ok := stringFrom(inputs, "base_url"); ok {
		p.BaseURL = v
	}
	if tools, params, subAgents, ok := agentToolsFrom(inputs, "tools"); ok {
		p.Tools = tools
		p.SubAgents = subAgents
		p.ToolParams = mergeToolParams(p.ToolParams, params)
	}
	if v, ok := nestedMapFrom(inputs, "tool_params"); ok {
		p.ToolParams = mergeToolParams(p.ToolParams, v)
	}
	if v, ok := boolFrom(inputs, "optimize_multi_turn"); ok {
		p.OptimizeMultiTurn = v
	}
	if v, ok := intFrom(inputs, "optimize_history_window"); ok {
		p.OptimizeHistoryWindow = v
	}
	if v, ok := boolFrom(inputs, "cite"); ok {
		p.Cite = v
	}
	return p
}

// agentToolsFrom extracts the Agent tools list. The Go-native shape is
// []string; the canvas DSL shape stores tool component objects with
// component_name and params. Child Agent objects are returned separately
// because they are dynamic tools, not entries in the static tool registry.
func agentToolsFrom(inputs map[string]any, name string) ([]string, map[string]map[string]any, []SubAgentTool, bool) {
	v, ok := inputs[name]
	if !ok {
		return nil, nil, nil, false
	}
	switch x := v.(type) {
	case []string:
		return x, nil, nil, true
	case []any:
		out := make([]string, 0, len(x))
		params := make(map[string]map[string]any)
		subAgents := make([]SubAgentTool, 0)
		for _, item := range x {
			switch tool := item.(type) {
			case string:
				if strings.TrimSpace(tool) == "" {
					continue
				}
				out = append(out, tool)
			case map[string]any:
				if subAgent, ok := subAgentToolObject(tool); ok {
					subAgents = append(subAgents, subAgent)
					continue
				}
				toolName, toolParams, ok := agentToolObject(tool)
				if !ok {
					continue
				}
				out = append(out, toolName)
				if len(toolParams) != 0 {
					params[strings.ToLower(strings.TrimSpace(toolName))] = toolParams
				}
			}
		}
		return out, params, subAgents, true
	}
	return nil, nil, nil, false
}

func subAgentToolObject(item map[string]any) (SubAgentTool, bool) {
	componentName, ok := stringFrom(item, "component_name")
	if !ok || !strings.EqualFold(strings.TrimSpace(componentName), "Agent") {
		return SubAgentTool{}, false
	}

	rawParams, _ := item["params"].(map[string]any)
	param := agentParamFromMap(rawParams)
	name, _ := stringFrom(item, "function_name")
	if strings.TrimSpace(name) == "" {
		name, _ = stringFrom(item, "name")
	}
	if strings.TrimSpace(name) == "" {
		name, _ = stringFrom(item, "id")
	}
	description, _ := stringFrom(item, "description")
	if strings.TrimSpace(description) == "" {
		description = param.Description
	}

	return SubAgentTool{
		Name:        normalizeAgentToolName(name),
		Description: description,
		Param:       param,
	}, true
}

func agentToolObject(item map[string]any) (string, map[string]any, bool) {
	toolName, ok := stringFrom(item, "component_name")
	if !ok || strings.TrimSpace(toolName) == "" {
		toolName, ok = stringFrom(item, "tool_name")
	}
	if !ok || strings.TrimSpace(toolName) == "" {
		toolName, ok = stringFrom(item, "name")
	}
	if !ok || strings.TrimSpace(toolName) == "" {
		return "", nil, false
	}
	toolName = strings.TrimSpace(toolName)

	rawParams, _ := item["params"].(map[string]any)
	toolParams := cloneMap(rawParams)
	if fn, ok := stringFrom(item, "function_name"); ok && strings.TrimSpace(fn) != "" {
		if toolParams == nil {
			toolParams = make(map[string]any)
		}
		toolParams["function_name"] = strings.TrimSpace(fn)
	}
	return toolName, toolParams, true
}

func cloneMap(in map[string]any) map[string]any {
	if len(in) == 0 {
		return nil
	}
	out := make(map[string]any, len(in))
	for k, v := range in {
		out[k] = v
	}
	return out
}

func mergeToolParams(base, overrides map[string]map[string]any) map[string]map[string]any {
	if len(base) == 0 && len(overrides) == 0 {
		return nil
	}
	out := make(map[string]map[string]any, len(base)+len(overrides))
	for name, params := range base {
		out[name] = cloneMap(params)
		if lower := strings.ToLower(strings.TrimSpace(name)); lower != "" && lower != name {
			out[lower] = cloneMap(params)
		}
	}
	for name, params := range overrides {
		if len(params) == 0 {
			continue
		}
		lower := strings.ToLower(strings.TrimSpace(name))
		for k := range out {
			if strings.ToLower(strings.TrimSpace(k)) == lower {
				delete(out, k)
			}
		}
		out[name] = cloneMap(params)
		if lower != "" && lower != name {
			out[lower] = cloneMap(params)
		}
	}
	return out
}

// sliceFrom extracts []string from inputs[name].
func sliceFrom(inputs map[string]any, name string) ([]string, bool) {
	v, ok := inputs[name]
	if !ok {
		return nil, false
	}
	switch x := v.(type) {
	case []string:
		return x, true
	case []any:
		out := make([]string, 0, len(x))
		for _, item := range x {
			if s, ok := item.(string); ok {
				out = append(out, s)
			}
		}
		return out, true
	}
	return nil, false
}

// nestedMapFrom extracts map[string]map[string]any from inputs[name].
func nestedMapFrom(inputs map[string]any, name string) (map[string]map[string]any, bool) {
	v, ok := inputs[name]
	if !ok {
		return nil, false
	}
	raw, ok := v.(map[string]any)
	if !ok {
		return nil, false
	}
	out := make(map[string]map[string]any, len(raw))
	for k, child := range raw {
		m, ok := child.(map[string]any)
		if !ok {
			continue
		}
		out[k] = m
	}
	return out, true
}

func agentParamFromMap(params map[string]any) AgentParam {
	return mergeAgentParam(AgentParam{
		MessageHistoryWindowSize: defaultAgentMessageHistoryWindowSize,
	}, params)
}

// init registers AgentComponent with the orchestrator-owned registry.
func init() {
	Register("Agent", func(params map[string]any) (Component, error) {
		return NewAgentComponent(agentParamFromMap(params)), nil
	})
}
