Skip to content

Commit 7ff441c

Browse files
mxmzbclaude
andcommitted
feat(core): add an optional error field to TOOL_CALL_RESULT
ToolMessage carries an optional `error`. ToolCallResultEvent does not, so a UI rendering a live stream cannot show a failed tool call as failed — it has to wait for the MESSAGES_SNAPSHOT that carries the finished message, which arrives after the run and too late to be useful. Reported by S&P Global. Adds the field to TypeScript, Python and .NET as the event-side twin of ToolMessage.error, matching its naming, optionality and doc style. Purely additive: an absent error behaves exactly as today, and the tests assert that a stream produced before this field existed re-serializes byte-identically. No null tolerance, matching every field added since PNI-199 — absent is the only spelling and an explicit null fails the parse. Two changes beyond the schema, because the schema edit alone leaves the field incoherent: - @ag-ui/client threads `error` onto the ToolMessage that defaultApplyEvents accumulates. Without it the message built from the stream and the one in the MESSAGES_SNAPSHOT disagree about whether the call failed. - sdks/fixtures/null-omission.json gains tool_call_result_with_error, holding all three SDKs to the same wire text for the present case, as the existing tool_call_result_without_role already does for the absent one. The TypeScript suite carries a compile-time assertion alongside the runtime ones. BaseEventSchema passes unknown keys through, so deleting the field from the schema leaves five of six runtime cases green — the value survives parsing as an unrecognized key. Python has the same trap via extra="allow" and is guarded by asserting on model_fields. Both verified by deleting the field and watching the suites fail. Out of scope, deliberately: protobuf (TOOL_CALL_RESULT is not in the schema at all, tracked as PNI-214), producers actually populating the field, and the community SDKs, which are already behind core on subagentRunId. Refs PNI-362. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAPvqoeqhoEhtxChZHMFoE
1 parent 6fd81c2 commit 7ff441c

12 files changed

Lines changed: 471 additions & 1 deletion

File tree

docs/sdk/js/core/events.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,7 @@ type ToolCallResultEvent = BaseEvent & {
341341
toolCallId: string
342342
content: string
343343
role?: "tool"
344+
error?: string
344345
}
345346
```
346347
@@ -350,6 +351,11 @@ type ToolCallResultEvent = BaseEvent & {
350351
| `toolCallId` | `string` | Matches the ID from the corresponding ToolCallStartEvent |
351352
| `content` | `string` | The actual result/output content from the tool execution |
352353
| `role` | `"tool"` (optional) | Optional role identifier, typically "tool" for tool results |
354+
| `error` | `string` (optional) | Failure detail when the call failed; absent when it succeeded |
355+
356+
`error` is the event-side twin of `ToolMessage.error`. It lets a client render a
357+
failed tool call as it streams, instead of waiting for the `MESSAGES_SNAPSHOT`
358+
that carries the finished message.
353359
354360
## State Management Events
355361

docs/sdk/python/core/events.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,7 @@ class ToolCallResultEvent(BaseEvent):
339339
tool_call_id: str
340340
content: str
341341
role: Optional[Literal["tool"]] = None
342+
error: Optional[str] = None
342343
```
343344

344345
| Property | Type | Description |
@@ -347,6 +348,11 @@ class ToolCallResultEvent(BaseEvent):
347348
| `tool_call_id` | `str` | Matches the ID from the corresponding ToolCallStartEvent |
348349
| `content` | `str` | The actual result/output content from the tool execution |
349350
| `role` | `Optional[Literal["tool"]]` | Optional role identifier, typically "tool" for tool results |
351+
| `error` | `Optional[str]` | Failure detail when the call failed; absent when it succeeded |
352+
353+
`error` is the event-side twin of `ToolMessage.error`. It lets a client render a
354+
failed tool call as it streams, instead of waiting for the `MESSAGES_SNAPSHOT`
355+
that carries the finished message.
350356

351357
## State Management Events
352358

sdks/dotnet/src/AGUI.Abstractions/Events/ToolCallResultEvent.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ public sealed class ToolCallResultEvent : BaseEvent
2323
[JsonPropertyName("role")]
2424
public string? Role { get; set; }
2525

26+
/// <summary>
27+
/// Gets or sets the failure detail for this tool call, absent when the call succeeded.
28+
/// The event-side twin of <see cref="AGUIToolMessage.Error"/>, so a consumer can render
29+
/// the failure from the live stream instead of waiting for the messages snapshot.
30+
/// </summary>
31+
[JsonPropertyName("error")]
32+
public string? Error { get; set; }
33+
2634
/// <summary>
2735
/// Gets or sets the subagent that produced this event, absent when the parent agent
2836
/// produced it directly.

sdks/dotnet/src/AGUI.Abstractions/PublicAPI.Unshipped.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,8 @@ AGUI.Abstractions.ToolCallEndEvent.SubagentRunId.get -> string?
873873
AGUI.Abstractions.ToolCallEndEvent.SubagentRunId.set -> void
874874
AGUI.Abstractions.ToolCallResultEvent.SubagentRunId.get -> string?
875875
AGUI.Abstractions.ToolCallResultEvent.SubagentRunId.set -> void
876+
AGUI.Abstractions.ToolCallResultEvent.Error.get -> string?
877+
AGUI.Abstractions.ToolCallResultEvent.Error.set -> void
876878
AGUI.Abstractions.ToolCallStartEvent.SubagentRunId.get -> string?
877879
AGUI.Abstractions.ToolCallStartEvent.SubagentRunId.set -> void
878880
const AGUI.Abstractions.AGUIEventTypes.SubagentError = "SUBAGENT_ERROR" -> string!

sdks/dotnet/tests/AGUI.Abstractions.UnitTests/ToolCallResultEventTest.cs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,104 @@ public void Deserialize_ViaBaseEvent_ReturnsCorrectType()
9090
Assert.Equal("msg-3", typed.MessageId);
9191
Assert.Equal("done", typed.Content);
9292
}
93+
94+
// `Error` is the event-side twin of AGUIToolMessage.Error: set when the tool call
95+
// failed, so a consumer can render the failure from the live stream rather than
96+
// waiting for the messages snapshot that carries the finished message.
97+
98+
[Fact]
99+
public void Serialize_IncludesError_WhenSet()
100+
{
101+
var evt = new ToolCallResultEvent
102+
{
103+
ToolCallId = "call-1",
104+
MessageId = "msg-1",
105+
Content = string.Empty,
106+
Error = "SearchTimeout: upstream did not respond within 30s"
107+
};
108+
109+
var json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ToolCallResultEvent);
110+
using var doc = JsonDocument.Parse(json);
111+
112+
Assert.Equal(
113+
"SearchTimeout: upstream did not respond within 30s",
114+
doc.RootElement.GetProperty("error").GetString());
115+
}
116+
117+
[Fact]
118+
public void Serialize_OmitsError_WhenNull()
119+
{
120+
var evt = new ToolCallResultEvent
121+
{
122+
ToolCallId = "call-1",
123+
MessageId = "msg-1",
124+
Content = "ok"
125+
};
126+
127+
var json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ToolCallResultEvent);
128+
using var doc = JsonDocument.Parse(json);
129+
130+
Assert.False(doc.RootElement.TryGetProperty("error", out _));
131+
}
132+
133+
[Fact]
134+
public void Serialize_KeepsEmptyStringError()
135+
{
136+
// WhenWritingNull omits null, not empty — an empty string is a value the
137+
// producer chose to send, and dropping it would read as "the call succeeded".
138+
var evt = new ToolCallResultEvent
139+
{
140+
ToolCallId = "call-1",
141+
MessageId = "msg-1",
142+
Content = "ok",
143+
Error = string.Empty
144+
};
145+
146+
var json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ToolCallResultEvent);
147+
using var doc = JsonDocument.Parse(json);
148+
149+
Assert.True(doc.RootElement.TryGetProperty("error", out var error));
150+
Assert.Equal(string.Empty, error.GetString());
151+
}
152+
153+
[Fact]
154+
public void Deserialize_RoundTripsError()
155+
{
156+
var evt = new ToolCallResultEvent
157+
{
158+
ToolCallId = "call-2",
159+
MessageId = "msg-2",
160+
Content = string.Empty,
161+
Role = "tool",
162+
Error = "boom"
163+
};
164+
165+
var json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ToolCallResultEvent);
166+
var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.ToolCallResultEvent);
167+
168+
Assert.NotNull(deserialized);
169+
Assert.Equal("boom", deserialized.Error);
170+
}
171+
172+
[Fact]
173+
public void Deserialize_ViaBaseEvent_CarriesError()
174+
{
175+
var json = """{"type":"TOOL_CALL_RESULT","toolCallId":"call-3","messageId":"msg-3","content":"","error":"boom"}""";
176+
var evt = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.BaseEvent);
177+
178+
var typed = Assert.IsType<ToolCallResultEvent>(evt);
179+
Assert.Equal("boom", typed.Error);
180+
}
181+
182+
[Fact]
183+
public void Deserialize_ExistingEventWithoutError_LeavesItNull()
184+
{
185+
// The additive guarantee: an event from before this field existed is
186+
// unchanged, and reads back with no error.
187+
var json = """{"type":"TOOL_CALL_RESULT","toolCallId":"call-4","messageId":"msg-4","content":"done","role":"tool"}""";
188+
var evt = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.BaseEvent);
189+
190+
var typed = Assert.IsType<ToolCallResultEvent>(evt);
191+
Assert.Null(typed.Error);
192+
}
93193
}

sdks/fixtures/null-omission.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,29 @@
287287
"content": "{\"hits\":2}"
288288
}
289289
},
290+
{
291+
"name": "tool_call_result_with_error",
292+
"producedBy": [
293+
"typescript",
294+
"python",
295+
"dotnet"
296+
],
297+
"note": "The failure twin of the case above: when `error` IS set every SDK must carry it on the wire under that exact key, so a consumer can render the failure from the stream rather than waiting for MESSAGES_SNAPSHOT. The preceding case pins the absent half.",
298+
"input": {
299+
"type": "TOOL_CALL_RESULT",
300+
"messageId": "msg_3",
301+
"toolCallId": "tc_2",
302+
"content": "",
303+
"error": "SearchTimeout: upstream did not respond within 30s"
304+
},
305+
"expected": {
306+
"type": "TOOL_CALL_RESULT",
307+
"messageId": "msg_3",
308+
"toolCallId": "tc_2",
309+
"content": "",
310+
"error": "SearchTimeout: upstream did not respond within 30s"
311+
}
312+
},
290313
{
291314
"name": "state_snapshot_keeps_nulls_inside_the_snapshot",
292315
"producedBy": [

sdks/python/ag_ui/core/events.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,11 @@ class ToolCallResultEvent(BaseEvent):
232232
tool_call_id: str
233233
content: str
234234
role: Optional[Literal["tool"]] = None
235+
# The event-side twin of ToolMessage.error: set when the tool call failed,
236+
# so a consumer can render the failure from the live stream instead of
237+
# waiting for the MESSAGES_SNAPSHOT that carries the message. Absent means
238+
# the call succeeded, exactly as before this field existed.
239+
error: Optional[str] = None
235240
subagent_run_id: Optional[str] = None
236241

237242
class ThinkingStartEvent(BaseEvent):
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import json
2+
import unittest
3+
4+
from pydantic import TypeAdapter
5+
6+
from ag_ui.core.events import Event, EventType, ToolCallResultEvent
7+
8+
9+
class TestToolCallResultError(unittest.TestCase):
10+
"""The optional `error` on TOOL_CALL_RESULT — the event-side twin of ToolMessage.error."""
11+
12+
def _base(self, **overrides):
13+
kwargs = dict(
14+
type=EventType.TOOL_CALL_RESULT,
15+
message_id="msg_1",
16+
tool_call_id="tc_1",
17+
content='{"hits":2}',
18+
)
19+
kwargs.update(overrides)
20+
return ToolCallResultEvent(**kwargs)
21+
22+
def test_error_is_a_declared_field_not_an_extra(self):
23+
# ConfiguredBaseModel uses extra="allow", so simply passing error= and
24+
# reading it back would pass even if the field were never declared.
25+
# model_fields is the assertion that actually proves the declaration.
26+
self.assertIn("error", ToolCallResultEvent.model_fields)
27+
28+
def test_defaults_to_none_and_is_omitted_from_the_wire(self):
29+
event = self._base()
30+
self.assertIsNone(event.error)
31+
self.assertNotIn("error", json.loads(event.model_dump_json(by_alias=True)))
32+
33+
def test_carries_a_real_error_string(self):
34+
event = self._base(content="", error="SearchTimeout: upstream did not respond within 30s")
35+
self.assertEqual(event.error, "SearchTimeout: upstream did not respond within 30s")
36+
self.assertEqual(
37+
json.loads(event.model_dump_json(by_alias=True))["error"],
38+
"SearchTimeout: upstream did not respond within 30s",
39+
)
40+
41+
def test_empty_string_error_survives_rather_than_being_dropped(self):
42+
# Omission applies to None, not to a falsy value the producer chose to
43+
# send. An empty string must not silently become "the call succeeded".
44+
event = self._base(error="")
45+
self.assertEqual(event.error, "")
46+
self.assertEqual(json.loads(event.model_dump_json(by_alias=True))["error"], "")
47+
48+
def test_round_trips_through_json(self):
49+
event = self._base(content="", error="boom")
50+
restored = ToolCallResultEvent.model_validate_json(event.model_dump_json(by_alias=True))
51+
self.assertEqual(restored.error, "boom")
52+
self.assertEqual(restored.tool_call_id, "tc_1")
53+
54+
def test_discriminated_union_still_resolves_tool_call_result(self):
55+
adapter = TypeAdapter(Event)
56+
with_error = adapter.validate_python(
57+
{
58+
"type": "TOOL_CALL_RESULT",
59+
"messageId": "msg_1",
60+
"toolCallId": "tc_1",
61+
"content": "",
62+
"error": "boom",
63+
}
64+
)
65+
self.assertIsInstance(with_error, ToolCallResultEvent)
66+
self.assertEqual(with_error.error, "boom")
67+
68+
without_error = adapter.validate_python(
69+
{
70+
"type": "TOOL_CALL_RESULT",
71+
"messageId": "msg_1",
72+
"toolCallId": "tc_1",
73+
"content": "ok",
74+
}
75+
)
76+
self.assertIsInstance(without_error, ToolCallResultEvent)
77+
self.assertIsNone(without_error.error)
78+
79+
def test_a_pre_existing_event_serializes_exactly_as_before(self):
80+
# The additive guarantee: an event from before this field existed keeps
81+
# the same keys and values, so no consumer sees a change.
82+
legacy_wire = {
83+
"type": "TOOL_CALL_RESULT",
84+
"messageId": "msg_1",
85+
"toolCallId": "tc_1",
86+
"content": '{"hits":2}',
87+
"role": "tool",
88+
}
89+
event = ToolCallResultEvent.model_validate(legacy_wire)
90+
self.assertEqual(json.loads(event.model_dump_json(by_alias=True)), legacy_wire)
91+
92+
93+
if __name__ == "__main__":
94+
unittest.main()

sdks/typescript/packages/client/src/apply/__tests__/default.tool-calls.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,4 +1019,90 @@ describe("defaultApplyEvents with tool calls", () => {
10191019
}
10201020
});
10211021
});
1022+
1023+
it("carries TOOL_CALL_RESULT.error onto the tool message it accumulates into", async () => {
1024+
// Without this the streamed message and the MESSAGES_SNAPSHOT disagree
1025+
// about whether the call failed — the snapshot's ToolMessage has `error`,
1026+
// the one built from the stream would not.
1027+
const events$ = new Subject<BaseEvent>();
1028+
const initialState = {
1029+
messages: [],
1030+
state: {},
1031+
threadId: "test-thread",
1032+
runId: "test-run",
1033+
tools: [],
1034+
context: [],
1035+
};
1036+
1037+
const agent = createAgent(initialState.messages);
1038+
const result$ = defaultApplyEvents(initialState, events$, agent, []);
1039+
const stateUpdatesPromise = firstValueFrom(result$.pipe(toArray()));
1040+
1041+
events$.next({ type: EventType.RUN_STARTED } as RunStartedEvent);
1042+
events$.next({
1043+
type: EventType.TOOL_CALL_START,
1044+
toolCallId: "tool1",
1045+
toolCallName: "search",
1046+
} as ToolCallStartEvent);
1047+
events$.next({ type: EventType.TOOL_CALL_END, toolCallId: "tool1" } as ToolCallEndEvent);
1048+
events$.next({
1049+
type: EventType.TOOL_CALL_RESULT,
1050+
messageId: "res1",
1051+
toolCallId: "tool1",
1052+
content: "",
1053+
error: "SearchTimeout: upstream did not respond within 30s",
1054+
} as ToolCallResultEvent);
1055+
1056+
await new Promise((resolve) => setTimeout(resolve, 10));
1057+
events$.complete();
1058+
1059+
const stateUpdates = await stateUpdatesPromise;
1060+
const finalMessages = stateUpdates[stateUpdates.length - 1].messages ?? [];
1061+
const toolMessage = finalMessages.find((m) => m.role === "tool");
1062+
1063+
expect(toolMessage).toBeDefined();
1064+
expect((toolMessage as any).error).toBe("SearchTimeout: upstream did not respond within 30s");
1065+
});
1066+
1067+
it("leaves `error` off the tool message when the event carries none", async () => {
1068+
const events$ = new Subject<BaseEvent>();
1069+
const initialState = {
1070+
messages: [],
1071+
state: {},
1072+
threadId: "test-thread",
1073+
runId: "test-run",
1074+
tools: [],
1075+
context: [],
1076+
};
1077+
1078+
const agent = createAgent(initialState.messages);
1079+
const result$ = defaultApplyEvents(initialState, events$, agent, []);
1080+
const stateUpdatesPromise = firstValueFrom(result$.pipe(toArray()));
1081+
1082+
events$.next({ type: EventType.RUN_STARTED } as RunStartedEvent);
1083+
events$.next({
1084+
type: EventType.TOOL_CALL_START,
1085+
toolCallId: "tool1",
1086+
toolCallName: "search",
1087+
} as ToolCallStartEvent);
1088+
events$.next({ type: EventType.TOOL_CALL_END, toolCallId: "tool1" } as ToolCallEndEvent);
1089+
events$.next({
1090+
type: EventType.TOOL_CALL_RESULT,
1091+
messageId: "res1",
1092+
toolCallId: "tool1",
1093+
content: "sunny",
1094+
} as ToolCallResultEvent);
1095+
1096+
await new Promise((resolve) => setTimeout(resolve, 10));
1097+
events$.complete();
1098+
1099+
const stateUpdates = await stateUpdatesPromise;
1100+
const finalMessages = stateUpdates[stateUpdates.length - 1].messages ?? [];
1101+
const toolMessage = finalMessages.find((m) => m.role === "tool");
1102+
1103+
expect(toolMessage).toBeDefined();
1104+
// The key is absent, not present-and-undefined: the message must serialize
1105+
// identically to how it did before this field existed.
1106+
expect(Object.keys(toolMessage as object)).not.toContain("error");
1107+
});
10221108
});

0 commit comments

Comments
 (0)