Skip to content

feat(engine): support MCP structuredContent and artifacts in tool results (#134) - #136

Closed
rishu685 wants to merge 4 commits into
extra-org:mainfrom
rishu685:feat/issue-134-mcp-structured-content
Closed

rishu685 wants to merge 4 commits into
extra-org:mainfrom
rishu685:feat/issue-134-mcp-structured-content

Conversation

@rishu685

@rishu685 rishu685 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #134 by introducing a provider-agnostic NormalizedToolResult abstraction that preserves MCP structuredContent and artifact metadata alongside model-facing text content throughout the runtime execution pipeline.


What Changed

  1. NormalizedToolResult Abstraction (tool_result.py):

    • Introduced NormalizedToolResult(text: str, structured: Any | None, artifact: Any | None).
    • Implemented normalize_tool_result() to parse ToolMessage.artifact["structuredContent"], dicts with structuredContent, local structured return values (dataclasses/Pydantic/dicts), and multi-block text responses.
  2. Idempotency Ledger Persistence (models.py, tool_execution_manager.py):

    • Added structured: Any | None field to ToolExecutionRecord.
    • Updated ToolExecutionManager and InMemoryToolExecutionRepository to persist and restore structured tool outputs across idempotent execution replays.
  3. Runtime Hooks Integration (hooks/models.py, tool_invoker.py):

    • Added structured and artifact fields to ToolResultContext.
    • Ensured transform_tool_result hooks preserve structured results even when text is truncated or redacted via with_result().
  4. Safety & Robustness:

    • Excluded raw structured data from standard log statements.
    • Gracefully handles malformed structured output by falling back to text representation without crashing tool execution.
  5. Test Suite Coverage:

    • Created test_tool_result_normalization.py covering MCP structuredContent, multi-block text, structured-only results, local string tools, local structured tools, replay persistence, and hook transformations.

Verification

  • ruff format & ruff check — 0 errors ✅
  • mypy src/agent_engine — 0 type errors ✅
  • pytest — 948/948 passed ✅
  • npm run test:widgetwidget self-check: OK
  • playwright test — 40/40 passed ✅

Closes #134.

…ults (extra-org#134)

- Introduce NormalizedToolResult domain model and normalize_tool_result function
- Extract model-facing text alongside structuredContent and artifact metadata for MCP and local tools
- Update ToolExecutionRecord and ToolExecutionManager to persist structured output across idempotent replays
- Expose structured and artifact fields on ToolResultContext for transform_tool_result hooks
- Add comprehensive test suite in test_tool_result_normalization.py
@Asaf-prog

Copy link
Copy Markdown
Collaborator

Thanks — the overall direction is good, and I like the introduction of a provider-agnostic NormalizedToolResult.

I still see a few correctness/design gaps before approving.

  1. Replay does not actually restore the structured result

The execution record now persists structured, but _cached_result() still returns only:

cached.result

So the lifecycle is effectively:

first execution
→ text + structured

idempotent replay
→ text only

That means the structured result survives storage, but not the runtime replay path.

The replayed execution should restore the same semantic NormalizedToolResult as the original live execution, otherwise behavior depends on whether the tool actually ran or came from the ledger.

  1. Structured-only MCP ToolMessage can still produce empty model text

The JSON fallback currently exists only in the raw-dict path.

For example:

ToolMessage(
    content=[],
    artifact={
        "structuredContent": {
            "balance": 1250
        }
    }
)

currently produces:

text = ""
structured = {"balance": 1250}

So the model still receives an empty tool result even though useful structured data exists.

Please add the fallback at the normalized-result level so structured-only results behave consistently regardless of whether the provider result arrived as a dict or ToolMessage.

A regression test should cover the real ToolMessage shape here.

  1. artifact and structured are being conflated

This logic is too broad:

elif isinstance(artifact, (dict, list)):
    structured = artifact
else:
    structured = artifact

An artifact is not necessarily structured tool output.

For example:

artifact = {
    "file_id": "abc",
    "mime_type": "application/pdf"
}

should not automatically become:

structured = artifact

structured should represent the machine-readable tool result, while artifact should remain auxiliary/provider artifact data.

Please keep those semantics separate.

  1. Hook transformations are not propagated consistently

ToolResultContext.with_result() now allows hooks to return updated:

result
structured
artifact

but ToolInvoker only consumes:

transformed.result

and later persists:

structured=normalized.structured

So if a hook intentionally transforms/redacts the structured result, that change is currently ignored and the original value is persisted.

Either:

  • structured/artifact should be read-only in the hook contract, or
  • transformed structured/artifact values must be propagated and persisted.

The current API suggests they are mutable while the runtime behaves as if they are not.

There is also an API ambiguity in with_result() because None currently means "keep the previous value", which makes it impossible for a hook to explicitly clear structured/artifact data.

One smaller design concern: I would avoid using arbitrary str() fallback for unknown structured/provider values, since that can reintroduce Python-repr-style model content. The normalization boundary should remain deterministic and explicit.

The core architecture is heading in the right direction, but I think these cases need to be fixed so the result contract is consistent across:

live execution
replay
hooks
structured-only MCP results
artifact handling

Once those invariants are aligned, I think this will be in a much stronger state.

…ructured-content

# Conflicts:
#	src/agent_engine/approvals/in_memory_tool_execution_repository.py
#	src/agent_engine/approvals/models.py
#	src/agent_engine/approvals/tool_execution_manager.py
#	src/agent_engine/approvals/tool_execution_repository.py
#	src/agent_engine/engine/langgraph/tools/tool_invoker.py
#	src/agent_engine/runtime/hooks/models.py
@Asaf-prog

Copy link
Copy Markdown
Collaborator

Thanks for the work on this and for addressing the previous review feedback.

Since this PR was opened, #135 has been merged into main and now implements the structured tool-result contract from #134 end-to-end, including normalization, persistence/replay, hooks, artifact handling, and structured-only results.

At this point, merging #136 would introduce a second NormalizedToolResult / normalization path alongside the implementation that is already in main, and the new path is not connected to the production ToolInvoker.

So I don't think it makes sense to continue iterating on this PR.

I'm going to close this as superseded by #135.

Thanks again for the contribution — the direction here was valid, but the underlying issue has now been implemented through the other PR.

@Asaf-prog Asaf-prog closed this Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support MCP structuredContent and Artifacts in Tool Results

2 participants