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
28 changes: 28 additions & 0 deletions agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderActionIds.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace Aevatar.GAgents.Authoring.Lark;

/// <summary>
/// Single source of truth for the <c>agent_builder_action</c> identifiers wired between the
/// card-rendering surface (<see cref="AgentBuilderCardContent"/>) and the dispatch surfaces
/// (<see cref="AgentBuilderCardFlow"/>, <see cref="NyxRelayAgentBuilderFlow"/>).
/// </summary>
/// <remarks>
/// Keeping these in one place avoids the silent-divergence hazard of redeclaring the same string
/// literal in every consumer: a typo in a renderer's button argument would route the click to a
/// fallback branch with no compile-time signal. The card-flow router and the shared content
/// builders both reference the same constant by name.
/// </remarks>
internal static class AgentBuilderActionIds
{
public const string DailyReport = "create_daily_report";
public const string SocialMedia = "create_social_media";
public const string OpenDailyReportForm = "open_daily_report_form";
public const string OpenSocialMediaForm = "open_social_media_form";
public const string ListTemplates = "list_templates";
public const string ListAgents = "list_agents";
public const string AgentStatus = "agent_status";
public const string RunAgent = "run_agent";
public const string DisableAgent = "disable_agent";
public const string EnableAgent = "enable_agent";
public const string ConfirmDeleteAgent = "confirm_delete_agent";
public const string DeleteAgent = "delete_agent";
}
181 changes: 141 additions & 40 deletions agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardContent.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text;
using System.Text.Json;
using Aevatar.GAgents.Channel.Abstractions;
using Aevatar.GAgents.Scheduled;
Expand All @@ -11,8 +12,12 @@ namespace Aevatar.GAgents.Authoring.Lark;
/// </summary>
public static class AgentBuilderCardContent
{
private const string DailyReportAction = "create_daily_report";
private const string SocialMediaAction = "create_social_media";
private const string DailyReportAction = AgentBuilderActionIds.DailyReport;
private const string SocialMediaAction = AgentBuilderActionIds.SocialMedia;
private const string OpenDailyReportFormAction = AgentBuilderActionIds.OpenDailyReportForm;
private const string OpenSocialMediaFormAction = AgentBuilderActionIds.OpenSocialMediaForm;
private const string ListTemplatesAction = AgentBuilderActionIds.ListTemplates;
private const string ListAgentsAction = AgentBuilderActionIds.ListAgents;
private const string DefaultScheduleTime = "09:00";

public static MessageContent BuildDailyReportForm(string? preferredGithubUsername) =>
Expand Down Expand Up @@ -185,6 +190,131 @@ public static MessageContent FormatDailyReportToolReply(JsonElement root)
return TextContent(string.Join('\n', lines));
}

/// <summary>
/// Renders <c>/agents</c> as a single consolidated card. The earlier design produced one
/// <see cref="CardBlock"/> per agent plus per-agent "Status: …" buttons; in Lark that compiled
/// into many stacked markdown blocks followed by a long button row, which users perceived as a
/// text list mixed with a separate status card (issue #476). The unified design surfaces one
/// card with a structured agent list in the body and a small footer of global actions, while
/// per-agent operations stay accessible through the documented slash commands listed inline.
/// </summary>
/// <param name="root">The list-agents tool result JSON root element.</param>
/// <param name="noticeMarkdown">
/// Optional headline to prepend to the body, e.g. a "Deleted agent X" notice when the same
/// renderer is reused as the post-delete acknowledgment so the user sees the updated registry
/// without a second card hop.
/// </param>
public static MessageContent FormatListAgentsResult(JsonElement root, string? noticeMarkdown = null)
{
if (TryReadError(root, out var error))
return TextContent($"List agents failed: {error}");

var content = new MessageContent();
var notice = NormalizeOptionalMarkdown(noticeMarkdown);

if (!root.TryGetProperty("agents", out var agentsElement) ||
agentsElement.ValueKind != JsonValueKind.Array ||
agentsElement.GetArrayLength() == 0)
{
var emptyBody = new StringBuilder();
if (notice is not null)
{
emptyBody.Append(notice);
emptyBody.Append("\n\n");
}
emptyBody.Append("No agents yet. Create one to get started:\n");
emptyBody.Append("- `/daily` — daily GitHub report\n");
emptyBody.Append("- `/social-media` — social-media drafter\n\n");
emptyBody.Append("Run `/templates` to browse all available templates.");

content.Cards.Add(new CardBlock
{
Kind = CardBlockKind.Section,
BlockId = "agents_empty",
Title = "Your Agents",
Text = emptyBody.ToString(),
});
content.Actions.Add(BuildAction("Create Daily Report", OpenDailyReportFormAction, isPrimary: true));
content.Actions.Add(BuildAction("Create Social Media", OpenSocialMediaFormAction, isPrimary: false));
content.Actions.Add(BuildAction("Templates", ListTemplatesAction, isPrimary: false));
return content;
}

var totalCount = agentsElement.GetArrayLength();
var bodyBuilder = new StringBuilder();
if (notice is not null)
{
bodyBuilder.Append(notice);
bodyBuilder.Append("\n\n");
}

var index = 0;
foreach (var agent in agentsElement.EnumerateArray())
{
index++;
var agentId = TryReadString(agent, "agent_id") ?? "unknown-agent";
var template = TryReadString(agent, "template") ?? "unknown-template";
var status = TryReadString(agent, "status") ?? "unknown";
var nextRun = TryReadString(agent, "next_scheduled_run") ?? "pending";
var lastRun = TryReadOptional(agent, "last_run_at");

if (index > 1)
bodyBuilder.Append("\n\n");

bodyBuilder.Append($"**{index}. `{template}`** · {status}\n");
bodyBuilder.Append($"- Agent ID: `{agentId}`\n");
bodyBuilder.Append($"- Next run: `{nextRun}`");
if (lastRun is not null)
{
bodyBuilder.Append('\n');
bodyBuilder.Append($"- Last run: `{lastRun}`");
}
}

bodyBuilder.Append("\n\n**Manage agents** with these commands:\n");
bodyBuilder.Append("- `/agent-status <id>` — view full details\n");
bodyBuilder.Append("- `/run-agent <id>` — trigger immediately\n");
bodyBuilder.Append("- `/disable-agent <id>` · `/enable-agent <id>` — toggle scheduling\n");
bodyBuilder.Append("- `/delete-agent <id> confirm` — remove the agent");

content.Cards.Add(new CardBlock
{
Kind = CardBlockKind.Section,
BlockId = "agents_list",
Title = $"Your Agents ({totalCount})",
Text = bodyBuilder.ToString(),
});

// Footer is intentionally limited to discovery / creation shortcuts. Per-agent actions
// (status, run, disable, enable, delete) deliberately stay off this card to avoid the
// visual "list + status panel" duplication called out in issue #476; the inline command
// hints in the body cover the same ground without the layout noise.
content.Actions.Add(BuildAction("Refresh", ListAgentsAction, isPrimary: false));
content.Actions.Add(BuildAction("Templates", ListTemplatesAction, isPrimary: false));
content.Actions.Add(BuildAction("Create Daily Report", OpenDailyReportFormAction, isPrimary: false));
content.Actions.Add(BuildAction("Create Social Media", OpenSocialMediaFormAction, isPrimary: false));
return content;
}

private static string? NormalizeOptionalMarkdown(string? value)
{
var trimmed = value?.Trim();
return string.IsNullOrEmpty(trimmed) ? null : trimmed;
}

private static ActionElement BuildAction(string label, string agentBuilderAction, bool isPrimary)
{
var button = new ActionElement
{
Kind = ActionElementKind.Button,
ActionId = agentBuilderAction,
Label = label,
IsPrimary = isPrimary,
};
button.Arguments["agent_builder_action"] = agentBuilderAction;
return button;
}

private static MessageContent BuildDailyReportCredentialsCard(JsonElement root, string status)
{
var providerId = TryReadString(root, "provider_id") ?? "unknown-provider";
Expand Down Expand Up @@ -251,46 +381,17 @@ private static ActionElement BuildFormSubmit(string actionId, string label, bool
IsPrimary = isPrimary,
};

private static MessageContent TextContent(string text) => new() { Text = text };

private static bool TryReadError(JsonElement root, out string error)
{
error = TryReadString(root, "error") ?? string.Empty;
return error.Length > 0;
}

private static string? TryReadString(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var property))
return null;
private static MessageContent TextContent(string text) => AgentBuilderJson.TextContent(text);

return property.ValueKind switch
{
JsonValueKind.String => property.GetString(),
JsonValueKind.Number => property.GetRawText(),
JsonValueKind.True => bool.TrueString,
JsonValueKind.False => bool.FalseString,
_ => null,
};
}
private static bool TryReadError(JsonElement root, out string error) =>
AgentBuilderJson.TryReadError(root, out error);

private static bool TryReadBool(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var property))
return false;
private static string? TryReadString(JsonElement element, string propertyName) =>
AgentBuilderJson.TryReadString(element, propertyName);

return property.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String => bool.TryParse(property.GetString(), out var parsed) && parsed,
_ => false,
};
}
private static bool TryReadBool(JsonElement element, string propertyName) =>
AgentBuilderJson.TryReadBool(element, propertyName);

private static string? TryReadOptional(JsonElement element, string propertyName)
{
var raw = TryReadString(element, propertyName);
return string.IsNullOrWhiteSpace(raw) ? null : raw.Trim();
}
private static string? TryReadOptional(JsonElement element, string propertyName) =>
AgentBuilderJson.TryReadOptional(element, propertyName);
}
Loading
Loading