Skip to content

.NET: [Bug]: Declarative EditTable/EditTableV2 Add replaces the items variable with the added record, so a second Add always fails #7323

Description

@crloz

What happened

In a declarative (YAML) workflow, applying EditTable with changeType: Add twice to the same
itemsVariable fails on the second application:

Unexpected workflow failure during EditTable [add_second]: Require 'Local.Items' to be a table, not: 'InMemoryRecordValue'.

The cause is that the Add path replaces the items variable with the record it just appended, rather
than with the mutated table — EditTableExecutor.ExecuteAsync:

RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), addResult.Value.ToFormula());
await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false);
await this.AssignAsync(variablePath, newRecord, context).ConfigureAwait(false);

variablePath here is the same path the executor read the table from at the start of the method. So
after one Add the variable holds a RecordValue, and the executor's own entry guard then rejects it:

FormulaValue table = context.ReadState(variablePath);
if (table is not TableValue tableValue)
{
    throw this.Exception($"Require '{variablePath}' to be a table, not: '{table.GetType().Name}'.");
}

EditTableV2Executor has the same behaviour and produces the same failure.

The net effect is that the action guarantees on exit precisely the condition it rejects on entry, so
Add cannot be composed with itself and cannot be used inside a Foreach to accumulate a table.

What I expected to happen

After Add, itemsVariable still refers to a table — now containing the appended record — so a
subsequent Add against the same variable succeeds. That is the behaviour the name EditTable, the
operation name Add, and the parameter name itemsVariable all suggest.

Steps to reproduce

  1. In a declarative workflow, seed a table variable with SetVariable and =Table({id: 1}).
  2. Add EditTable with changeType: Add against that variable.
  3. Add a second, identical EditTable against the same variable.
  4. Run the workflow. Step 2 succeeds; step 3 throws.

Full runnable repro under Code Sample.

Code Sample

`Repro.yaml`:


kind: Workflow
trigger:
  kind: OnConversationStart
  id: repro
  actions:

    - kind: SetVariable
      id: seed_table
      variable: Local.Items
      value: '=Table({id: 1})'

    - kind: EditTable
      id: add_first
      itemsVariable: Local.Items
      changeType: Add
      value: '={id: 2}'

    - kind: EditTable
      id: add_second
      itemsVariable: Local.Items
      changeType: Add
      value: '={id: 3}'


`Program.cs`:


using System.Runtime.CompilerServices;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Declarative;
using Microsoft.Extensions.AI;

Workflow workflow = DeclarativeWorkflowBuilder.Build<string>(
    "Repro.yaml",
    new DeclarativeWorkflowOptions(new StubProvider())
);

await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, "go");

await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false))
{
    switch (evt)
    {
        case ExecutorCompletedEvent completed:
            Console.WriteLine($"OK      {completed.ExecutorId}");
            break;
        case ExecutorFailedEvent failed:
            Console.WriteLine($"FAILED  {failed.ExecutorId}");
            Console.WriteLine($"        {failed.Data?.GetType().FullName}");
            Console.WriteLine($"        {failed.Data?.Message}");
            break;
        case WorkflowErrorEvent error:
            Console.WriteLine($"ERROR   {error.Exception?.GetType().FullName}: {error.Exception?.Message}");
            break;
    }
}

internal sealed class StubProvider : ResponseAgentProvider
{
    public override Task<string> CreateConversationAsync(CancellationToken cancellationToken = default) =>
        Task.FromResult("conversation-1");

    public override Task<ChatMessage> CreateMessageAsync(
        string conversationId, ChatMessage conversationMessage,
        CancellationToken cancellationToken = default) => Task.FromResult(conversationMessage);

    public override Task<ChatMessage> GetMessageAsync(
        string conversationId, string messageId,
        CancellationToken cancellationToken = default) => throw new NotSupportedException();

    public override async IAsyncEnumerable<ChatMessage> GetMessagesAsync(
        string conversationId, int? limit = null, string? after = null, string? before = null,
        bool newestFirst = false,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        await Task.CompletedTask;
        yield break;
    }

    public override IAsyncEnumerable<AgentResponseUpdate> InvokeAgentAsync(
        string agentId, string? agentVersion, string? conversationId,
        IEnumerable<ChatMessage>? messages, IDictionary<string, object?>? inputArguments,
        CancellationToken cancellationToken = default) => throw new NotSupportedException();
}


Swapping both actions for the `EditTableV2` form reproduces identically:


    - kind: EditTableV2
      id: add_first
      itemsVariable: Local.Items
      changeType:
        kind: AddItemOperation
        value: '={id: 2}'

Error Messages / Stack Traces

`EditTable`:


OK      repro_Root
OK      repro
OK      seed_table
OK      add_first
FAILED  add_second
        Microsoft.Agents.AI.Workflows.Declarative.DeclarativeActionException
        Unexpected workflow failure during EditTable [add_second]: Require 'Local.Items' to be a table, not: 'InMemoryRecordValue'.
ERROR   System.Reflection.TargetInvocationException: Error invoking handler for Microsoft.Agents.AI.Workflows.Declarative.Kit.ActionExecutorResult


`EditTableV2`:


FAILED  add_second
        Microsoft.Agents.AI.Workflows.Declarative.DeclarativeActionException
        Unexpected workflow failure during EditTableV2 [add_second]: Require 'Local.Items' to be a table, not: 'InMemoryRecordValue'.


Note that `add_first` reports success in both cases; only the second application fails.

Package Versions

Microsoft.Agents.AI.Workflows: 1.15.0 Microsoft.Agents.AI.Workflows.Declarative: 1.15.0

.NET Version

.NET SDK 10.0.302, runtime 10.0.10

Additional Context

Related but separate, and mentioned only so it is not mistaken for the same thing: a single-record
bracket literal such as =[{id: 1}] evaluates to a record rather than a one-row table, so it is
rejected by these actions even on the first Add. =Table({id: 1}) is required, and is what the
repro uses. Happy to file that separately if it is not intended.

Metadata

Metadata

Assignees

Labels

.NETUsage: [Issues, PRs], Target: .NetdeclarativeUsage: [Issues, PRs], Target: declarative agents and workflowsreproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflow

Type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions