Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 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
3 changes: 2 additions & 1 deletion src/A2A/A2A.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<!-- Dependencies only needed by netstandard2.0 -->
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="System.Text.Json" />
<PackageReference Include="System.Threading.Channels" />
</ItemGroup>

<!-- Dependencies needed by all -->
Expand All @@ -36,7 +37,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="System.Threading.Channels" />

</ItemGroup>

<ItemGroup>
Expand Down
15 changes: 15 additions & 0 deletions src/A2A/Server/ITaskManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,21 @@ public interface ITaskManager
/// <returns>A task representing the asynchronous operation.</returns>
Task ReturnArtifactAsync(string taskId, Artifact artifact, CancellationToken cancellationToken = default);

/// <summary>
/// Updates an artifact for a task, either by adding a new artifact or appending to the last one.
/// </summary>
/// <remarks>
/// When append is true, the artifact's parts are added to the last artifact in the task's collection.
/// When append is false or there are no existing artifacts, a new artifact is added to the collection.
/// </remarks>
/// <param name="taskId">The ID of the task to update the artifact for.</param>
/// <param name="artifact">The artifact containing parts to add.</param>
/// <param name="append">Whether to append to the last artifact (true) or create a new one (false).</param>
/// <param name="lastChunk">Whether this is the last chunk of the artifact.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task UpdateArtifactAsync(string taskId, Artifact artifact, bool append = false, bool? lastChunk = null, CancellationToken cancellationToken = default);
Copy link
Collaborator

Choose a reason for hiding this comment

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

@copilot This method is not used yet (except in tests). Show me how it is supposed to be used on agent implementations in samples/ folder. This should play nice with the current ReturnArtifactAsync method implementation so observe how it is being used in samples/

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Enhanced the EchoAgentWithTasks sample to demonstrate how UpdateArtifactAsync should be used in agent implementations. The sample now shows three patterns:

  1. Simple responses: ReturnArtifactAsync for complete, single artifacts (default behavior)
  2. Streaming responses: UpdateArtifactAsync with chunked data (use stream: <text> command)
  3. Incremental building: UpdateArtifactAsync with append functionality (use append: <text> command)

The key difference from ReturnArtifactAsync is that UpdateArtifactAsync supports:

  • append parameter to add to existing artifacts vs creating new ones
  • lastChunk parameter for streaming scenarios
  • More granular control over artifact construction

This demonstrates the proper usage patterns alongside the existing ReturnArtifactAsync usage in the samples. Commit: 8a8af29


/// <summary>
/// Updates the status of a task and optionally adds a message to its history.
/// </summary>
Expand Down
95 changes: 93 additions & 2 deletions src/A2A/Server/TaskManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,97 @@ public async Task ReturnArtifactAsync(string taskId, Artifact artifact, Cancella
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
// TODO: Implement UpdateArtifact method
}

/// <inheritdoc />
public async Task UpdateArtifactAsync(string taskId, Artifact artifact, bool append = false, bool? lastChunk = null, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();

if (string.IsNullOrEmpty(taskId))
{
throw new A2AException(nameof(taskId), A2AErrorCode.InvalidParams);
}
else if (artifact is null)
{
throw new A2AException(nameof(artifact), A2AErrorCode.InvalidParams);
}

using var activity = ActivitySource.StartActivity("UpdateArtifact", ActivityKind.Server);
activity?.SetTag("task.id", taskId);
activity?.SetTag("artifact.append", append);
activity?.SetTag("artifact.lastChunk", lastChunk);

try
{
var task = await _taskStore.GetTaskAsync(taskId, cancellationToken).ConfigureAwait(false);
if (task != null)
{
activity?.SetTag("task.found", true);

task.Artifacts ??= [];

if (append && task.Artifacts.Count > 0)
{
// Append to the last artifact by adding parts to it
var lastArtifact = task.Artifacts[^1];

Choose a reason for hiding this comment

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

Hello @brandonh-msft. Why are you taking the last artifact from the task ?
Is this a limitation from the A2A protocol, meaning we can only append to the last artifact from the task ?
If this is not a limitation, you could use the artifact object received from the method params and look for a corresponding artifactId ?

Copy link
Contributor

Choose a reason for hiding this comment

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

Matching by artifactId is the correct approach here, not "last artifact".

The Artifact model already has ArtifactId as a required field. A task can have multiple artifacts concurrently — if an agent is streaming two separate outputs (say, a text artifact and a code artifact), using Last() would incorrectly append parts to whichever artifact was added most recently, regardless of which stream the chunk belongs to.

The fix should be:

var existing = task.Artifacts?.FirstOrDefault(a => a.ArtifactId == artifact.ArtifactId);
if (existing is not null)
{
    existing.Parts.AddRange(artifact.Parts);
}
else
{
    task.Artifacts ??= [];
    task.Artifacts.Add(artifact);
}

This way each artifact chunk is correctly routed to the artifact it belongs to, regardless of insertion order. The Append flag on the emitted event can then accurately reflect true when an existing artifact was found and updated, and false when a new one was created — which aligns with what Copilot already implemented for the event side.


// Add all parts from the new artifact to the last artifact
foreach (var part in artifact.Parts)
{
lastArtifact.Parts.Add(part);
}

activity?.SetTag("event.type", "artifact_append");
await _taskStore.SetTaskAsync(task, cancellationToken).ConfigureAwait(false);

// Notify with the updated artifact and append=true
_taskUpdateEventEnumerators.TryGetValue(task.Id, out var enumerator);
if (enumerator is not null)
{
var taskUpdateEvent = new TaskArtifactUpdateEvent
{
TaskId = task.Id,
Artifact = lastArtifact,
Append = true,
LastChunk = lastChunk
};
enumerator.NotifyEvent(taskUpdateEvent);
}
}
else
{
// Create a new artifact (either append=false or no existing artifacts)
task.Artifacts.Add(artifact);
activity?.SetTag("event.type", "artifact_new");
await _taskStore.SetTaskAsync(task, cancellationToken).ConfigureAwait(false);

// Notify with the new artifact and append=false
_taskUpdateEventEnumerators.TryGetValue(task.Id, out var enumerator);
if (enumerator is not null)
{
var taskUpdateEvent = new TaskArtifactUpdateEvent
{
TaskId = task.Id,
Artifact = artifact,
Append = false, // Always false when creating a new artifact
LastChunk = lastChunk
};
enumerator.NotifyEvent(taskUpdateEvent);
}
}
}
else
{
activity?.SetTag("task.found", false);
activity?.SetStatus(ActivityStatusCode.Error, "Task not found");
throw new A2AException("Task not found.", A2AErrorCode.TaskNotFound);
}
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
}
Loading