arcade-mcp/libs/arcade-core/arcade_core/output.py
Francisco Or Something 1492c80fc5
TOO-627: Improve error messages for agents and Datadog (#814)
## Summary

- Improve tool call error messages across 4 libraries (arcade-core,
arcade-tdk, arcade-mcp-server, arcade-serve) so agents can self-correct
and Datadog can facet on structured fields
- Guard empty error messages, enrich input validation errors with
field-level detail, fix `@tool` decorator fallback formatting, surface
`additional_prompt_content` in MCP responses, and add structured log
extras for Datadog
- Addresses the 3 worst error patterns: generic "Error in tool input
deserialization", bare `KeyError` values, and empty `FatalToolError`
messages

**Linear:** TOO-627
**Plan:** `docs/plans/2026-04-08-improve-error-messages-handoff.md`

## Tasks

- [ ] Task 1: Guard empty error messages (arcade-core)
- [ ] Task 2: Enrich input validation error messages (arcade-core)
- [ ] Task 3: Improve `@tool` decorator error fallback (arcade-tdk)
- [ ] Task 4: Fix MCP agent-facing error response (arcade-mcp-server)
- [ ] Task 5: Add structured log extras in BaseWorker (arcade-serve)
- [ ] Task 6: Add structured log extras in MCP server
(arcade-mcp-server)

## Test plan

- [ ] Each task has dedicated unit tests verifying the new behavior
- [ ] `make test` passes after all tasks
- [ ] `make check` (ruff + mypy) passes
- [ ] Verify the 3 worst error patterns now produce actionable messages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches cross-library error formatting and logging behavior used in
production tool execution paths; while mostly additive/guardrails, it
changes agent-visible messages and Datadog log facets, which could
impact client expectations and alerting.
> 
> **Overview**
> Improves tool-call error handling across core/runtime, MCP transport,
worker transport, and the TDK to make agent-visible failures more
actionable while *reducing sensitive-data leakage*.
> 
> In `arcade-core`, empty error messages now get placeholders,
`ToolOutputFactory.fail*` defaults blank messages, and input validation
errors are rewritten as field-level summaries that intentionally omit
rejected values (avoiding Pydantic echo of secrets). The `@tool`
fallback in `arcade-tdk` no longer surfaces `str(exception)` to agents;
it returns exception *type-only* in `message` while preserving full
detail in `developer_message`.
> 
> Adds a shared `build_tool_error_log_extra` helper and updates
`arcade-serve` + `arcade-mcp-server` to emit consistent structured
WARNING logs (`error_*`, `tool_name`, optional toolkit/version) for
Datadog, while MCP error responses now append
`additional_prompt_content` and force `structuredContent=None` on
failures per spec. Includes extensive new tests and bumps package
versions (`arcade-core` 4.6.2, `arcade-tdk` 3.6.1, `arcade-mcp-server`
1.19.3, `arcade-serve` 3.2.3).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
e5c7ebcaf56176cfbd8e6d1f2b6295352abd0ec0. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:10:51 -03:00

126 lines
4.2 KiB
Python

from typing import Any, TypeVar
from pydantic import BaseModel
from arcade_core.errors import ErrorKind
from arcade_core.schema import ToolCallError, ToolCallLog, ToolCallOutput
from arcade_core.utils import coerce_empty_list_to_none
T = TypeVar("T")
class ToolOutputFactory:
"""
Singleton pattern for unified return method from tools.
"""
def success(
self,
*,
data: T | None = None,
logs: list[ToolCallLog] | None = None,
) -> ToolCallOutput:
# Extract the result value
"""
Extracts the result value for the tool output.
The executor guarantees that `data` is either a string, a dict, or None.
"""
value: str | int | float | bool | dict | list | None
if data is None:
value = ""
elif hasattr(data, "result"):
result = getattr(data, "result", "")
# Handle None result the same way as None data
if result is None:
value = ""
# If the result is a BaseModel (e.g., from TypedDict conversion), convert to dict
elif isinstance(result, BaseModel):
value = result.model_dump()
# If the result is a list, check if it contains BaseModel objects
elif isinstance(result, list):
value = [
item.model_dump() if isinstance(item, BaseModel) else item for item in result
]
else:
value = result
elif isinstance(data, BaseModel):
value = data.model_dump()
elif isinstance(data, (str, int, float, bool, list, dict)):
value = data
else:
raise ValueError(f"Unsupported data output type: {type(data)}")
logs = coerce_empty_list_to_none(logs)
return ToolCallOutput(
value=value,
logs=logs,
)
def fail(
self,
*,
message: str,
developer_message: str | None = None,
stacktrace: str | None = None,
logs: list[ToolCallLog] | None = None,
additional_prompt_content: str | None = None,
retry_after_ms: int | None = None,
kind: ErrorKind = ErrorKind.UNKNOWN,
can_retry: bool = False,
status_code: int | None = None,
extra: dict[str, Any] | None = None,
) -> ToolCallOutput:
if not message or not message.strip():
message = "Unspecified error during tool execution"
return ToolCallOutput(
error=ToolCallError(
message=message,
developer_message=developer_message,
can_retry=can_retry,
additional_prompt_content=additional_prompt_content,
retry_after_ms=retry_after_ms,
stacktrace=stacktrace,
kind=kind,
status_code=status_code,
extra=extra,
),
logs=coerce_empty_list_to_none(logs),
)
def fail_retry(
self,
*,
message: str,
developer_message: str | None = None,
additional_prompt_content: str | None = None,
retry_after_ms: int | None = None,
stacktrace: str | None = None,
logs: list[ToolCallLog] | None = None,
kind: ErrorKind = ErrorKind.TOOL_RUNTIME_RETRY,
status_code: int = 500,
extra: dict[str, Any] | None = None,
) -> ToolCallOutput:
"""
DEPRECATED: Use ToolOutputFactory.fail instead.
This method will be removed in version 3.0.0
"""
if not message or not message.strip():
message = "Unspecified error during tool execution"
return ToolCallOutput(
error=ToolCallError(
message=message,
developer_message=developer_message,
can_retry=True,
additional_prompt_content=additional_prompt_content,
retry_after_ms=retry_after_ms,
stacktrace=stacktrace,
kind=kind,
status_code=status_code,
extra=extra,
),
logs=coerce_empty_list_to_none(logs),
)
output_factory = ToolOutputFactory()