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
37 changes: 37 additions & 0 deletions src/Coven.Agents.OpenAI/AgentMaxLengthWindowPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: BUSL-1.1
using Coven.Core.Streaming;

namespace Coven.Agents.OpenAI;

/// <summary>
/// Emits when recent agent chunk length reaches a max. Minimal lookback of 1.
/// </summary>
public sealed class AgentMaxLengthWindowPolicy : IWindowPolicy<AgentAfferentChunk>
{
private readonly int _max;

public AgentMaxLengthWindowPolicy(int max)
{
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(max, 0);
_max = max;
}

public int MinChunkLookback => 1;

public bool ShouldEmit(StreamWindow<AgentAfferentChunk> window)
{
int total = 0;
foreach (AgentAfferentChunk chunk in window.PendingChunks)
{
if (!string.IsNullOrEmpty(chunk.Text))
{
total += chunk.Text.Length;
if (total >= _max)
{
return true;
}
}
}
return false;
}
}
34 changes: 34 additions & 0 deletions src/Coven.Agents.OpenAI/AgentParagraphWindowPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: BUSL-1.1
using System.Text;
using Coven.Core.Streaming;

namespace Coven.Agents.OpenAI;

/// <summary>
/// Emits agent output when the recent text (last 1–2 chunks) ends at a paragraph boundary.
/// Uses a minimal lookback of 2 to handle boundaries that straddle chunk edges.
/// </summary>
public sealed class AgentParagraphWindowPolicy : IWindowPolicy<AgentAfferentChunk>
{
public int MinChunkLookback => 2;

public bool ShouldEmit(StreamWindow<AgentAfferentChunk> window)
{
StringBuilder stringBuilder = new();
foreach (AgentAfferentChunk chunk in window.PendingChunks)
{
if (!string.IsNullOrEmpty(chunk.Text))
{
stringBuilder.Append(chunk.Text);
}
}

if (stringBuilder.Length == 0)
{
return false;
}

string concatenatedWindow = stringBuilder.ToString();
return concatenatedWindow.EndsWith("\r\n\r\n", StringComparison.Ordinal) || concatenatedWindow.EndsWith("\n\n", StringComparison.Ordinal);
}
}
38 changes: 38 additions & 0 deletions src/Coven.Agents.OpenAI/AgentThoughtMaxLengthWindowPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: BUSL-1.1
using Coven.Core.Streaming;

namespace Coven.Agents.OpenAI;

/// <summary>
/// Emits when recent agent thought chunk length reaches a max. Minimal lookback of 1.
/// </summary>
public sealed class AgentThoughtMaxLengthWindowPolicy : IWindowPolicy<AgentAfferentThoughtChunk>
{
private readonly int _max;

public AgentThoughtMaxLengthWindowPolicy(int max)
{
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(max, 0);
_max = max;
}

public int MinChunkLookback => 1;

public bool ShouldEmit(StreamWindow<AgentAfferentThoughtChunk> window)
{
int total = 0;
foreach (AgentAfferentThoughtChunk chunk in window.PendingChunks)
{
if (!string.IsNullOrEmpty(chunk.Text))
{
total += chunk.Text.Length;
if (total >= _max)
{
return true;
}
}
}
return false;
}
}

35 changes: 35 additions & 0 deletions src/Coven.Agents.OpenAI/AgentThoughtParagraphWindowPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: BUSL-1.1
using System.Text;
using Coven.Core.Streaming;

namespace Coven.Agents.OpenAI;

/// <summary>
/// Emits agent thought output when the recent text (last 1–2 chunks) ends at a paragraph boundary.
/// Uses a minimal lookback of 2 to handle boundaries that straddle chunk edges.
/// </summary>
public sealed class AgentThoughtParagraphWindowPolicy : IWindowPolicy<AgentAfferentThoughtChunk>
{
public int MinChunkLookback => 2;

public bool ShouldEmit(StreamWindow<AgentAfferentThoughtChunk> window)
{
StringBuilder stringBuilder = new();
foreach (AgentAfferentThoughtChunk chunk in window.PendingChunks)
{
if (!string.IsNullOrEmpty(chunk.Text))
{
stringBuilder.Append(chunk.Text);
}
}

if (stringBuilder.Length == 0)
{
return false;
}

string concatenatedWindow = stringBuilder.ToString();
return concatenatedWindow.EndsWith("\r\n\r\n", StringComparison.Ordinal) || concatenatedWindow.EndsWith("\n\n", StringComparison.Ordinal);
}
}

47 changes: 47 additions & 0 deletions src/Coven.Agents.OpenAI/AgentThoughtSentenceWindowPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: BUSL-1.1
using System.Text;
using Coven.Core.Streaming;

namespace Coven.Agents.OpenAI;

/// <summary>
/// Emits when the concatenated thought text ends at a sentence boundary.
/// A sentence boundary is a trailing '.', '!' or '?' (ignoring trailing whitespace).
/// </summary>
public sealed class AgentThoughtSentenceWindowPolicy : IWindowPolicy<AgentAfferentThoughtChunk>
{
// 4 chunks should be generous for windowing sentence termination
public int MinChunkLookback => 4;

public bool ShouldEmit(StreamWindow<AgentAfferentThoughtChunk> window)
{
StringBuilder sb = new();
foreach (AgentAfferentThoughtChunk chunk in window.PendingChunks)
{
if (!string.IsNullOrEmpty(chunk.Text))
{
sb.Append(chunk.Text);
}
}

return sb.Length != 0 && EndsWithSentenceBoundary(sb);
}

private static bool EndsWithSentenceBoundary(StringBuilder sb)
{
int i = sb.Length - 1;
while (i >= 0 && char.IsWhiteSpace(sb[i]))
{
i--;
}

if (i < 0)
{
return false;
}

char c = sb[i];
return c is '.' or '!' or '?';
}
}

83 changes: 83 additions & 0 deletions src/Coven.Agents.OpenAI/AgentThoughtSummaryMarkerWindowPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: BUSL-1.1
using System.Text;
using Coven.Core.Streaming;

namespace Coven.Agents.OpenAI;

/// <summary>
/// Emits when a summary marker is observed in the thought stream.
/// The marker is any bold Markdown segment ("**...**") followed by a newline sequence.
/// Recognized sequences: "\n\n", "\r\n\r\n", or "\r\n".
/// </summary>
public sealed class AgentThoughtSummaryMarkerWindowPolicy : IWindowPolicy<AgentAfferentThoughtChunk>
{
public int MinChunkLookback => 10;

public bool ShouldEmit(StreamWindow<AgentAfferentThoughtChunk> window)
{
StringBuilder stringBuilder = new();
foreach (AgentAfferentThoughtChunk chunk in window.PendingChunks)
{
if (!string.IsNullOrEmpty(chunk.Text))
{
stringBuilder.Append(chunk.Text);
}
}

if (stringBuilder.Length == 0)
{
return false;
}

string text = stringBuilder.ToString();
ReadOnlySpan<char> span = text.AsSpan();
return HasBoldFollowedByNewline(span);
}

private static bool HasBoldFollowedByNewline(ReadOnlySpan<char> span)
{
int position = 0;
while (position < span.Length)
{
int start = span[position..].IndexOf("**");
if (start < 0)
{
return false;
}
start += position;

int afterOpen = start + 2;
if (afterOpen >= span.Length)
{
return false;
}

int end = span[afterOpen..].IndexOf("**");
if (end < 0)
{
// Unmatched opener; advance past it and continue scanning later content
position = start + 2;
continue;
}
end += afterOpen;

// Require non-empty content between markers
if (end > start + 2)
{
int after = end + 2;
ReadOnlySpan<char> tail = after <= span.Length ? span[after..] : [];

if (tail.StartsWith("\r\n\r\n", StringComparison.Ordinal) ||
tail.StartsWith("\n\n", StringComparison.Ordinal) ||
tail.StartsWith("\r\n", StringComparison.Ordinal))
{
return true;
}
}

position = end + 2;
}

return false;
}
}
110 changes: 110 additions & 0 deletions src/Coven.Agents.OpenAI/AgentThoughtSummaryShatterPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// SPDX-License-Identifier: BUSL-1.1
using Coven.Core.Streaming;

namespace Coven.Agents.OpenAI;

/// <summary>
/// Shatters AgentThought outputs on the first matched "summary marker":
/// any bold Markdown segment ("**...**") immediately followed by a newline sequence
/// ("\n\n", "\r\n\r\n", or "\r\n").
///
/// When a boundary is found, emits two AgentThought entries:
/// - First: everything before the bold segment
/// - Second: the bold segment plus the newline sequence and any remaining text
///
/// If no boundary exists, produces no outputs (forward unchanged).
/// </summary>
public sealed class AgentThoughtSummaryShatterPolicy : IShatterPolicy<AgentEntry>
{
private static class Grammar
{
// Token for a Markdown bold delimiter
public const string Bold = "**";
// Ordered newline sequences that define a "paragraph boundary"
public static readonly string[] _paragraphBoundaries = ["\r\n\r\n", "\n\n", "\r\n"];
}

public IEnumerable<AgentEntry> Shatter(AgentEntry entry)
{
if (entry is not AgentThought thought || string.IsNullOrEmpty(thought.Text))
{
yield break;
}

string text = thought.Text;
// Locate the first boundary where a bold segment is immediately followed by a newline sequence.
// The returned index is the position of the bold opener; we split BEFORE it.
int splitIndex = IndexOfSummaryBoundary(text);
if (splitIndex < 0)
{
yield break;
}

// Split before the header: first = preface text; second = header + newline(s) + remainder.
string first = text[..splitIndex];
string second = text[splitIndex..];

// Emit only non-empty chunks.
if (first.Length > 0)
{
yield return new AgentThought(thought.Sender, first);
}
if (second.Length > 0)
{
yield return new AgentThought(thought.Sender, second);
}
}

private static int IndexOfSummaryBoundary(string s)
{
ReadOnlySpan<char> span = s.AsSpan();
int position = 0;
while (position < span.Length)
{
// Find the next bold opener starting at the current scan position.
int start = span[position..].IndexOf(Grammar.Bold);
if (start < 0)
{
return -1;
}
// Convert relative index to absolute index within the source span.
start += position;

// Index immediately after the opening bold token.
int afterOpen = start + Grammar.Bold.Length;
if (afterOpen >= span.Length) { return -1; }

// Search for the matching bold closer after the opener.
int end = span[afterOpen..].IndexOf(Grammar.Bold);
if (end < 0)
{
// No closer found; advance past the opener to allow subsequent matches later in the text.
position = start + Grammar.Bold.Length;
continue;
}
end += afterOpen;

// Require non-empty content between the opener and closer (i.e., at least one character inside the bold segment).
if (end > start + Grammar.Bold.Length)
{
// Index immediately after the closing bold token.
int after = end + Grammar.Bold.Length;
// Slice of text following the bold segment; used to detect newline sequences.
ReadOnlySpan<char> tail = after <= span.Length ? span[after..] : [];
// Bold header followed by newline(s) — split BEFORE the bold segment.
foreach (string nl in Grammar._paragraphBoundaries)
{
if (tail.StartsWith(nl, StringComparison.Ordinal))
{
// Found a boundary: return the index of the bold opener so callers split BEFORE it.
return start;
}
}
}

// Advance scan position to just after the bold closer and continue searching.
position = end + Grammar.Bold.Length;
}
return -1;
}
}
2 changes: 1 addition & 1 deletion src/Coven.Agents.OpenAI/DefaultOpenAITranscriptBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ internal sealed class DefaultOpenAITranscriptBuilder(
private readonly IScrivener<OpenAIEntry> _journal = journal ?? throw new ArgumentNullException(nameof(journal));
private readonly ITransmuter<OpenAIEntry, ResponseItem?> _entryToItem = entryToItem ?? throw new ArgumentNullException(nameof(entryToItem));

public async Task<List<ResponseItem>> BuildAsync(OpenAIOutgoing newest, int maxMessages, CancellationToken cancellationToken)
public async Task<List<ResponseItem>> BuildAsync(OpenAIEfferent newest, int maxMessages, CancellationToken cancellationToken)
{
List<ResponseItem> buffer = [];

Expand Down
Loading