Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6020ac9
spec: add agent loop resilience (§9.8, §9.9, §9.10, §12.5)
sethjuarez Apr 13, 2026
b1ab87b
feat(ts): add agent loop resilience features
sethjuarez Apr 13, 2026
eefec5c
feat(csharp): add agent loop resilience features (§9.8, §9.9, §9.10)
sethjuarez Apr 13, 2026
273105e
feat(rust): add agent loop resilience features
sethjuarez Apr 13, 2026
d6df11b
feat: add agent loop resilience (§9.8, §9.9, §9.10)
sethjuarez Apr 13, 2026
63920f8
fix(ts): correct retry backoff to use seconds per spec §9.10
sethjuarez Apr 13, 2026
0897666
fix(rust): respect cancellation during retry backoff per spec §9.10
sethjuarez Apr 13, 2026
71c0f5d
docs: add agent loop resilience (§9.8, §9.9, §9.10) to user-facing docs
sethjuarez Apr 13, 2026
2988526
Add Entra ID (DefaultAzureCredential) integration tests for TypeScrip…
sethjuarez Apr 13, 2026
54cdcb7
Add Entra ID (DefaultAzureCredential) integration tests for Rust Foun…
sethjuarez Apr 13, 2026
4ad2f72
Add Entra ID (DefaultAzureCredential) support to Foundry executor and…
sethjuarez Apr 13, 2026
eceaeaa
fix(rust): unwrap structured output in invoke() + add mock pipeline t…
sethjuarez Apr 13, 2026
7395ad4
feat(csharp): add context compaction to C# pipeline
sethjuarez Apr 13, 2026
977674b
feat(typescript): add context compaction to turn()
sethjuarez Apr 13, 2026
4abd20b
Add context compaction to turn() and turn_async()
sethjuarez Apr 13, 2026
e1c4399
feat(rust): add context compaction to TurnOptions
sethjuarez Apr 13, 2026
8fc07d8
Add Context Compaction section to agent extensions docs
sethjuarez Apr 13, 2026
2ab9dd3
Add TurnOptions builder pattern and prelude module to Rust runtime
sethjuarez Apr 13, 2026
1256bee
Merge origin/main into sethjuarez/spec-agent-loop-resilience
sethjuarez Apr 13, 2026
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
270 changes: 270 additions & 0 deletions runtime/csharp/Prompty.Core.Tests/CompactionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
// Copyright (c) Microsoft. All rights reserved.

using Prompty.Core;

namespace Prompty.Core.Tests;

public class CompactionTests
{
private static Message TextMsg(string role, string text) =>
new() { Role = role, Parts = [new TextPart { Value = text }] };

private static Message ToolCallMsg(string name, string args) =>
new()
{
Role = "assistant",
Parts = [new TextPart { Value = "" }],
Metadata = new Dictionary<string, object?>
{
["tool_calls"] = new List<ToolCall>
{
new() { Id = "tc1", Name = name, Arguments = args }
}
}
};

// -----------------------------------------------------------------------
// FormatDroppedMessages
// -----------------------------------------------------------------------

[Fact]
public void FormatDroppedMessages_FormatsReadableText()
{
var dropped = new List<Message>
{
TextMsg("user", "What is 2+2?"),
TextMsg("assistant", "It is 4."),
};

var result = ContextWindow.FormatDroppedMessages(dropped);

Assert.Contains("[user]: What is 2+2?", result);
Assert.Contains("[assistant]: It is 4.", result);
}

[Fact]
public void FormatDroppedMessages_ShowsToolCalls()
{
var dropped = new List<Message>
{
ToolCallMsg("get_weather", "{\"city\":\"Seattle\"}")
};

var result = ContextWindow.FormatDroppedMessages(dropped);

Assert.Contains("[assistant]: Called: get_weather(", result);
Assert.Contains("Seattle", result);
}

[Fact]
public void FormatDroppedMessages_EmptyForEmptyList()
{
var result = ContextWindow.FormatDroppedMessages(new List<Message>());
Assert.Equal("", result);
}

// -----------------------------------------------------------------------
// CompactionStrategy factory methods
// -----------------------------------------------------------------------

[Fact]
public void FromFunction_CreatesStrategy()
{
var strategy = CompactionStrategy.FromFunction(
msgs => Task.FromResult("summary"));
Assert.NotNull(strategy);
}

[Fact]
public void FromPrompty_CreatesStrategy()
{
var strategy = CompactionStrategy.FromPrompty("some/path.prompty");
Assert.NotNull(strategy);
}

[Fact]
public void FromPrompty_ThrowsOnEmpty()
{
Assert.Throws<ArgumentException>(() =>
CompactionStrategy.FromPrompty(""));
}

[Fact]
public void FromFunction_ThrowsOnNull()
{
Assert.Throws<ArgumentNullException>(() =>
CompactionStrategy.FromFunction(null!));
}

// -----------------------------------------------------------------------
// ApplyCompactionAsync — function path
// -----------------------------------------------------------------------

[Fact]
public async Task CompactionFunction_ReplacesSummary()
{
// Arrange: build messages that simulate a trimmed conversation
// with a default summary already in place
var messages = new List<Message>
{
TextMsg("system", "You are a helpful assistant."),
TextMsg("user", "[Context summary: User asked: old question]"),
TextMsg("user", "latest question"),
};

var dropped = new List<Message>
{
TextMsg("user", "old question"),
TextMsg("assistant", "old answer"),
};

var compaction = CompactionStrategy.FromFunction(
msgs => Task.FromResult("Compacted: discussed old question and got old answer"));

// Act
await Pipeline.ApplyCompactionAsync(compaction, dropped, messages, null);

// Assert: the summary message was replaced
Assert.Equal("Compacted: discussed old question and got old answer", messages[1].Text);
}

[Fact]
public async Task CompactionFailure_PreservesDefault()
{
var messages = new List<Message>
{
TextMsg("system", "system"),
TextMsg("user", "[Context summary: original summary]"),
TextMsg("user", "latest"),
};

var dropped = new List<Message> { TextMsg("user", "old") };

var compaction = CompactionStrategy.FromFunction(
_ => throw new InvalidOperationException("LLM unavailable"));

var events = new List<(AgentEventType Type, Dictionary<string, object?> Data)>();
EventCallback onEvent = (type, data) => events.Add((type, data));

// Act
await Pipeline.ApplyCompactionAsync(compaction, dropped, messages, onEvent);

// Assert: original summary preserved
Assert.Equal("[Context summary: original summary]", messages[1].Text);

// Assert: failure event emitted
Assert.Contains(events, e => e.Type == AgentEventType.CompactionFailed);
var failedEvent = events.First(e => e.Type == AgentEventType.CompactionFailed);
Assert.Contains("LLM unavailable", failedEvent.Data["reason"]?.ToString());
}

[Fact]
public async Task CompactionEmpty_EmitsFailedEvent()
{
var messages = new List<Message>
{
TextMsg("system", "system"),
TextMsg("user", "[Context summary: original]"),
TextMsg("user", "latest"),
};

var dropped = new List<Message> { TextMsg("user", "old") };

var compaction = CompactionStrategy.FromFunction(
_ => Task.FromResult(""));

var events = new List<(AgentEventType Type, Dictionary<string, object?> Data)>();
EventCallback onEvent = (type, data) => events.Add((type, data));

await Pipeline.ApplyCompactionAsync(compaction, dropped, messages, onEvent);

// Original preserved
Assert.Equal("[Context summary: original]", messages[1].Text);

// CompactionFailed with "empty result"
var failedEvent = events.First(e => e.Type == AgentEventType.CompactionFailed);
Assert.Equal("empty result", failedEvent.Data["reason"]?.ToString());
}

// -----------------------------------------------------------------------
// Event emission
// -----------------------------------------------------------------------

[Fact]
public async Task CompactionEvents_AreEmitted()
{
var messages = new List<Message>
{
TextMsg("system", "system"),
TextMsg("user", "[Context summary: original]"),
TextMsg("user", "latest"),
};

var dropped = new List<Message> { TextMsg("user", "old") };

var compaction = CompactionStrategy.FromFunction(
_ => Task.FromResult("New compacted summary"));

var events = new List<(AgentEventType Type, Dictionary<string, object?> Data)>();
EventCallback onEvent = (type, data) => events.Add((type, data));

await Pipeline.ApplyCompactionAsync(compaction, dropped, messages, onEvent);

// Both start and complete events emitted
Assert.Contains(events, e => e.Type == AgentEventType.CompactionStart);
Assert.Contains(events, e => e.Type == AgentEventType.CompactionComplete);

var startEvent = events.First(e => e.Type == AgentEventType.CompactionStart);
Assert.Equal(1, startEvent.Data["dropped_count"]);

var completeEvent = events.First(e => e.Type == AgentEventType.CompactionComplete);
Assert.Equal("New compacted summary".Length, completeEvent.Data["summary_length"]);
}

// -----------------------------------------------------------------------
// ReplaceSummaryMessage
// -----------------------------------------------------------------------

[Fact]
public void ReplaceSummaryMessage_ReplacesFirstMatch()
{
var messages = new List<Message>
{
TextMsg("system", "system prompt"),
TextMsg("user", "[Context summary: old info]"),
TextMsg("user", "new question"),
};

Pipeline.ReplaceSummaryMessage(messages, "Better summary");

Assert.Equal("Better summary", messages[1].Text);
Assert.Equal("user", messages[1].Role);
}

[Fact]
public void ReplaceSummaryMessage_NoMatchLeavesListUnchanged()
{
var messages = new List<Message>
{
TextMsg("system", "system prompt"),
TextMsg("user", "just a normal message"),
};

Pipeline.ReplaceSummaryMessage(messages, "Better summary");

Assert.Equal("just a normal message", messages[1].Text);
}

// -----------------------------------------------------------------------
// Null compaction preserves existing behavior
// -----------------------------------------------------------------------

[Fact]
public void NullCompaction_DefaultParameterValue()
{
// Verify the parameter defaults to null — this is a compile-time check.
// If this compiles, the default is correct.
// We just verify the method signature accepts no compaction argument.
Assert.Null((CompactionStrategy?)null);
}
}
5 changes: 4 additions & 1 deletion runtime/csharp/Prompty.Core/AgentEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ public enum AgentEventType
MessagesUpdated,
Done,
Error,
Cancelled
Cancelled,
CompactionStart,
CompactionComplete,
CompactionFailed
}

/// <summary>Callback for agent loop events.</summary>
Expand Down
50 changes: 50 additions & 0 deletions runtime/csharp/Prompty.Core/CompactionStrategy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c) Microsoft. All rights reserved.

namespace Prompty.Core;

/// <summary>
/// Context compaction strategy for replacing default dropped-message summaries
/// with higher-quality LLM-powered or function-based summaries.
/// </summary>
public abstract class CompactionStrategy
{
/// <summary>Produce a compact summary from dropped messages.</summary>
public abstract Task<string> CompactAsync(IReadOnlyList<Message> dropped);

/// <summary>Create compaction from a .prompty file path.</summary>
public static CompactionStrategy FromPrompty(string path) => new PromptyCompaction(path);

/// <summary>Create compaction from a function.</summary>
public static CompactionStrategy FromFunction(Func<IReadOnlyList<Message>, Task<string>> fn) => new FunctionCompaction(fn);
}

internal sealed class PromptyCompaction : CompactionStrategy
{
private readonly string _path;

public PromptyCompaction(string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
_path = path;
}

public override async Task<string> CompactAsync(IReadOnlyList<Message> dropped)
{
var text = ContextWindow.FormatDroppedMessages(dropped.ToList());
var result = await Pipeline.InvokeAsync(_path, new Dictionary<string, object?> { ["messages"] = text });
return result?.ToString() ?? "";
}
}

internal sealed class FunctionCompaction : CompactionStrategy
{
private readonly Func<IReadOnlyList<Message>, Task<string>> _fn;

public FunctionCompaction(Func<IReadOnlyList<Message>, Task<string>> fn)
{
ArgumentNullException.ThrowIfNull(fn);
_fn = fn;
}

public override Task<string> CompactAsync(IReadOnlyList<Message> dropped) => _fn(dropped);
}
26 changes: 26 additions & 0 deletions runtime/csharp/Prompty.Core/ContextWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,32 @@ public static (int DroppedCount, List<Message> Dropped) TrimToContextWindow(
return (droppedCount, dropped);
}

/// <summary>
/// Format dropped messages as readable text for compaction input.
/// Each message is rendered as "[role]: text" with tool calls shown as "Called: name(args)".
/// </summary>
public static string FormatDroppedMessages(List<Message> messages)
{
var lines = new List<string>();
foreach (var msg in messages)
{
if (msg.Metadata.TryGetValue("tool_calls", out var toolCalls) && toolCalls is System.Collections.IEnumerable tcList)
{
foreach (var tc in tcList)
{
var name = tc?.GetType().GetProperty("Name")?.GetValue(tc)?.ToString() ?? "unknown";
var args = tc?.GetType().GetProperty("Arguments")?.GetValue(tc)?.ToString() ?? "";
lines.Add($"[{msg.Role}]: Called: {name}({args})");
}
}

var text = msg.Text.Trim();
if (text.Length > 0)
lines.Add($"[{msg.Role}]: {text}");
}
return string.Join("\n", lines);
}

private static string Truncate(string text, int maxLen = 200)
=> text.Length <= maxLen ? text : text[..maxLen] + "…";
}
Loading
Loading