Skip to content

Commit 8d5bddc

Browse files
committed
Add automatic dedent of description and instructions
In configuration, especially TOML or YAML, this allows improved readability by adding indents as needed, but still get a clean dedented string just like the C# multiline literal strings.
1 parent 777a00f commit 8d5bddc

File tree

4 files changed

+189
-0
lines changed

4 files changed

+189
-0
lines changed

.netconfig

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,8 @@
159159
sha = 3012d56be7554c483e5c5d277144c063969cada9
160160
etag = 43c81c6c6dcdf5baee40a9e3edc5e871e473e6c954c901b82bb87a3a48888ea0
161161
weak
162+
[file "src/Agents/Extensions/Dedent.cs"]
163+
url = https://github.com/devlooped/catbag/blob/main/System/Dedent.cs
164+
sha = c0a15b3c5e42a6f5e73b8e43ad3a335d7d6f3787
165+
etag = fe8de7929a8ecdb631911233ae3c6bad034b26b9802e62c3521918207f6d4068
166+
weak

src/Agents/ConfigurableAIAgent.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnum
8282
{
8383
var options = configSection.Get<AgentClientOptions>();
8484
options?.Name ??= name;
85+
options?.Description = options?.Description?.Dedent();
86+
options?.Instructions = options?.Instructions?.Dedent();
8587

8688
// If there was a custom id, we must validate it didn't change since that's not supported.
8789
if (configuration[$"{section}:name"] is { } newname && newname != name)

src/Agents/Extensions/Dedent.cs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// <auto-generated />
2+
#region License
3+
// MIT License
4+
//
5+
// Copyright (c) Daniel Cazzulino
6+
//
7+
// Permission is hereby granted, free of charge, to any person obtaining a copy
8+
// of this software and associated documentation files (the "Software"), to deal
9+
// in the Software without restriction, including without limitation the rights
10+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
// copies of the Software, and to permit persons to whom the Software is
12+
// furnished to do so, subject to the following conditions:
13+
//
14+
// The above copyright notice and this permission notice shall be included in all
15+
// copies or substantial portions of the Software.
16+
//
17+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23+
// SOFTWARE.
24+
#endregion
25+
26+
#nullable enable
27+
using System.Text;
28+
using System.Text.RegularExpressions;
29+
30+
namespace System
31+
{
32+
/// <summary>
33+
/// String extension methods for text processing.
34+
/// </summary>
35+
static partial class StringExtensions
36+
{
37+
/// <summary>
38+
/// Remove leading whitespace from each line of a multi-line string that is common
39+
/// to all non-empty lines. This is equivalent to Python's textwrap.dedent().
40+
/// </summary>
41+
/// <param name="text">The text to dedent.</param>
42+
/// <returns>The dedented text.</returns>
43+
/// <example>
44+
/// <code>
45+
/// var text = """
46+
/// Line 1
47+
/// Line 2
48+
/// Line 3
49+
/// """;
50+
/// var dedented = text.Dedent();
51+
/// // Result:
52+
/// // Line 1
53+
/// // Line 2
54+
/// // Line 3
55+
/// </code>
56+
/// </example>
57+
public static string Dedent(this string text)
58+
{
59+
if (string.IsNullOrEmpty(text))
60+
return text;
61+
62+
// Detect the line ending style used in the input
63+
var lineEnding = Environment.NewLine;
64+
if (text.Contains("\r\n"))
65+
lineEnding = "\r\n";
66+
else if (text.Contains('\r'))
67+
lineEnding = "\r";
68+
else if (text.Contains('\n'))
69+
lineEnding = "\n";
70+
71+
// Split using regex to properly handle different line endings
72+
var lines = NewLineExpr().Split(text);
73+
74+
// Remove leading and trailing empty lines
75+
int start = 0;
76+
int end = lines.Length - 1;
77+
78+
while (start < lines.Length && string.IsNullOrWhiteSpace(lines[start]))
79+
start++;
80+
81+
while (end >= 0 && string.IsNullOrWhiteSpace(lines[end]))
82+
end--;
83+
84+
if (start > end)
85+
return string.Empty;
86+
87+
// Find the minimum indentation (ignoring empty lines)
88+
int minIndent = int.MaxValue;
89+
for (int i = start; i <= end; i++)
90+
{
91+
var line = lines[i];
92+
if (!string.IsNullOrWhiteSpace(line))
93+
{
94+
int indent = 0;
95+
while (indent < line.Length && char.IsWhiteSpace(line[indent]))
96+
indent++;
97+
98+
minIndent = Math.Min(minIndent, indent);
99+
}
100+
}
101+
102+
if (minIndent == int.MaxValue || minIndent == 0)
103+
minIndent = 0;
104+
105+
// Remove the common indentation from all lines
106+
var result = new StringBuilder();
107+
for (int i = start; i <= end; i++)
108+
{
109+
var line = lines[i];
110+
if (string.IsNullOrWhiteSpace(line))
111+
{
112+
if (i < end) // Don't add newline for last empty line
113+
result.Append(lineEnding);
114+
}
115+
else
116+
{
117+
var dedentedLine = line.Length > minIndent ? line[minIndent..] : string.Empty;
118+
result.Append(dedentedLine);
119+
if (i < end)
120+
result.Append(lineEnding);
121+
}
122+
}
123+
124+
return result.ToString();
125+
}
126+
127+
[GeneratedRegex(@"\r\n|\r|\n")]
128+
private static partial Regex NewLineExpr();
129+
}
130+
}

src/Tests/ConfigurableAgentTests.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,58 @@ public void CanConfigureAgent()
3838
Assert.Equal("Helpful chat agent", agent.Description);
3939
}
4040

41+
[Fact]
42+
public void DedentsDescriptionAndInstructions()
43+
{
44+
var builder = new HostApplicationBuilder();
45+
46+
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
47+
{
48+
["ai:clients:chat:modelid"] = "gpt-4.1-nano",
49+
["ai:clients:chat:apikey"] = "sk-asdfasdf",
50+
["ai:agents:bot:client"] = "chat",
51+
["ai:agents:bot:name"] = "chat",
52+
["ai:agents:bot:description"] =
53+
"""
54+
55+
56+
Line 1
57+
Line 2
58+
Line 3
59+
60+
""",
61+
["ai:agents:bot:instructions"] =
62+
"""
63+
Agent Instructions:
64+
- Step 1
65+
- Step 2
66+
- Step 3
67+
""",
68+
["ai:agents:bot:options:temperature"] = "0.5",
69+
});
70+
71+
builder.AddAIAgents();
72+
73+
var app = builder.Build();
74+
75+
var agent = app.Services.GetRequiredKeyedService<AIAgent>("chat");
76+
77+
Assert.Equal(
78+
"""
79+
Line 1
80+
Line 2
81+
Line 3
82+
""", agent.Description);
83+
84+
Assert.Equal(
85+
"""
86+
Agent Instructions:
87+
- Step 1
88+
- Step 2
89+
- Step 3
90+
""", agent.GetService<ChatClientAgentOptions>()?.Instructions);
91+
}
92+
4193
[Fact]
4294
public void CanReloadConfiguration()
4395
{

0 commit comments

Comments
 (0)