Skip to content

Conversation

aron
Copy link

@aron aron commented Sep 10, 2025

Background

Currently the addToolResult() helper allows a user to update a pending tool call evaluated on the client. However, there is not currently a mechanism for being able to report a pending tool call as errored with a state of output-error.

We would like to be able to use this to report client side errors with failed tool calls as well as cancellations.

There doesn't appear to be a way to replicate the behavior of the addToolResult implementation using just the public API provided by useChat.

So as a workaround we currently have to use addToolResult and add an additional typed field on our tool output with status: "success" | "error" and consider failed tool calls "output" then adapt the rendering in our application. This differs to how we handle server side errors with tool calls which will use the output-error state.

const { messages, sendMessage, addToolResult } = useChat({
  transport: new DefaultChatTransport({
    api: '/api/chat',
  }),

  sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,

  // run client-side tools that are automatically executed:
  async onToolCall({ toolCall }) {
    // Check if it's a dynamic tool first for proper type narrowing
    if (toolCall.dynamic) {
      return;
    }

    if (toolCall.toolName === 'getWeatherInformation') {
      try {
        const weather = await getWeatherInformation(toolCall.input);

        // No await - avoids potential deadlocks
        addToolResult({
          tool: 'getWeatherInformation',
          toolCallId: toolCall.toolCallId,
          output: {
            status: "success",
            value: cities[Math.floor(Math.random() * cities.length)],
          }
        });
      } catch (err) {
        addToolResult({
          tool: 'getWeatherInformation',
          toolCallId: toolCall.toolCallId,
          output: {
            status: "error",
            value: "Failed to get weather information",
          }
        });
      }
    }
  },
});

Then in our UI code when handling the tool part:

const status = useMemo((): React.ReactNode => {
  if (cancelled) {
    return <ErrorIcon />;
  }

  switch (state) {
    case "input-available":
    case "input-streaming":
      return (
        <Spinner />
      );
    case "output-available":
      // Duplicate error handling.
      if (output.type === "error") {
        return (
          <ErrorIcon />
        );
      }
      return (
        <SuccessIcon />
      );
    case "output-error":
      return (
        <ErrorIcon />
      );
    default:
      const exhaustive: never = state;
      throw new Error(`Unexpected state: ${exhaustive}`);
  }
}, [state, output, cancelled]);

Summary

This commit extends the addToolResult method on the AbstractChat class to optionally take an output-error result. This allows clients to report both successful and failed tool calls using the same mechanism.

addToolResult({ tool, toolCallId, state: "output-error", errorText: "Failed" });

For backwards compatibility the existing method interface is still supported and will default to a state of output-available.

addToolResult({ tool, toolCallId, output: "Success!" });

So my earlier example becomes:

const { messages, sendMessage, addToolResult } = useChat({
  transport: new DefaultChatTransport({
    api: '/api/chat',
  }),

  sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,

  // run client-side tools that are automatically executed:
  async onToolCall({ toolCall }) {
    // Check if it's a dynamic tool first for proper type narrowing
    if (toolCall.dynamic) {
      return;
    }

    if (toolCall.toolName === 'getWeatherInformation') {
      try {
        const weather = await getWeatherInformation(toolCall.input);

        // No await - avoids potential deadlocks
        addToolResult({
          tool: 'getWeatherInformation',
          toolCallId: toolCall.toolCallId,
          output: cities[Math.floor(Math.random() * cities.length)],
        });
      } catch (err) {
        addToolResult({
          tool: 'getWeatherInformation',
          toolCallId: toolCall.toolCallId,
          state: "output-error",
          errorText: "Failed to get weather information",
        });
      }
    }
  },
});

Integration style tests have been added to the existing chat.test.ts suite to exercise the new behavior in the same fashion as the current implementation.

It also updates the lastAssistantMessageIsCompleteWithToolCalls helper function to also consider tool parts with a state of output-error a completed tool call. This ensures consistent behavior for both types of result.

Notes on implementation

I've extended addToolResult to keep the API footprint minimal but I would appreciate feedback on whether an additional method e.g. addToolError({ tool, toolCallId, errorText }) would be a more appropriate solution. I'm happy to change the code if needed.

const { messages, sendMessage, addToolResult } = useChat({
  transport: new DefaultChatTransport({
    api: '/api/chat',
  }),

  sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,

  // run client-side tools that are automatically executed:
  async onToolCall({ toolCall }) {
    // Check if it's a dynamic tool first for proper type narrowing
    if (toolCall.dynamic) {
      return;
    }

    if (toolCall.toolName === 'getWeatherInformation') {
      try {
        const weather = await getWeatherInformation(toolCall.input);

        // No await - avoids potential deadlocks
        addToolResult({
          tool: 'getWeatherInformation',
          toolCallId: toolCall.toolCallId,
          output: cities[Math.floor(Math.random() * cities.length)],
        });
      } catch (err) {
        addToolError({
          tool: 'getWeatherInformation',
          toolCallId: toolCall.toolCallId,
          errorText: "Failed to get weather information",
        });
      }
    }
  },
});

Manual Verification

I couldn't figure out how to correctly npm link the @ai-sdk/react and ai packages into our local project so we're currently running the it with a monkey patched version of addToolResult and lastAssistantMessageIsCompleteWithToolCalls copy & pasted into our codebase.

Tasks

  • Tests have been added / updated (for bug fixes / features)
  • Documentation has been added / updated (for bug fixes / features)
  • A patch changeset for relevant packages has been added (for bug fixes / features - run pnpm changeset in the project root)
  • Formatting issues have been fixed (run pnpm prettier-fix in the project root)
  • I have reviewed this pull request (self-review)

Future Work

None

Related Issues

None

@aron aron force-pushed the add-tool-result-error-output branch from 24eafe2 to b1265dc Compare September 10, 2025 11:53
@aron aron force-pushed the add-tool-result-error-output branch from b1265dc to 3f9f067 Compare September 10, 2025 15:42
@gr2m gr2m self-assigned this Sep 11, 2025
@gr2m
Copy link
Collaborator

gr2m commented Sep 11, 2025

Hi Aron 👋🏼 thanks for the pull request! It's a great, especially given that it's your first 👏🏼

@lgrammel and I looked at it today. The CI is failing and we had some thoughts on how to improve the code, we thought the easiest would be to just push the changes we suggest: e218283 let us know what you think.

I would appreciate feedback on whether an additional method e.g. addToolError({ tool, toolCallId, errorText }) would be a more appropriate solution

We think the best solution would be to keep it at one method, but to rename it from addToolResult to addToolOutput, which would also better align with our overall naming convention. We can do that in a follow up to this pull request.

As a side note, we are looking deeper into tool calling right now, in particular in regard of human-in-the-loop. Stay tuned!

@gr2m gr2m requested a review from nicoalbanese September 11, 2025 04:06
@gr2m
Copy link
Collaborator

gr2m commented Sep 11, 2025

@nicoalbanese can you please review the docs updates?

@aron aron force-pushed the add-tool-result-error-output branch from 3f9f067 to 9396fdf Compare September 11, 2025 08:42
@aron
Copy link
Author

aron commented Sep 11, 2025

@lgrammel and I looked at it today. The CI is failing and we had some thoughts on how to improve the code, we thought the easiest would be to just push the changes we suggest: e218283 let us know what you think.

Changes look great, the argument types are much clearer now. I've integrated them into this branch and force pushed.

As a side note, we are looking deeper into tool calling right now, in particular in regard of human-in-the-loop. Stay tuned!

Fantastic, looking forward to it!

Comment on lines 349 to 484
Yes
</button>
<button
onClick={() =>
addToolResult({
tool: 'askForConfirmation',
toolCallId: callId,
output: 'No, denied',
})
}
>
No
</button>
</div>
</div>
);
case 'output-available':
return (
<div key={callId}>
Location access allowed: {part.output}
</div>
);
case 'output-error':
return <div key={callId}>Error: {part.errorText}</div>;
}
break;
}

case 'tool-getLocation': {
const callId = part.toolCallId;

switch (part.state) {
case 'input-streaming':
return (
<div key={callId}>Preparing location request...</div>
);
case 'input-available':
return <div key={callId}>Getting location...</div>;
case 'output-available':
return <div key={callId}>Location: {part.output}</div>;
case 'output-error':
return (
<div key={callId}>
Error getting location: {part.errorText}
</div>
);
}
break;
}

case 'tool-getWeatherInformation': {
const callId = part.toolCallId;

switch (part.state) {
// example of pre-rendering streaming tool inputs:
case 'input-streaming':
return (
<pre key={callId}>{JSON.stringify(part, null, 2)}</pre>
);
case 'input-available':
return (
<div key={callId}>
Getting weather information for {part.input.city}...
</div>
);
case 'output-available':
return (
<div key={callId}>
Weather in {part.input.city}: {part.output}
</div>
);
case 'output-error':
return (
<div key={callId}>
Error getting weather for {part.input.city}:{' '}
{part.errorText}
</div>
);
}
break;
}
}
})}
<br />
</div>
))}

<form
onSubmit={e => {
e.preventDefault();
if (input.trim()) {
sendMessage({ text: input });
setInput('');
}
}}
>
<input value={input} onChange={e => setInput(e.target.value)} />
</form>
</>
);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we cut all the markup of the component? the important thing to highlight here is the onToolCall and addToolResult state.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done 👍 I wasn't sure if the examples were intended to be fully-working examples.

@nicoalbanese
Copy link
Collaborator

@gr2m happy with this once comments are resolved!

aron and others added 4 commits September 16, 2025 11:31
This commit updates the `lastAssistantMessageIsCompleteWithToolCalls`
helper function to also consider tool parts with a state of
`output-error` a completed tool call.

Co-Authored-By: Lars Grammel <[email protected]>
This commit extends the `addToolResult` method on the `AbstractChat`
class to optionally take an `output-error` result. This allows clients
to report both successful and failed tool calls using the same
mechanism.

    addToolResult({ state: "output-error", errorText: "Failed" });

For backwards compatibility the existing method interface is still
supported and will default to a state of `output-available`.

Co-Authored-By: Lars Grammel <[email protected]>
@aron aron force-pushed the add-tool-result-error-output branch from 2cf60db to 065731e Compare September 16, 2025 10:35
@aron
Copy link
Author

aron commented Sep 16, 2025

Thanks both, I've made the requested changes and rebased.

@aron aron requested a review from nicoalbanese September 16, 2025 10:39
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.

3 participants