Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ public enum ActionType
ChatTurnCancelled,
[WireValue("chat/error")]
ChatError,
[WireValue("chat/turnResume")]
ChatTurnResume,
[WireValue("chat/activityChanged")]
ChatActivityChanged,
[WireValue("chat/workingDirectorySet")]
Expand Down Expand Up @@ -1162,15 +1164,18 @@ public sealed record ChatDeltaAction
public Dictionary<string, JsonElement>? Meta { get; init; }
}

/// <summary>Structured content appended to the response.</summary>
/// <summary>Structured content appended to the response.
///
/// An {@link ErrorResponsePart} MUST be appended with {@link ChatErrorAction}
/// instead so adding the part and ending the turn are one atomic transition.</summary>
public sealed record ChatResponsePartAction
{
public ActionType Type { get; init; }

/// <summary>Turn identifier</summary>
public required string TurnId { get; init; }

/// <summary>Response part (markdown or content ref)</summary>
/// <summary>Response part to append; error parts are ignored.</summary>
public required ResponsePart Part { get; init; }

/// <summary>Additional provider-specific metadata for this action.
Expand Down Expand Up @@ -1622,8 +1627,9 @@ public sealed record ChatErrorAction
/// data.</summary>
public long Duration { get; init; }

/// <summary>Error details</summary>
public required ErrorInfo Error { get; init; }
/// <summary>Error part to append to the response stream before finalizing the turn.
/// Its optional `resumable` flag indicates whether the turn can be resumed.</summary>
public required ErrorResponsePart Part { get; init; }

/// <summary>Additional provider-specific metadata for this action.
///
Expand All @@ -1637,6 +1643,20 @@ public sealed record ChatErrorAction
public Dictionary<string, JsonElement>? Meta { get; init; }
}

/// <summary>Resumes the latest errored turn without adding another message.
///
/// The turn MUST be the latest turn, its state MUST be `error`, and its final
/// response part MUST be a resumable error. The reducer reopens the same turn
/// with its existing message, response parts, and usage intact. The host then
/// resumes the provider's execution for that turn.</summary>
public sealed record ChatTurnResumeAction
{
public ActionType Type { get; init; }

/// <summary>Identifier of the errored turn.</summary>
public required string TurnId { get; init; }
}

/// <summary>The activity description of this chat changed.
///
/// Dispatched by the server to indicate what the chat is currently doing
Expand Down Expand Up @@ -2596,6 +2616,7 @@ public StateActionConverter()
["chat/turnComplete"] = typeof(ChatTurnCompleteAction),
["chat/turnCancelled"] = typeof(ChatTurnCancelledAction),
["chat/error"] = typeof(ChatErrorAction),
["chat/turnResume"] = typeof(ChatTurnResumeAction),
["chat/activityChanged"] = typeof(ChatActivityChangedAction),
["chat/workingDirectorySet"] = typeof(ChatWorkingDirectorySetAction),
["chat/workingDirectoryRemoved"] = typeof(ChatWorkingDirectoryRemovedAction),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ namespace Microsoft.AgentHostProtocol;
[JsonSerializable(typeof(ChatTruncatedAction))]
[JsonSerializable(typeof(ChatTurnCancelledAction))]
[JsonSerializable(typeof(ChatTurnCompleteAction))]
[JsonSerializable(typeof(ChatTurnResumeAction))]
[JsonSerializable(typeof(ChatTurnsLoadedAction))]
[JsonSerializable(typeof(ChatTurnStartedAction))]
[JsonSerializable(typeof(ChatUsageAction))]
Expand Down Expand Up @@ -193,6 +194,7 @@ namespace Microsoft.AgentHostProtocol;
[JsonSerializable(typeof(DisposeSessionParams))]
[JsonSerializable(typeof(DisposeTerminalParams))]
[JsonSerializable(typeof(ErrorInfo))]
[JsonSerializable(typeof(ErrorResponsePart))]
[JsonSerializable(typeof(FetchAutomationRunsParams))]
[JsonSerializable(typeof(FetchAutomationRunsResult))]
[JsonSerializable(typeof(FetchTurnsParams))]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,8 @@ public enum ResponsePartKind
SystemNotification,
[WireValue("inputRequest")]
InputRequest,
[WireValue("error")]
Error,
}

/// <summary>Status of a tool call in the lifecycle state machine.</summary>
Expand Down Expand Up @@ -2061,10 +2063,6 @@ public sealed class Turn

/// <summary>How the turn ended</summary>
public TurnState State { get; set; }

/// <summary>Error details if state is `'error'`</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ErrorInfo? Error { get; set; }
}

/// <summary>An in-progress turn — the assistant is actively streaming.</summary>
Expand Down Expand Up @@ -2611,6 +2609,28 @@ public sealed record InputRequestResponsePart
public ChatInputResponseKind? Response { get; init; }
}

/// <summary>An error encountered while processing a turn.
///
/// This is the detailed source of truth for the error. {@link Turn.state}
/// remains {@link TurnState.Error} while the turn is stopped at this error so
/// clients can detect the terminal state without inspecting response parts.
///
/// When {@link resumable} is `true`, a client may dispatch `chat/turnResume`
/// while this is the latest turn and its state is {@link TurnState.Error}.
/// Clients decide whether and how to present that affordance.</summary>
public sealed record ErrorResponsePart
{
/// <summary>Discriminant</summary>
public ResponsePartKind Kind { get; init; }

/// <summary>Error details.</summary>
public required ErrorInfo Error { get; init; }

/// <summary>Whether the host can resume the turn from this error. Only `true` enables resume.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Resumable { get; init; }
}

/// <summary>Tool execution result details, available after execution completes.</summary>
public sealed record ToolCallResult
{
Expand Down Expand Up @@ -5508,6 +5528,7 @@ public ResponsePartConverter()
["reasoning"] = typeof(ReasoningResponsePart),
["systemNotification"] = typeof(SystemNotificationResponsePart),
["inputRequest"] = typeof(InputRequestResponsePart),
["error"] = typeof(ErrorResponsePart),
},
allowUnknown: true)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ public static bool TryGetActionType(object action, out ActionType actionType)
case ChatTurnCompleteAction value:
actionType = value.Type;
return true;
case ChatTurnResumeAction value:
actionType = value.Type;
return true;
case ChatTurnsLoadedAction value:
actionType = value.Type;
return true;
Expand Down Expand Up @@ -410,6 +413,7 @@ public static string GetWireName(ActionType actionType) =>
ActionType.ChatTruncated => "chat/truncated",
ActionType.ChatTurnCancelled => "chat/turnCancelled",
ActionType.ChatTurnComplete => "chat/turnComplete",
ActionType.ChatTurnResume => "chat/turnResume",
ActionType.ChatTurnsLoaded => "chat/turnsLoaded",
ActionType.ChatTurnStarted => "chat/turnStarted",
ActionType.ChatUsage => "chat/usage",
Expand Down
40 changes: 37 additions & 3 deletions clients/dotnet/src/AgentHostProtocol/Reducers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ private static ReduceOutcome EndTurn(
TurnState turnState,
long duration,
SessionStatus? terminalStatus,
ErrorInfo? errInfo)
ErrorResponsePart? errorPart)
{
if (state.ActiveTurn is null || state.ActiveTurn.Id != turnId)
{
Expand Down Expand Up @@ -333,6 +333,10 @@ private static ReduceOutcome EndTurn(
ToolCall = new ToolCallState(cancelled),
}));
}
if (errorPart is not null)
{
parts.Add(new ResponsePart(errorPart));
}

var turn = new Turn
{
Expand All @@ -345,7 +349,6 @@ private static ReduceOutcome EndTurn(
ResponseParts = parts,
Usage = active.Usage,
State = turnState,
Error = errInfo,
};

state.Turns.Add(turn);
Expand Down Expand Up @@ -913,6 +916,10 @@ public static ReduceOutcome ApplyToChat(ChatState state, StateAction action)
{
return ReduceOutcome.NoOp;
}
if (a.Part.Value is ErrorResponsePart)
{
return ReduceOutcome.NoOp;
}

state.ActiveTurn.ResponseParts.Add(a.Part);
return ReduceOutcome.Applied;
Expand All @@ -921,7 +928,33 @@ public static ReduceOutcome ApplyToChat(ChatState state, StateAction action)
case ChatTurnCancelledAction a:
return EndTurn(state, a.TurnId, TurnState.Cancelled, a.Duration, null, null);
case ChatErrorAction a:
return EndTurn(state, a.TurnId, TurnState.Error, a.Duration, SessionStatus.Error, a.Error);
return EndTurn(state, a.TurnId, TurnState.Error, a.Duration, SessionStatus.Error, a.Part);
case ChatTurnResumeAction a:
if (state.ActiveTurn is not null || state.Turns.Count == 0)
{
return ReduceOutcome.NoOp;
}

Turn resumableTurn = state.Turns[state.Turns.Count - 1];
if (resumableTurn.Id != a.TurnId
|| resumableTurn.State != TurnState.Error
|| resumableTurn.ResponseParts.Count == 0
|| resumableTurn.ResponseParts[resumableTurn.ResponseParts.Count - 1].Value is not ErrorResponsePart { Resumable: true })
{
return ReduceOutcome.NoOp;
}

state.Turns.RemoveAt(state.Turns.Count - 1);
state.ActiveTurn = new ActiveTurn
{
Id = resumableTurn.Id,
StartedAt = resumableTurn.StartedAt ?? state.ModifiedAt,
Message = resumableTurn.Message,
ResponseParts = resumableTurn.ResponseParts,
Usage = resumableTurn.Usage,
};
state.Status = WithStatusFlag(ChatSummaryStatus(state, null), SessionStatus.IsRead, false);
return ReduceOutcome.Applied;
case ChatActivityChangedAction a:
state.Activity = a.Activity;
return ReduceOutcome.Applied;
Expand Down Expand Up @@ -2415,6 +2448,7 @@ public static ReduceOutcome ApplyToAutomationRun(
"chat/toolCallResultConfirmed",
"chat/toolCallContentChanged",
"chat/turnCancelled",
"chat/turnResume",
"chat/pendingMessageSet",
"chat/pendingMessageRemoved",
"chat/queuedMessagesReordered",
Expand Down
45 changes: 41 additions & 4 deletions clients/go/ahp/reducers.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,14 @@ func hasOpenInputRequest(state *ahptypes.ChatState) bool {
return false
}

func hasResumableError(turn *ahptypes.Turn) bool {
if len(turn.ResponseParts) == 0 {
return false
}
part, ok := turn.ResponseParts[len(turn.ResponseParts)-1].Value.(*ahptypes.ErrorResponsePart)
return ok && part.Resumable != nil && *part.Resumable
}

func summaryStatus(state *ahptypes.ChatState, terminal *ahptypes.SessionStatus) ahptypes.SessionStatus {
var activity ahptypes.SessionStatus
switch {
Expand All @@ -191,7 +199,7 @@ func refreshSummaryStatus(state *ahptypes.ChatState) {

// ─── Active-turn helpers ───────────────────────────────────────────────

func endTurn(state *ahptypes.ChatState, turnID string, duration int64, turnState ahptypes.TurnState, terminalStatus *ahptypes.SessionStatus, errInfo *ahptypes.ErrorInfo) ReduceOutcome {
func endTurn(state *ahptypes.ChatState, turnID string, duration int64, turnState ahptypes.TurnState, terminalStatus *ahptypes.SessionStatus, errorPart *ahptypes.ErrorResponsePart) ReduceOutcome {
if state.ActiveTurn == nil || state.ActiveTurn.Id != turnID {
return ReduceOutcomeNoOp
}
Expand Down Expand Up @@ -229,6 +237,9 @@ func endTurn(state *ahptypes.ChatState, turnID string, duration int64, turnState
ToolCall: ahptypes.ToolCallState{Value: cancelled},
}})
}
if errorPart != nil {
parts = append(parts, ahptypes.ResponsePart{Value: errorPart})
}

// Defensive clamp: duration is producer-supplied and opaque to this
// reducer, but a negative value would be nonsensical to display.
Expand All @@ -244,7 +255,6 @@ func endTurn(state *ahptypes.ChatState, turnID string, duration int64, turnState
ResponseParts: parts,
Usage: active.Usage,
State: turnState,
Error: errInfo,
}

state.Turns = append(state.Turns, turn)
Expand Down Expand Up @@ -436,6 +446,7 @@ func updateResponsePart(state *ahptypes.ChatState, turnID, partID string, update
if state.ActiveTurn == nil || state.ActiveTurn.Id != turnID {
return ReduceOutcomeNoOp
}

for i := range state.ActiveTurn.ResponseParts {
part := &state.ActiveTurn.ResponseParts[i]
var id string
Expand Down Expand Up @@ -509,16 +520,42 @@ func ApplyActionToChat(state *ahptypes.ChatState, action ahptypes.StateAction) R
if state.ActiveTurn == nil || state.ActiveTurn.Id != a.TurnId {
return ReduceOutcomeNoOp
}
if _, ok := a.Part.Value.(*ahptypes.ErrorResponsePart); ok {
return ReduceOutcomeNoOp
}
state.ActiveTurn.ResponseParts = append(state.ActiveTurn.ResponseParts, a.Part)
return ReduceOutcomeApplied
case *ahptypes.ChatTurnCompleteAction:
return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateComplete, nil, nil)
case *ahptypes.ChatTurnCancelledAction:
return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateCancelled, nil, nil)
case *ahptypes.ChatErrorAction:
errCopy := a.Error
errStatus := ahptypes.SessionStatusError
return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateError, &errStatus, &errCopy)
return endTurn(state, a.TurnId, a.Duration, ahptypes.TurnStateError, &errStatus, &a.Part)
case *ahptypes.ChatTurnResumeAction:
if state.ActiveTurn != nil || len(state.Turns) == 0 {
return ReduceOutcomeNoOp
}
turnIndex := len(state.Turns) - 1
turn := state.Turns[turnIndex]
if turn.Id != a.TurnId || turn.State != ahptypes.TurnStateError || !hasResumableError(&turn) {
return ReduceOutcomeNoOp
}

startedAt := state.ModifiedAt
if turn.StartedAt != nil {
startedAt = *turn.StartedAt
}
state.Turns = state.Turns[:turnIndex]
state.ActiveTurn = &ahptypes.ActiveTurn{
Id: turn.Id,
StartedAt: startedAt,
Message: turn.Message,
ResponseParts: turn.ResponseParts,
Usage: turn.Usage,
}
state.Status = withStatusFlag(summaryStatus(state, nil), ahptypes.SessionStatusIsRead, false)
return ReduceOutcomeApplied
case *ahptypes.ChatActivityChangedAction:
state.Activity = a.Activity
return ReduceOutcomeApplied
Expand Down
Loading