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
- In a declarative workflow, seed a table variable with
SetVariable and =Table({id: 1}).
- Add
EditTable with changeType: Add against that variable.
- Add a second, identical
EditTable against the same variable.
- 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.
What happened
In a declarative (YAML) workflow, applying
EditTablewithchangeType: Addtwice to the sameitemsVariablefails on the second application: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:variablePathhere is the same path the executor read the table from at the start of the method. Soafter one Add the variable holds a
RecordValue, and the executor's own entry guard then rejects it:EditTableV2Executorhas 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
Addcannot be composed with itself and cannot be used inside aForeachto accumulate a table.What I expected to happen
After
Add,itemsVariablestill refers to a table — now containing the appended record — so asubsequent
Addagainst the same variable succeeds. That is the behaviour the nameEditTable, theoperation name
Add, and the parameter nameitemsVariableall suggest.Steps to reproduce
SetVariableand=Table({id: 1}).EditTablewithchangeType: Addagainst that variable.EditTableagainst the same variable.Full runnable repro under Code Sample.
Code Sample
Error Messages / Stack Traces
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 isrejected by these actions even on the first
Add.=Table({id: 1})is required, and is what therepro uses. Happy to file that separately if it is not intended.