diff --git a/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderActionIds.cs b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderActionIds.cs
new file mode 100644
index 000000000..09e3f9e7b
--- /dev/null
+++ b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderActionIds.cs
@@ -0,0 +1,28 @@
+namespace Aevatar.GAgents.Authoring.Lark;
+
+///
+/// Single source of truth for the agent_builder_action identifiers wired between the
+/// card-rendering surface () and the dispatch surfaces
+/// (, ).
+///
+///
+/// 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.
+///
+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";
+}
diff --git a/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardContent.cs b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardContent.cs
index 0bb047709..90b6a80ed 100644
--- a/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardContent.cs
+++ b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardContent.cs
@@ -1,3 +1,4 @@
+using System.Text;
using System.Text.Json;
using Aevatar.GAgents.Channel.Abstractions;
using Aevatar.GAgents.Scheduled;
@@ -11,8 +12,12 @@ namespace Aevatar.GAgents.Authoring.Lark;
///
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) =>
@@ -185,6 +190,131 @@ public static MessageContent FormatDailyReportToolReply(JsonElement root)
return TextContent(string.Join('\n', lines));
}
+ ///
+ /// Renders /agents as a single consolidated card. The earlier design produced one
+ /// 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.
+ ///
+ /// The list-agents tool result JSON root element.
+ ///
+ /// 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.
+ ///
+ 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 ` — view full details\n");
+ bodyBuilder.Append("- `/run-agent ` — trigger immediately\n");
+ bodyBuilder.Append("- `/disable-agent ` · `/enable-agent ` — toggle scheduling\n");
+ bodyBuilder.Append("- `/delete-agent 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";
@@ -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);
}
diff --git a/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardFlow.cs b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardFlow.cs
index 4e47f8aa3..11c908970 100644
--- a/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardFlow.cs
+++ b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardFlow.cs
@@ -1,4 +1,5 @@
using System.Globalization;
+using System.Text;
using System.Text.Json;
using Aevatar.GAgents.Channel.Abstractions;
using Aevatar.GAgents.Channel.Runtime;
@@ -11,18 +12,18 @@ public static class AgentBuilderCardFlow
{
private const string PrivateChatType = "p2p";
private const string CardActionChatType = "card_action";
- private const string OpenDailyReportFormAction = "open_daily_report_form";
- private const string OpenSocialMediaFormAction = "open_social_media_form";
- private const string DailyReportAction = "create_daily_report";
- private const string SocialMediaAction = "create_social_media";
- private const string ListTemplatesAction = "list_templates";
- private const string ListAgentsAction = "list_agents";
- private const string AgentStatusAction = "agent_status";
- private const string RunAgentAction = "run_agent";
- private const string DisableAgentAction = "disable_agent";
- private const string EnableAgentAction = "enable_agent";
- private const string ConfirmDeleteAgentAction = "confirm_delete_agent";
- private const string DeleteAgentAction = "delete_agent";
+ private const string OpenDailyReportFormAction = AgentBuilderActionIds.OpenDailyReportForm;
+ private const string OpenSocialMediaFormAction = AgentBuilderActionIds.OpenSocialMediaForm;
+ private const string DailyReportAction = AgentBuilderActionIds.DailyReport;
+ private const string SocialMediaAction = AgentBuilderActionIds.SocialMedia;
+ private const string ListTemplatesAction = AgentBuilderActionIds.ListTemplates;
+ private const string ListAgentsAction = AgentBuilderActionIds.ListAgents;
+ private const string AgentStatusAction = AgentBuilderActionIds.AgentStatus;
+ private const string RunAgentAction = AgentBuilderActionIds.RunAgent;
+ private const string DisableAgentAction = AgentBuilderActionIds.DisableAgent;
+ private const string EnableAgentAction = AgentBuilderActionIds.EnableAgent;
+ private const string ConfirmDeleteAgentAction = AgentBuilderActionIds.ConfirmDeleteAgent;
+ private const string DeleteAgentAction = AgentBuilderActionIds.DeleteAgent;
private const string DefaultScheduleTime = "09:00";
private const string SocialMediaCommand = "/social-media";
private const string AgentStatusCommand = "/agent-status";
@@ -237,6 +238,8 @@ private static bool TryResolve(
return true;
}
+ // Use the MessageContent overload so the relay composer renders this as a real
+ // Lark card instead of forwarding a JSON-as-text payload (issue #482).
decision = AgentBuilderFlowDecision.DirectReply(BuildDeleteConfirmationCard(
agentId,
evt.Extra.TryGetValue("template", out var template) ? template : null));
@@ -257,6 +260,12 @@ private static bool TryResolve(
}
}
+ ///
+ /// Formats the tool result for a card-action invocation. Each branch returns a structured
+ /// with Cards and Actions populated; never a Lark
+ /// card JSON string wrapped as . The latter shape used to
+ /// reach the relay verbatim and the user saw raw {"config":...} blobs (issue #482).
+ ///
public static MessageContent FormatToolResult(AgentBuilderFlowDecision decision, string toolResultJson)
{
ArgumentNullException.ThrowIfNull(decision);
@@ -268,16 +277,20 @@ public static MessageContent FormatToolResult(AgentBuilderFlowDecision decision,
{
// Daily report creation uses the shared formatter so Nyx-relay slash commands and
// Feishu card-action submits render the same "running now, I'll reply when done"
- // acknowledgment instead of one path dumping the legacy JSON card as text.
+ // acknowledgment.
DailyReportAction => AgentBuilderCardContent.FormatDailyReportToolReply(doc.RootElement),
- SocialMediaAction => ToTextContent(FormatCreateSocialMediaResult(doc.RootElement)),
- ListTemplatesAction => ToTextContent(FormatListTemplatesResult(doc.RootElement)),
- ListAgentsAction => ToTextContent(FormatListAgentsResult(doc.RootElement)),
- AgentStatusAction => ToTextContent(FormatAgentStatusResult(doc.RootElement)),
- RunAgentAction => ToTextContent(FormatRunAgentResult(doc.RootElement)),
- DisableAgentAction => ToTextContent(FormatDisableAgentResult(doc.RootElement)),
- EnableAgentAction => ToTextContent(FormatEnableAgentResult(doc.RootElement)),
- DeleteAgentAction => ToTextContent(FormatDeleteAgentResult(doc.RootElement)),
+ SocialMediaAction => FormatCreateSocialMediaResult(doc.RootElement),
+ ListTemplatesAction => FormatListTemplatesResult(doc.RootElement),
+ // Card-click "Refresh List" and the typed `/agents` command share the same
+ // unified renderer (issue #476).
+ ListAgentsAction => AgentBuilderCardContent.FormatListAgentsResult(doc.RootElement),
+ AgentStatusAction => FormatAgentStatusResult(doc.RootElement),
+ RunAgentAction => FormatRunAgentResult(doc.RootElement),
+ DisableAgentAction => FormatDisableAgentResult(doc.RootElement),
+ EnableAgentAction => FormatEnableAgentResult(doc.RootElement),
+ // After a delete completes, surface the updated registry through the same unified
+ // list renderer with the delete notice prepended.
+ DeleteAgentAction => FormatDeleteAgentResultAsList(doc.RootElement),
_ => ToTextContent(toolResultJson),
};
}
@@ -287,7 +300,7 @@ public static MessageContent FormatToolResult(AgentBuilderFlowDecision decision,
}
}
- private static MessageContent ToTextContent(string text) => new() { Text = text };
+ private static MessageContent ToTextContent(string text) => AgentBuilderJson.TextContent(text);
public static string ResolveToolChatType(ChannelInboundEvent evt)
{
@@ -500,19 +513,56 @@ private static bool TryResolvePrivateChatCommand(
return true;
}
- if (TryParseAgentCommand(normalizedText, DeleteAgentCommand, out agentId, out errorReply))
+ if (TryResolveDeleteAgentTextCommand(normalizedText, out decision))
+ return true;
+
+ return false;
+ }
+
+ ///
+ /// Parses /delete-agent <agent_id> [confirm]. The optional confirm trailer
+ /// matches the NyxRelay text contract (and the inline command hint surfaced from the shared
+ /// /agents renderer) so a user who follows the printed hint
+ /// /delete-agent <id> confirm in a direct-webhook chat does not end up with
+ /// "<id> confirm" being treated as a single agent_id by the legacy
+ /// parser. Without the trailing keyword we still surface
+ /// the explicit confirmation card; with it we skip the extra step and dispatch the delete
+ /// directly, mirroring the relay path's semantics.
+ ///
+ private static bool TryResolveDeleteAgentTextCommand(
+ string normalizedText,
+ out AgentBuilderFlowDecision? decision)
+ {
+ decision = null;
+ if (!normalizedText.StartsWith(DeleteAgentCommand, StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ var tokens = ChannelTextCommandParser.Tokenize(normalizedText);
+ if (tokens.Count < 2 || string.IsNullOrWhiteSpace(tokens[1]))
{
- if (errorReply != null)
- {
- decision = AgentBuilderFlowDecision.DirectReply(errorReply);
- return true;
- }
+ decision = AgentBuilderFlowDecision.DirectReply($"Usage: {DeleteAgentCommand} ");
+ return true;
+ }
- decision = AgentBuilderFlowDecision.DirectReply(BuildDeleteConfirmationCard(agentId!, null));
+ var agentId = tokens[1].Trim();
+ var confirmed = tokens.Count > 2 &&
+ string.Equals(tokens[2], "confirm", StringComparison.OrdinalIgnoreCase);
+
+ if (confirmed)
+ {
+ decision = AgentBuilderFlowDecision.ToolCall(
+ DeleteAgentAction,
+ JsonSerializer.Serialize(new
+ {
+ action = DeleteAgentAction,
+ agent_id = agentId,
+ confirm = true,
+ }));
return true;
}
- return false;
+ decision = AgentBuilderFlowDecision.DirectReply(BuildDeleteConfirmationCard(agentId, null));
+ return true;
}
private static bool TryParseAgentCommand(
@@ -597,65 +647,69 @@ private static bool ShouldLoadPreferredGithubUsername(ChannelInboundEvent evt)
return normalized.Length == 0 ? null : normalized;
}
- private static string FormatCreateSocialMediaResult(JsonElement root)
+ private static MessageContent FormatCreateSocialMediaResult(JsonElement root)
{
if (TryReadError(root, out var error))
- return $"Create social media agent failed: {error}";
+ return ToTextContent($"Create social media agent failed: {error}");
var status = ReadString(root, "status") ?? "accepted";
var agentId = ReadString(root, "agent_id") ?? "unknown-agent";
var workflowId = ReadString(root, "workflow_id") ?? "pending";
var nextRun = ReadString(root, "next_scheduled_run") ?? "pending";
- var note = ReadString(root, "note");
-
- var lines = new List
- {
- string.Equals(status, "created", StringComparison.OrdinalIgnoreCase)
- ? $"Social media agent created: {agentId}"
- : $"Social media agent accepted: {agentId}",
- $"Workflow ID: `{workflowId}`",
- $"Next scheduled run: {nextRun}",
- };
-
- if (!string.IsNullOrWhiteSpace(note))
- lines.Add(note!);
-
- return BuildInfoCard(
- "Social Media Agent",
- string.Join("\n", lines),
- "orange",
- new object[]
- {
- BuildButton("View Agents", "primary", new
- {
- agent_builder_action = ListAgentsAction,
- }),
- BuildButton("Create Another", "default", new
- {
- agent_builder_action = OpenSocialMediaFormAction,
- }),
- });
+ var note = NormalizeOptional(ReadString(root, "note"));
+
+ var headline = string.Equals(status, "created", StringComparison.OrdinalIgnoreCase)
+ ? "Social media agent created."
+ : "Social media agent accepted.";
+
+ var body = new StringBuilder();
+ body.Append(headline).Append('\n');
+ body.Append($"- Agent ID: `{agentId}`\n");
+ body.Append($"- Workflow ID: `{workflowId}`\n");
+ body.Append($"- Next scheduled run: `{nextRun}`");
+ if (note is not null)
+ body.Append("\n\n").Append(note);
+
+ var content = new MessageContent();
+ content.Cards.Add(new CardBlock
+ {
+ Kind = CardBlockKind.Section,
+ BlockId = $"social_media_created:{agentId}",
+ Title = "Social Media Agent",
+ Text = body.ToString(),
+ });
+ content.Actions.Add(BuildCardAction("View Agents", ListAgentsAction, isPrimary: true));
+ content.Actions.Add(BuildCardAction("Create Another", OpenSocialMediaFormAction, isPrimary: false));
+ return content;
}
- private static string FormatListTemplatesResult(JsonElement root)
+ private static MessageContent FormatListTemplatesResult(JsonElement root)
{
if (TryReadError(root, out var error))
- return $"List templates failed: {error}";
+ return ToTextContent($"List templates failed: {error}");
+
+ var content = new MessageContent();
if (!root.TryGetProperty("templates", out var templatesElement) ||
- templatesElement.ValueKind != JsonValueKind.Array)
+ templatesElement.ValueKind != JsonValueKind.Array ||
+ templatesElement.GetArrayLength() == 0)
{
- return "No templates available.";
+ content.Cards.Add(new CardBlock
+ {
+ Kind = CardBlockKind.Section,
+ BlockId = "templates_empty",
+ Title = "Available Templates",
+ Text = "No templates available right now.",
+ });
+ content.Actions.Add(BuildCardAction("View Agents", ListAgentsAction, isPrimary: false));
+ return content;
}
- var elements = new List