diff --git a/internal/agent/component/agent.go b/internal/agent/component/agent.go index 233d9e07e8..3a3a4e3d8d 100644 --- a/internal/agent/component/agent.go +++ b/internal/agent/component/agent.go @@ -538,7 +538,8 @@ func buildAgentTools(p AgentParam) ([]einotool.BaseTool, error) { // NewAgentComponent builds an AgentComponent from raw params. func NewAgentComponent(p AgentParam) *AgentComponent { if p.MaxRounds <= 0 { - p.MaxRounds = 3 + // Keep the Python Agent default (AgentParam.max_rounds = 5). + p.MaxRounds = 5 } return &AgentComponent{param: p} } @@ -627,7 +628,15 @@ func (c *AgentComponent) Invoke(ctx context.Context, inputs map[string]any) (map } } if err != nil { - return nil, fmt.Errorf("component: Agent.Invoke: %w", err) + // 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 @@ -686,6 +695,18 @@ func (c *AgentComponent) Invoke(ctx context.Context, inputs map[string]any) (map 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, inputs map[string]any) (<-chan map[string]any, error) { diff --git a/internal/agent/component/agent_test.go b/internal/agent/component/agent_test.go index cdde193e36..5f66b6cc5e 100644 --- a/internal/agent/component/agent_test.go +++ b/internal/agent/component/agent_test.go @@ -8,7 +8,7 @@ // 2. ToolCallRound: the runner returns a message with ToolCalls → // component extracts them into the tool_calls output. // 3. ExhaustRoundsError: the runner returns an error → component -// propagates it. +// exposes it through the Python-compatible _ERROR output. // 4. MissingModelID: the component rejects before calling the runner. package component @@ -324,15 +324,18 @@ func TestAgent_ToolCallRound(t *testing.T) { func TestAgent_ExhaustRoundsError(t *testing.T) { withAgentRunner(t, func(_ context.Context, _ AgentParam) (*schema.Message, error) { - return nil, errors.New("agent: exhausted rounds without final answer") + return nil, errors.New("[GraphRunError] exceeds max steps") }) c := NewAgentComponent(AgentParam{ModelID: "stub", MaxRounds: 2}) - _, err := c.Invoke(context.Background(), map[string]any{ + out, err := c.Invoke(context.Background(), map[string]any{ "user_prompt": "x", }) - if err == nil { - t.Fatal("expected error when loop exhausts without a final answer") + if err != nil { + t.Fatalf("Invoke: %v", err) + } + if got := out["_ERROR"]; got != "**ERROR**: [GraphRunError] exceeds max steps" { + t.Fatalf("_ERROR = %v, want Python-compatible error envelope", got) } }