fix(agent-harness): accept MCP image/audio/resource tool results - #73
fix(agent-harness): accept MCP image/audio/resource tool results#73EnesYilmazcode wants to merge 2 commits into
Conversation
A tool call that returned a non-text MCP content block failed CallToolResponseSchema.parse, and the resulting ZodError was truncated at its first newline, so the model was handed the literal string "Error: [" for a call that had actually succeeded. - widen ToolCallOutputContentItemSchema to the MCP content-block union (text, image, audio, resource_link, resource), keeping the OpenAI image_url shape for inbound message history - summarize ZodError issues instead of splitting on the first newline - render non-text blocks as a one-line descriptor before they enter the chat-completions payload - add harness unit tests plus a CI job to run them
| - uses: actions/checkout@v4 | ||
| - uses: actions/setup-node@v4 |
There was a problem hiding this comment.
The new workflow executes actions/checkout and actions/setup-node through mutable v4 tags on qualifying pull requests and pushes to main. Pinning these actions to full commit SHAs prevents upstream tag movement from changing executable CI code without a reviewed repository change.
How this was verified: Both newly added uses entries reference major-version tags rather than immutable commit SHAs.
Prompt To Fix With AI
This is a comment left during a code review.
Path: .github/workflows/agent-harness-tests.yml
Line: 22-23
Comment:
**Mutable CI action references**
The new workflow executes `actions/checkout` and `actions/setup-node` through mutable `v4` tags on qualifying pull requests and pushes to `main`. Pinning these actions to full commit SHAs prevents upstream tag movement from changing executable CI code without a reviewed repository change.
**How this was verified:** Both newly added `uses` entries reference major-version tags rather than immutable commit SHAs.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
|
Thanks. I left these as major-version tags deliberately, to match the workflow they sit beside: Happy to go either way. If you would rather pin to SHAs, I think it is worth doing across both workflows in one change rather than just this one, and I am glad to do that here or in a separate PR, whichever the maintainers prefer. |
…rors Review follow-ups on the content-block widening: - formatToolCallError: Zod 3 collapses a failed union into one invalid_union issue whose message is "Invalid input", with the real detail in unionErrors. Recurse into them and cap the result, so the model gets 'content.1: unrecognized type "nonsense", expected one of ...' instead of 'content.1: Invalid input', and 40 bad blocks yield 313 chars, not 1068. - Revert the widening on the inbound side. ToolCallOutputContentItemSchema also backs RunAgentAPIRequestBodySchema.messages, so callers could POST raw MCP blocks that get forwarded to the provider verbatim. Tool results are widened; request history stays Chat-Completions shaped. - Typecheck tests in CI. npm test runs through tsx, which strips types without checking, and tsconfig.json only includes src, so a test with real type errors went green. Adds tsconfig.test.json and an npm run typecheck step. - Drop met-museum from the test comments: prunedTools already forces returnImage=false for it, so it cannot emit an image here. - Note why the union is hand-rolled: the SDK's ContentBlockSchema is Zod 4 and this package pins zod@^3, so the two cannot be composed.
db0679b to
e88f5c0
Compare
Why
A successful MCP tool call that returns a non-text content block reaches the model as the literal string
Error: [.ToolCallOutputContentItemSchemaaccepted{type:'text'}and{type:'image', image_url:{url}}. That second shape comes from the OpenAI chat format; MCP does not use it. The sandbox returns MCP blocks unchanged (agent_environment/main.py:177-183returnsresult.content), so an image block arrives as{type:'image', data, mimeType}and fails the parse atagent-eval.ts:259. The catch block does.split('\n')[0]on the message, and a ZodError's message is pretty-printed JSON starting with[\n, so the first line is a bare[. The model is told a working tool call failed and is given nothing to recover from.It is reachable on the current pins:
desktop-commander@0.2.7read_fileandread_multiple_filesreturn text plus image for any image path (dist/handlers/filesystem-handlers.js:41-53,:89-101). Nothing suppresses them. They are enabled in 57 of the 500 public tasks, and PNGs ship in the sandbox at/data/repos/storyteller/example/*.pngand/data/repos/slackr/man/figures/logo.pngvia the pinned submodules.filesystem@2026.7.10read_media_filereturns image, audio and resource (dist/index.js:246-252), enabled in 39 tasks.Narrower than it first looks:
sandbox-client.ts:257-263requires a non-empty text block before passing a result through, so an image-only result becomes the stringsuccessbefore the schema ever sees it. Only a mixed result with non-empty text reaches the parse.Changes
types.tswidensToolCallOutputContentItemSchemato the MCP content-block union (text, image, audio, resource_link, resource) for tool responses.schema.tsimports that union forCallToolResponseSchema, and keeps a separate narrowInboundToolContentItemSchemaforToolCallOutputMessageSchema. Those two shared one symbol before. Widening both would have loosened thePOST /v2/mcp_eval/run_agentrequest contract so a caller could submit raw base64 that flows to the provider unvalidated, so the inbound side stays exactly as it was.errors.tsaddsformatToolCallError. Zod 3 collapses a failed union into a singleinvalid_unionissue whose message is literally"Invalid input", with the real detail inissue.unionErrors, so this recurses into those, prefers the branch whosetypeliteral matched, and caps the result at 300 chars.helpers/tool-content.tsaddsdescribeNonTextContent, applied beforecapToolContent. Tool results go verbatim into a Chat Completions payload, which carries text parts only, so widening the schema on its own would replaceError: [with a provider 400 that ends the agent loop. Non-text blocks become a descriptor like[image content omitted: image/png]. Text items are returned by reference, so text-only results are byte-for-byte unchanged.tests/tool-output-schema.test.ts, atypecheckscript withtsconfig.test.json, and.github/workflows/agent-harness-tests.yml. The harness has no CI today, so the tests would otherwise never run.tsconfig.jsononly includessrc/**/*andtsxstrips types without checking, so the build alone does not typecheck tests. Drop the workflow if you would rather wire up CI yourself.The union is hand-written rather than imported from
@modelcontextprotocol/sdk, which would be the obvious thing to do. It is not possible here: the SDK'sContentBlockSchemais a Zod 4 schema and this harness pinszod@^3.22.4(resolved 3.25.76).CB instanceof z.ZodTypeisfalseandz.union([ContentBlockSchema, ...])throwsoption._parseSync is not a function.Test plan
What the model actually receives for the original failing payload:
Error: [Error: content.1: unrecognized type "nonsense", expected one of "text", "image", "audio", "resource_link", "resource"Forty bad blocks go from a 1068-char error string to 313 chars ending in
… (40 issues).errors.tsandschema.tswhile keeping the tests fails exactly the four new ones, includingactual: 'content.1: Invalid input'.Invalid literal value, expected "text".describeNonTextContent, fails a test.assert.equal(described[0], textItem)), so a refactor that copies text items fails the test.TS2322to the test file leavesnpm run buildgreen and is caught only bynpm run typecheck.Not run: the Docker sandbox and a live LLM call, so there is no end-to-end trace, and the provider-400 reasoning behind
describeNonTextContentfollows from the Chat Completions contract rather than a measurement. The three-line wiring inagent-eval.tsis covered by typecheck and reading only, since exercising it needs an MCP client, a LiteLLM endpoint and a live sandbox.Notes
prunedToolsatagent-eval.ts:333force-setsreturnImage = falseformet-museum_get-museum-object, commentedprevent images from being returned. That workaround looks like it exists because of this bug. I left it in place, since removing it would re-enable images on 85 tasks and change token cost, so that is a maintainer decision.Separate and not fixed here:
sandbox-client.ts:257-263replaces an image-only result with the stringsuccessbefore the schema sees it, so that result is lost silently rather than erroring. The comment there suggests it is deliberate.Image bytes are described rather than forwarded, since there is no vision path today and Chat Completions does not carry images in a
toolmessage.🤖 Generated with Claude Code
Greptile Summary
The PR expands MCP tool-result validation and converts non-text results into provider-compatible text descriptions while improving validation errors and adding harness CI coverage.
Confidence Score: 3/5
The PR is not yet safe to merge because its new workflow still executes mutable GitHub Action references on pull requests and pushes.
The tool-result handling changes do not leave a newly eligible blocking failure, but the previously reported CI supply-chain issue remains: both action dependencies are still selected through movable
v4tags rather than immutable commit SHAs.Files Needing Attention: .github/workflows/agent-harness-tests.yml
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart LR A[MCP tool result] --> B[Validate content blocks] B --> C[Describe non-text content] C --> D[Apply output cap] D --> E[Send tool message to model] B -->|Validation failure| F[Format concise error] F --> EReviews (2): Last reviewed commit: "fix(agent-harness): keep inbound tool co..." | Re-trigger Greptile