Skip to content
Open
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
14 changes: 7 additions & 7 deletions src/Schuly.API/Helply/HelplyChatService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ async Task Emit(HelplyEvent e)
responses.Add(Response(pending, decision.Approved));
if (decision.Approved)
{
started.Add(AddTool(assistant, pending.ToolCallId, pending.RunningLabel));
started.Add(AddTool(assistant, pending.ToolCallId, pending.RunningLabel, null, HelplyToolLabels.Key(pending.ToolName), HelplyToolLabels.Args(pending.ToolName, pending.ToolLabel, null)));
finishLabels[pending.ToolCallId] = (pending.DoneLabel, pending.ToolLabel);
}
else
Expand All @@ -210,7 +210,7 @@ async Task Emit(HelplyEvent e)
foreach (var tool in turn.Kind == HelplyTurnKind.Approvals ? turn.Decisions.Where(d => !d.Approved) : [])
{
var denied = original.Pending.First(p => p.ApprovalId == tool.ApprovalId);
await Emit(new HelplyEvent("tool_finished", new { id = denied.ToolCallId, label = denied.ToolLabel, status = HelplyToolStatus.Denied, at = assistant.Text.Length }));
await Emit(new HelplyEvent("tool_finished", new { id = denied.ToolCallId, label = denied.ToolLabel, status = HelplyToolStatus.Denied, at = assistant.Text.Length, key = HelplyToolLabels.Key(denied.ToolName), args = HelplyToolLabels.Args(denied.ToolName, denied.ToolLabel, null) }));
}
foreach (var tool in started)
await Emit(new HelplyEvent("tool_started", ToolEvent(tool)));
Expand Down Expand Up @@ -281,7 +281,7 @@ async Task EmitText(string safe)
// The text before a call is a finished stretch: send all of it, so the step's place ("at") is where the user's text ends.
await EmitText(text.Flush());
var labels = HelplyToolLabels.For(info.Name, info.Title);
var tool = AddTool(assistant, call.CallId, labels.Running, HelplyToolLabels.Detail(call.Name, call.Arguments));
var tool = AddTool(assistant, call.CallId, labels.Running, HelplyToolLabels.Detail(call.Name, call.Arguments), HelplyToolLabels.Key(call.Name), HelplyToolLabels.Args(call.Name, info.Title, call.Arguments));
if (call.Name == "bash" && toolSet.IsLocal(call.Name))
tool.Code = new HelplyStoredCodeRun { Command = Shown(redactor, HelplyToolLabels.Text(call.Arguments, "command") ?? string.Empty, MaxShownCommandChars) };
finishLabels[call.CallId] = (labels.Done, info.Title);
Expand Down Expand Up @@ -435,7 +435,7 @@ public static ChatMessage UserInput(string text, IEnumerable<HelplyStoredAttachm
private static string Shown(HelplyRedactor redactor, string text, int max) => redactor.Redact(text.Length <= max ? text : text[..max] + "\n...");

/// <summary>A tool step as the user sees it. <c>at</c> is how much of the reply's text came before the step, so the chat can place the step between the stretches of text it came between.</summary>
private static object ToolEvent(HelplyStoredTool tool) => new { id = tool.Id, label = tool.Label, status = tool.Status, at = tool.At, detail = tool.Detail, code = tool.Code is null ? null : new { command = tool.Code.Command, output = tool.Code.Output, exitCode = tool.Code.ExitCode } };
private static object ToolEvent(HelplyStoredTool tool) => new { id = tool.Id, label = tool.Label, status = tool.Status, at = tool.At, detail = tool.Detail, key = tool.Key, args = tool.Args, code = tool.Code is null ? null : new { command = tool.Code.Command, output = tool.Code.Output, exitCode = tool.Code.ExitCode } };

private static object HelplyDtoShape(HelplyStoredApproval a) => new { approvalId = a.ApprovalId, toolLabel = a.ToolLabel, summary = a.Summary, details = a.Details.Select(d => new { label = d.Label, value = d.Value }).ToList(), destructive = a.Destructive, arguments = a.Arguments.Select(d => new { label = d.Label, value = d.Value }).ToList(), risk = a.Risk, riskReasons = a.RiskReasons };

Expand Down Expand Up @@ -478,17 +478,17 @@ private async Task<AgentSession> RestoreSessionAsync(AIAgent agent, HelplyConver
return await agent.CreateSessionAsync(ct);
}

private static HelplyStoredTool AddTool(HelplyStoredMessage message, string callId, string label, string? detail = null)
private static HelplyStoredTool AddTool(HelplyStoredMessage message, string callId, string label, string? detail = null, string? key = null, Dictionary<string, string>? args = null)
{
var tool = new HelplyStoredTool { Id = callId, Label = label, Status = HelplyToolStatus.Running, At = message.Text.Length, Detail = detail };
var tool = new HelplyStoredTool { Id = callId, Label = label, Status = HelplyToolStatus.Running, At = message.Text.Length, Detail = detail, Key = key, Args = args };
message.Tools.Add(tool);
return tool;
}

private static void AddFinishedTool(HelplyTranscript transcript, HelplyStoredApproval pending, string status)
{
var message = transcript.Messages.FirstOrDefault(m => m.Id == pending.MessageId);
message?.Tools.Add(new HelplyStoredTool { Id = pending.ToolCallId, Label = pending.ToolLabel, Status = status, At = message.Text.Length });
message?.Tools.Add(new HelplyStoredTool { Id = pending.ToolCallId, Label = pending.ToolLabel, Status = status, At = message.Text.Length, Key = HelplyToolLabels.Key(pending.ToolName), Args = HelplyToolLabels.Args(pending.ToolName, pending.ToolLabel, null) });
}

/// <summary>The mail domains of the user's linked Microsoft accounts: addresses on them count as inside the school.</summary>
Expand Down
57 changes: 47 additions & 10 deletions src/Schuly.API/Helply/HelplyToolLabels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,31 +79,68 @@ public static class HelplyToolLabels
["junkemail"] = "junk mail",
};

/// <summary>One harmless fact about a call. <see cref="Key"/> and <see cref="Value"/> are what the web app translates; <see cref="English"/> is the same note as plain English for clients that do not.</summary>
public sealed record StepNote(string Key, string Value, string English);

private static readonly string[] SearchSources = ["files", "channels", "mail", "chats", "pages"];

/// <summary>The key the web app translates a step by: the tool's name when it has a label of its own, otherwise "generic".</summary>
public static string Key(string toolName) => Labels.ContainsKey(toolName) ? toolName : GenericKey;

public const string GenericKey = "generic";

/// <summary>The structured arguments of a step label: the notes of <see cref="Notes"/> by key, plus the tool's title ("name") for a tool without a label of its own. Null when there is nothing to say.</summary>
public static Dictionary<string, string>? Args(string toolName, string? title, IDictionary<string, object?>? args)
{
var result = new Dictionary<string, string>(StringComparer.Ordinal);
if (!Labels.ContainsKey(toolName) && !string.IsNullOrWhiteSpace(title))
result["name"] = title;
foreach (var note in Notes(toolName, args))
result[note.Key] = note.Value;
return result.Count == 0 ? null : result;
}

/// <summary>
/// A short note under a step in the chat ("page 2", "unread only"), built only from arguments that say nothing personal: paging numbers, flags and well-known folder names.
/// A short note under a step in the chat ("page 2", "unread only"), built only from arguments that say nothing personal: paging numbers, flags, well-known folder names and the number of search terms and the sources searched.
/// Search text, ids, names and addresses never go into it. Null when there is nothing worth saying.
/// </summary>
public static string? Detail(string toolName, IDictionary<string, object?>? args)
{
var notes = Notes(toolName, args);
return notes.Count == 0 ? null : string.Join(" · ", notes.Select(n => n.English));
}

public static List<StepNote> Notes(string toolName, IDictionary<string, object?>? args)
{
var notes = new List<StepNote>();
if (args is null)
return null;
var notes = new List<string>();
return notes;
if (Number(args, "offset") is > 0 and var offset)
{
if (toolName.StartsWith("read_", StringComparison.Ordinal))
notes.Add("continued");
notes.Add(new StepNote("continued", "true", "continued"));
else if (Number(args, "limit") is > 0 and var limit)
notes.Add($"page {offset / limit + 1}");
notes.Add(new StepNote("page", (offset / limit + 1).ToString(System.Globalization.CultureInfo.InvariantCulture), $"page {offset / limit + 1}"));
else
notes.Add("more results");
notes.Add(new StepNote("more", "true", "more results"));
}
if (Bool(args, "unreadOnly") == true)
notes.Add("unread only");
notes.Add(new StepNote("unreadOnly", "true", "unread only"));
if (Text(args, "folder") is { } folder && WellKnownFolders.TryGetValue(folder, out var folderName))
notes.Add(folderName);
notes.Add(new StepNote("folder", folder.ToLowerInvariant(), folderName));
if (toolName == "list_mail" && !string.IsNullOrWhiteSpace(Text(args, "search")))
notes.Add("with a search");
return notes.Count == 0 ? null : string.Join(" · ", notes);
notes.Add(new StepNote("withSearch", "true", "with a search"));
if (toolName == "search")
{
var terms = Strings(args, "terms").Count(t => !string.IsNullOrWhiteSpace(t));
if (terms > 0)
notes.Add(new StepNote("terms", terms.ToString(System.Globalization.CultureInfo.InvariantCulture), terms == 1 ? "1 term" : $"{terms} terms"));
var sources = Strings(args, "sources").Select(s => s.Trim().ToLowerInvariant()).ToHashSet();
var named = SearchSources.Where(sources.Contains).ToList();
if (named.Count > 0)
notes.Add(new StepNote("sources", string.Join(",", named), "in " + string.Join(", ", named)));
}
return notes;
}

private static long? Number(IDictionary<string, object?> args, string name) => args.TryGetValue(name, out var value) ? value switch
Expand Down
4 changes: 2 additions & 2 deletions src/Schuly.Application/Dtos/HelplyDtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public record HelplyCodeRunDto(string Command, string? Output, int? ExitCode);

public record HelplyConversationSummaryDto(Guid Id, string Title, DateTime CreatedAt, DateTime UpdatedAt, DateTime ExpiresAt);

public record HelplyToolActivityDto(string Id, string Label, string Status, HelplyCodeRunDto? Code, int? At, string? Detail);
public record HelplyToolActivityDto(string Id, string Label, string Status, HelplyCodeRunDto? Code, int? At, string? Detail, string? Key, Dictionary<string, string>? Args);

public record HelplyMessageDto(Guid Id, string Role, string Text, DateTime CreatedAt, IReadOnlyList<HelplyToolActivityDto> Tools, bool Failed, IReadOnlyList<HelplyAttachmentDto> Attachments);

Expand All @@ -32,7 +32,7 @@ public static class HelplyDtoMapper

public static HelplyAttachmentDto ToDto(HelplyAttachmentInfo a) => new(a.Id, a.FileName, a.MediaType, a.SizeBytes);

public static HelplyToolActivityDto ToDto(HelplyStoredTool t) => new(t.Id, t.Label, t.Status, t.Code is null ? null : new HelplyCodeRunDto(t.Code.Command, t.Code.Output, t.Code.ExitCode), t.At, t.Detail);
public static HelplyToolActivityDto ToDto(HelplyStoredTool t) => new(t.Id, t.Label, t.Status, t.Code is null ? null : new HelplyCodeRunDto(t.Code.Command, t.Code.Output, t.Code.ExitCode), t.At, t.Detail, t.Key, t.Args);

public static HelplyConversationSummaryDto ToSummary(HelplyConversation c, string title) => new(c.Id, title, c.CreatedAt, c.LastActivityAt, c.ExpiresAt);

Expand Down
Loading
Loading