From 8f4b0adc26a880df092f88a50125f2432c3a757b Mon Sep 17 00:00:00 2001 From: agent Date: Tue, 3 Mar 2026 06:28:32 -0800 Subject: [PATCH 01/48] feat(core): add agent self-scheduling via MCP server Allow agents to create, update, and delete their own schedules at runtime through a capability-gated MCP server. This solves the problem where agents couldn't reliably set up dynamic schedules (e.g., FedEx tracking) because they didn't know the correct config file path. - Add `self_scheduling` config field (enabled, max_schedules, min_interval) - Create per-agent YAML store at .herdctl/dynamic-schedules/.yaml - Implement herdctl-scheduler stdio MCP server with 4 tools - Auto-inject MCP server into agents with self_scheduling.enabled - Merge dynamic schedules with static ones in scheduler (static wins) - Add source: "static" | "dynamic" tag to ScheduleInfo for visibility - Safety: namespace isolation, count limits, min interval, TTL support Co-Authored-By: Claude Opus 4.6 --- packages/core/package.json | 1 + .../config/__tests__/self-scheduling.test.ts | 163 +++++ packages/core/src/config/index.ts | 5 + packages/core/src/config/schema.ts | 31 + packages/core/src/config/self-scheduling.ts | 67 ++ .../core/src/fleet-manager/fleet-manager.ts | 8 + .../src/fleet-manager/schedule-management.ts | 40 ++ .../core/src/fleet-manager/status-queries.ts | 1 + packages/core/src/fleet-manager/types.ts | 5 + packages/core/src/mcp/scheduler-mcp.ts | 316 +++++++++ .../__tests__/dynamic-schedules.test.ts | 662 ++++++++++++++++++ .../core/src/scheduler/dynamic-schedules.ts | 486 +++++++++++++ packages/core/src/scheduler/index.ts | 20 + packages/core/src/scheduler/scheduler.ts | 48 +- 14 files changed, 1850 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/config/__tests__/self-scheduling.test.ts create mode 100644 packages/core/src/config/self-scheduling.ts create mode 100644 packages/core/src/mcp/scheduler-mcp.ts create mode 100644 packages/core/src/scheduler/__tests__/dynamic-schedules.test.ts create mode 100644 packages/core/src/scheduler/dynamic-schedules.ts diff --git a/packages/core/package.json b/packages/core/package.json index 9d5cbfd8..251eff74 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.1.0", + "@modelcontextprotocol/sdk": "^1.27.1", "chokidar": "^5", "cron-parser": "^4.9.0", "dockerode": "^4.0.9", diff --git a/packages/core/src/config/__tests__/self-scheduling.test.ts b/packages/core/src/config/__tests__/self-scheduling.test.ts new file mode 100644 index 00000000..93053943 --- /dev/null +++ b/packages/core/src/config/__tests__/self-scheduling.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import type { ResolvedAgent } from "../loader.js"; +import { injectSchedulerMcpServers } from "../self-scheduling.js"; + +function makeAgent(overrides: Partial = {}): ResolvedAgent { + return { + name: "test-agent", + qualifiedName: "test-agent", + configPath: "/tmp/agents/test-agent/agent.yaml", + fleetPath: [], + ...overrides, + } as ResolvedAgent; +} + +describe("injectSchedulerMcpServers", () => { + it("injects MCP server when self_scheduling.enabled is true", () => { + const agents = [ + makeAgent({ + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, + }), + ]; + + injectSchedulerMcpServers(agents, "/tmp/.herdctl"); + + expect(agents[0].mcp_servers).toBeDefined(); + expect(agents[0].mcp_servers!["herdctl-scheduler"]).toBeDefined(); + + const server = agents[0].mcp_servers!["herdctl-scheduler"]; + expect(server.command).toBe("node"); + expect(server.args).toHaveLength(1); + expect(server.args![0]).toContain("scheduler-mcp.js"); + expect(server.env!.HERDCTL_AGENT_NAME).toBe("test-agent"); + expect(server.env!.HERDCTL_STATE_DIR).toBe("/tmp/.herdctl"); + expect(server.env!.HERDCTL_MAX_SCHEDULES).toBe("10"); + expect(server.env!.HERDCTL_MIN_INTERVAL).toBe("5m"); + }); + + it("does not inject when self_scheduling is not set", () => { + const agents = [makeAgent()]; + + injectSchedulerMcpServers(agents, "/tmp/.herdctl"); + + expect(agents[0].mcp_servers).toBeUndefined(); + }); + + it("does not inject when self_scheduling.enabled is false", () => { + const agents = [ + makeAgent({ + self_scheduling: { enabled: false, max_schedules: 10, min_interval: "5m" }, + }), + ]; + + injectSchedulerMcpServers(agents, "/tmp/.herdctl"); + + expect(agents[0].mcp_servers).toBeUndefined(); + }); + + it("does not overwrite manually declared herdctl-scheduler", () => { + const agents = [ + makeAgent({ + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, + mcp_servers: { + "herdctl-scheduler": { + command: "custom-scheduler", + args: ["--custom"], + }, + }, + }), + ]; + + injectSchedulerMcpServers(agents, "/tmp/.herdctl"); + + expect(agents[0].mcp_servers!["herdctl-scheduler"].command).toBe("custom-scheduler"); + }); + + it("includes static schedule names in env", () => { + const agents = [ + makeAgent({ + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, + schedules: { + "daily-report": { type: "cron", cron: "0 9 * * *", enabled: true, resume_session: false }, + "hourly-check": { + type: "interval", + interval: "1h", + enabled: true, + resume_session: false, + }, + }, + }), + ]; + + injectSchedulerMcpServers(agents, "/tmp/.herdctl"); + + const staticSchedules = + agents[0].mcp_servers!["herdctl-scheduler"].env!.HERDCTL_STATIC_SCHEDULES; + expect(staticSchedules).toBeDefined(); + expect(staticSchedules).toContain("daily-report"); + expect(staticSchedules).toContain("hourly-check"); + }); + + it("uses qualified name for HERDCTL_AGENT_NAME", () => { + const agents = [ + makeAgent({ + name: "agent", + qualifiedName: "fleet.subfleet.agent", + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, + }), + ]; + + injectSchedulerMcpServers(agents, "/tmp/.herdctl"); + + expect(agents[0].mcp_servers!["herdctl-scheduler"].env!.HERDCTL_AGENT_NAME).toBe( + "fleet.subfleet.agent", + ); + }); + + it("uses custom max_schedules and min_interval values", () => { + const agents = [ + makeAgent({ + self_scheduling: { enabled: true, max_schedules: 5, min_interval: "15m" }, + }), + ]; + + injectSchedulerMcpServers(agents, "/tmp/.herdctl"); + + const env = agents[0].mcp_servers!["herdctl-scheduler"].env!; + expect(env.HERDCTL_MAX_SCHEDULES).toBe("5"); + expect(env.HERDCTL_MIN_INTERVAL).toBe("15m"); + }); + + it("initializes mcp_servers map when it doesn't exist", () => { + const agents = [ + makeAgent({ + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, + mcp_servers: undefined, + }), + ]; + + injectSchedulerMcpServers(agents, "/tmp/.herdctl"); + + expect(agents[0].mcp_servers).toBeDefined(); + expect(agents[0].mcp_servers!["herdctl-scheduler"]).toBeDefined(); + }); + + it("preserves existing MCP servers when injecting", () => { + const agents = [ + makeAgent({ + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, + mcp_servers: { + "existing-server": { + command: "node", + args: ["existing.js"], + }, + }, + }), + ]; + + injectSchedulerMcpServers(agents, "/tmp/.herdctl"); + + expect(agents[0].mcp_servers!["existing-server"]).toBeDefined(); + expect(agents[0].mcp_servers!["herdctl-scheduler"]).toBeDefined(); + }); +}); diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index cd283f05..eaaa4536 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -143,6 +143,8 @@ export { ScheduleSchema, type ScheduleType, ScheduleTypeSchema, + type SelfScheduling, + SelfSchedulingSchema, type Session, SessionSchema, type ShellHookConfig, @@ -172,3 +174,6 @@ export { type WorkSourceType, WorkSourceTypeSchema, } from "./schema.js"; + +// Self-scheduling injection +export { injectSchedulerMcpServers } from "./self-scheduling.js"; diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index e6d200f5..4af27a9b 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -838,6 +838,34 @@ export const AgentHooksSchema = z.object({ export const AgentWorkingDirectorySchema = z.union([z.string(), WorkingDirectorySchema]); +// ============================================================================= +// Self-Scheduling Schema +// ============================================================================= + +/** + * Configuration for agent self-scheduling capability + * + * When enabled, agents can create, update, and delete their own schedules + * at runtime via an auto-injected MCP server. The fleet operator controls + * which agents have this capability and sets safety limits. + * + * @example + * ```yaml + * self_scheduling: + * enabled: true + * max_schedules: 10 + * min_interval: "5m" + * ``` + */ +export const SelfSchedulingSchema = z.object({ + /** Whether self-scheduling is enabled for this agent */ + enabled: z.boolean().default(false), + /** Maximum number of dynamic schedules this agent can create (default: 10) */ + max_schedules: z.number().int().positive().optional().default(10), + /** Minimum interval between schedule triggers (default: "5m") */ + min_interval: z.string().optional().default("5m"), +}); + // ============================================================================= // Agent Configuration Schema // ============================================================================= @@ -877,6 +905,8 @@ export const AgentConfigSchema = z denied_tools: z.array(z.string()).optional(), /** Path to metadata JSON file written by agent (default: metadata.json in workspace) */ metadata_file: z.string().optional(), + /** Self-scheduling configuration — allows agent to create its own schedules at runtime */ + self_scheduling: SelfSchedulingSchema.optional(), /** * Setting sources for Claude SDK configuration discovery. * Controls where Claude looks for CLAUDE.md, skills, commands, etc. @@ -1076,6 +1106,7 @@ export type AgentChat = z.infer; export type SlackChannel = z.infer; export type AgentChatSlack = z.infer; export type AgentWorkingDirectory = z.infer; +export type SelfScheduling = z.infer; export type AgentConfig = z.infer; // Hook types - Output types (after parsing with defaults applied) export type HookEvent = z.infer; diff --git a/packages/core/src/config/self-scheduling.ts b/packages/core/src/config/self-scheduling.ts new file mode 100644 index 00000000..6124847e --- /dev/null +++ b/packages/core/src/config/self-scheduling.ts @@ -0,0 +1,67 @@ +/** + * Self-scheduling MCP server injection + * + * When an agent has `self_scheduling.enabled: true`, this module injects the + * herdctl-scheduler MCP server into the agent's mcp_servers. The MCP server + * lets the agent create, update, and delete its own dynamic schedules. + * + * Called from FleetManager.initialize() after the stateDir is known. + */ + +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ResolvedAgent } from "./loader.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** + * Path to the compiled scheduler MCP server script. + * Resolves relative to this file's location in dist/. + */ +function getSchedulerMcpPath(): string { + // This file: dist/config/self-scheduling.js + // Target: dist/mcp/scheduler-mcp.js + return join(__dirname, "..", "mcp", "scheduler-mcp.js"); +} + +/** + * Inject the herdctl-scheduler MCP server into agents that have + * self_scheduling.enabled. Mutates the agent configs in place. + * + * @param agents - Resolved agent configs + * @param stateDir - Absolute path to the .herdctl state directory + */ +export function injectSchedulerMcpServers(agents: ResolvedAgent[], stateDir: string): void { + const mcpPath = getSchedulerMcpPath(); + + for (const agent of agents) { + if (!agent.self_scheduling?.enabled) continue; + + const selfScheduling = agent.self_scheduling; + + // Collect static schedule names for collision prevention + const staticScheduleNames = agent.schedules ? Object.keys(agent.schedules) : []; + + // Initialize mcp_servers map if needed + if (!agent.mcp_servers) { + agent.mcp_servers = {}; + } + + // Don't overwrite if operator explicitly declared the server + if ("herdctl-scheduler" in agent.mcp_servers) continue; + + agent.mcp_servers["herdctl-scheduler"] = { + command: "node", + args: [mcpPath], + env: { + HERDCTL_AGENT_NAME: agent.qualifiedName, + HERDCTL_STATE_DIR: stateDir, + HERDCTL_MAX_SCHEDULES: String(selfScheduling.max_schedules ?? 10), + HERDCTL_MIN_INTERVAL: selfScheduling.min_interval ?? "5m", + ...(staticScheduleNames.length > 0 && { + HERDCTL_STATIC_SCHEDULES: staticScheduleNames.join(","), + }), + }, + }; + } +} diff --git a/packages/core/src/fleet-manager/fleet-manager.ts b/packages/core/src/fleet-manager/fleet-manager.ts index 3d208e74..8512c793 100644 --- a/packages/core/src/fleet-manager/fleet-manager.ts +++ b/packages/core/src/fleet-manager/fleet-manager.ts @@ -21,6 +21,7 @@ import { type ResolvedAgent, type ResolvedConfig, } from "../config/index.js"; +import { injectSchedulerMcpServers } from "../config/self-scheduling.js"; import { Scheduler, type TriggerInfo } from "../scheduler/index.js"; import { initStateDirectory, type StateDirectory } from "../state/index.js"; import { createLogger } from "../utils/logger.js"; @@ -299,6 +300,9 @@ export class FleetManager extends EventEmitter implements FleetManagerContext { this.stateDirInfo = await this.initializeStateDir(); this.logger.debug("State directory initialized"); + // Inject herdctl-scheduler MCP server into agents with self_scheduling enabled + injectSchedulerMcpServers(this.config.agents, this.stateDir); + this.scheduler = new Scheduler({ stateDir: this.stateDir, checkInterval: this.checkInterval, @@ -522,6 +526,10 @@ export class FleetManager extends EventEmitter implements FleetManagerContext { this, () => this.loadConfiguration(), (config) => { + // Re-inject scheduler MCP servers for agents with self_scheduling enabled + if (this.stateDirInfo) { + injectSchedulerMcpServers(config.agents, this.stateDir); + } this.config = config; }, ); diff --git a/packages/core/src/fleet-manager/schedule-management.ts b/packages/core/src/fleet-manager/schedule-management.ts index 37f00727..030fb7af 100644 --- a/packages/core/src/fleet-manager/schedule-management.ts +++ b/packages/core/src/fleet-manager/schedule-management.ts @@ -7,6 +7,7 @@ * @module schedule-management */ +import { loadAllDynamicSchedules } from "../scheduler/dynamic-schedules.js"; import type { FleetManagerContext } from "./context.js"; import { AgentNotFoundError, ScheduleNotFoundError } from "./errors.js"; import { buildScheduleInfoList, type FleetStateSnapshot } from "./status-queries.js"; @@ -40,13 +41,52 @@ export class ScheduleManagement { const config = this.ctx.getConfig(); const agents = config?.agents ?? []; const fleetState = await this.readFleetStateSnapshotFn(); + const stateDir = this.ctx.getStateDir(); const allSchedules: ScheduleInfo[] = []; + // Load dynamic schedules from disk + let dynamicSchedules = new Map< + string, + Record< + string, + { type: string; interval?: string; cron?: string; prompt?: string; enabled?: boolean } + > + >(); + try { + dynamicSchedules = (await loadAllDynamicSchedules(stateDir)) as typeof dynamicSchedules; + } catch { + // Ignore errors loading dynamic schedules — show static ones at minimum + } + for (const agent of agents) { const agentState = fleetState.agents[agent.qualifiedName]; const schedules = buildScheduleInfoList(agent, agentState); allSchedules.push(...schedules); + + // Add dynamic schedules for this agent + const agentDynamic = dynamicSchedules.get(agent.qualifiedName); + if (agentDynamic) { + const staticNames = new Set(agent.schedules ? Object.keys(agent.schedules) : []); + for (const [name, schedule] of Object.entries(agentDynamic)) { + // Skip if a static schedule with the same name exists (static wins) + if (staticNames.has(name)) continue; + + const scheduleState = agentState?.schedules?.[name]; + allSchedules.push({ + name, + agentName: agent.qualifiedName, + type: schedule.type, + interval: schedule.interval, + cron: schedule.cron, + status: scheduleState?.status ?? "idle", + lastRunAt: scheduleState?.last_run_at ?? null, + nextRunAt: scheduleState?.next_run_at ?? null, + lastError: scheduleState?.last_error ?? null, + source: "dynamic", + }); + } + } } return allSchedules; diff --git a/packages/core/src/fleet-manager/status-queries.ts b/packages/core/src/fleet-manager/status-queries.ts index f1cb0ed8..3f3c495a 100644 --- a/packages/core/src/fleet-manager/status-queries.ts +++ b/packages/core/src/fleet-manager/status-queries.ts @@ -347,6 +347,7 @@ export function buildScheduleInfoList( lastRunAt: scheduleState?.last_run_at ?? null, nextRunAt: scheduleState?.next_run_at ?? null, lastError: scheduleState?.last_error ?? null, + source: "static" as const, }; }); } diff --git a/packages/core/src/fleet-manager/types.ts b/packages/core/src/fleet-manager/types.ts index bceff8aa..251194f9 100644 --- a/packages/core/src/fleet-manager/types.ts +++ b/packages/core/src/fleet-manager/types.ts @@ -229,6 +229,11 @@ export interface ScheduleInfo { * Last error message if the schedule encountered an error */ lastError: string | null; + + /** + * Source of this schedule: "static" (from agent config) or "dynamic" (agent-created at runtime) + */ + source?: "static" | "dynamic"; } /** diff --git a/packages/core/src/mcp/scheduler-mcp.ts b/packages/core/src/mcp/scheduler-mcp.ts new file mode 100644 index 00000000..7be1ac86 --- /dev/null +++ b/packages/core/src/mcp/scheduler-mcp.ts @@ -0,0 +1,316 @@ +#!/usr/bin/env node +/** + * herdctl-scheduler MCP server + * + * Standalone stdio MCP server that allows agents to create, list, update, + * and delete their own dynamic schedules at runtime. + * + * Scoped to a single agent via HERDCTL_AGENT_NAME env var. + * All operations are namespace-isolated — an agent can only manage its own schedules. + * + * Environment variables (set by auto-injection in config loader): + * HERDCTL_AGENT_NAME — agent this server is scoped to + * HERDCTL_STATE_DIR — path to .herdctl/ directory + * HERDCTL_MAX_SCHEDULES — max dynamic schedules allowed (default: 10) + * HERDCTL_MIN_INTERVAL — minimum interval between triggers (default: "5m") + * HERDCTL_STATIC_SCHEDULES — comma-separated list of static schedule names + * + * Usage: + * node dist/mcp/scheduler-mcp.js + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { + createDynamicSchedule, + DynamicScheduleError, + deleteDynamicSchedule, + listDynamicSchedules, + MinIntervalViolationError, + ScheduleLimitExceededError, + ScheduleNameConflictError, + updateDynamicSchedule, +} from "../scheduler/dynamic-schedules.js"; + +// ============================================================================= +// Configuration from environment +// ============================================================================= + +const AGENT_NAME = process.env.HERDCTL_AGENT_NAME; +const STATE_DIR = process.env.HERDCTL_STATE_DIR; +const MAX_SCHEDULES = parseInt(process.env.HERDCTL_MAX_SCHEDULES ?? "10", 10); +const MIN_INTERVAL = process.env.HERDCTL_MIN_INTERVAL ?? "5m"; +const STATIC_SCHEDULES = (process.env.HERDCTL_STATIC_SCHEDULES ?? "").split(",").filter(Boolean); + +if (!AGENT_NAME) { + console.error("HERDCTL_AGENT_NAME environment variable is required"); + process.exit(1); +} + +if (!STATE_DIR) { + console.error("HERDCTL_STATE_DIR environment variable is required"); + process.exit(1); +} + +const scheduleOptions = { + maxSchedules: MAX_SCHEDULES, + minInterval: MIN_INTERVAL, + staticScheduleNames: STATIC_SCHEDULES, +}; + +// ============================================================================= +// MCP Server +// ============================================================================= + +const server = new McpServer( + { + name: "herdctl-scheduler", + version: "1.0.0", + }, + { + capabilities: { + tools: {}, + }, + }, +); + +// --- herdctl_create_schedule --- + +server.tool( + "herdctl_create_schedule", + "Create a new dynamic schedule for this agent. The schedule will persist across restarts and be evaluated by the herdctl scheduler.", + { + name: z + .string() + .describe("Schedule name (alphanumeric, hyphens, underscores). Must be unique."), + type: z + .enum(["cron", "interval"]) + .describe("Schedule type: 'cron' for cron expressions, 'interval' for fixed intervals"), + cron: z + .string() + .optional() + .describe("Cron expression (required when type is 'cron'). Example: '0 8,12,16,20 * * *'"), + interval: z + .string() + .optional() + .describe( + "Interval duration (required when type is 'interval'). Format: {number}{unit} where unit is s/m/h/d. Example: '30m', '6h'", + ), + prompt: z + .string() + .describe( + "The prompt to execute when this schedule triggers. This is what the agent will be asked to do.", + ), + ttl_hours: z + .number() + .optional() + .describe( + "Optional time-to-live in hours. The schedule auto-expires after this duration. Example: 168 for 7 days.", + ), + enabled: z.boolean().optional().describe("Whether the schedule is enabled (default: true)"), + }, + async (args) => { + try { + const schedule = await createDynamicSchedule( + STATE_DIR, + AGENT_NAME, + { + name: args.name, + type: args.type, + cron: args.cron, + interval: args.interval, + prompt: args.prompt, + ttl_hours: args.ttl_hours, + enabled: args.enabled, + }, + scheduleOptions, + ); + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + success: true, + message: `Schedule "${args.name}" created successfully`, + schedule: { name: args.name, ...schedule }, + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return formatError(error); + } + }, +); + +// --- herdctl_list_schedules --- + +server.tool( + "herdctl_list_schedules", + "List all dynamic schedules for this agent.", + {}, + async () => { + try { + const schedules = await listDynamicSchedules(STATE_DIR, AGENT_NAME); + const count = Object.keys(schedules).length; + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + agent: AGENT_NAME, + count, + max_schedules: MAX_SCHEDULES, + schedules, + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return formatError(error); + } + }, +); + +// --- herdctl_update_schedule --- + +server.tool( + "herdctl_update_schedule", + "Update an existing dynamic schedule. Only specified fields are changed.", + { + name: z.string().describe("Name of the schedule to update"), + cron: z.string().optional().describe("New cron expression"), + interval: z.string().optional().describe("New interval duration"), + prompt: z.string().optional().describe("New prompt text"), + ttl_hours: z + .number() + .nullable() + .optional() + .describe("New TTL in hours. Set to null to remove TTL."), + enabled: z.boolean().optional().describe("Enable or disable the schedule"), + }, + async (args) => { + try { + const { name, ...updates } = args; + const schedule = await updateDynamicSchedule( + STATE_DIR, + AGENT_NAME, + name, + updates, + scheduleOptions, + ); + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + success: true, + message: `Schedule "${name}" updated successfully`, + schedule: { name, ...schedule }, + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return formatError(error); + } + }, +); + +// --- herdctl_delete_schedule --- + +server.tool( + "herdctl_delete_schedule", + "Delete a dynamic schedule.", + { + name: z.string().describe("Name of the schedule to delete"), + }, + async (args) => { + try { + await deleteDynamicSchedule(STATE_DIR, AGENT_NAME, args.name); + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + success: true, + message: `Schedule "${args.name}" deleted successfully`, + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return formatError(error); + } + }, +); + +// ============================================================================= +// Error Formatting +// ============================================================================= + +function formatError(error: unknown) { + let message: string; + let errorType = "unknown_error"; + + if (error instanceof ScheduleLimitExceededError) { + errorType = "limit_exceeded"; + message = error.message; + } else if (error instanceof MinIntervalViolationError) { + errorType = "min_interval_violation"; + message = error.message; + } else if (error instanceof ScheduleNameConflictError) { + errorType = "name_conflict"; + message = error.message; + } else if (error instanceof DynamicScheduleError) { + errorType = "validation_error"; + message = error.message; + } else { + message = error instanceof Error ? error.message : String(error); + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ success: false, error: errorType, message }, null, 2), + }, + ], + isError: true, + }; +} + +// ============================================================================= +// Main +// ============================================================================= + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +main().catch((error) => { + console.error("Failed to start herdctl-scheduler MCP server:", error); + process.exit(1); +}); diff --git a/packages/core/src/scheduler/__tests__/dynamic-schedules.test.ts b/packages/core/src/scheduler/__tests__/dynamic-schedules.test.ts new file mode 100644 index 00000000..1de8078d --- /dev/null +++ b/packages/core/src/scheduler/__tests__/dynamic-schedules.test.ts @@ -0,0 +1,662 @@ +import { mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { stringify as stringifyYaml } from "yaml"; +import { + createDynamicSchedule, + type DynamicScheduleFile, + type DynamicScheduleOptions, + deleteDynamicSchedule, + getDynamicScheduleFilePath, + getDynamicSchedulesDir, + listDynamicSchedules, + loadAllDynamicSchedules, + MinIntervalViolationError, + readDynamicSchedules, + ScheduleLimitExceededError, + ScheduleNameConflictError, + updateDynamicSchedule, +} from "../dynamic-schedules.js"; + +async function createTempDir(): Promise { + const baseDir = join( + tmpdir(), + `herdctl-dynamic-schedules-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await mkdir(baseDir, { recursive: true }); + return await realpath(baseDir); +} + +const defaultOptions: DynamicScheduleOptions = { + maxSchedules: 10, + minInterval: "5m", + staticScheduleNames: [], +}; + +describe("dynamic-schedules", () => { + let stateDir: string; + + beforeEach(async () => { + stateDir = await createTempDir(); + }); + + afterEach(async () => { + await rm(stateDir, { recursive: true, force: true }); + }); + + // =========================================================================== + // Path Helpers + // =========================================================================== + + describe("getDynamicSchedulesDir", () => { + it("returns dynamic-schedules subdir of stateDir", () => { + expect(getDynamicSchedulesDir("/foo/.herdctl")).toBe("/foo/.herdctl/dynamic-schedules"); + }); + }); + + describe("getDynamicScheduleFilePath", () => { + it("returns yaml file for valid agent name", () => { + expect(getDynamicScheduleFilePath("/foo/.herdctl", "vector")).toBe( + "/foo/.herdctl/dynamic-schedules/vector.yaml", + ); + }); + + it("rejects agent names with path traversal", () => { + expect(() => getDynamicScheduleFilePath("/foo/.herdctl", "../evil")).toThrow( + "Invalid agent name", + ); + }); + + it("rejects agent names with dots", () => { + expect(() => getDynamicScheduleFilePath("/foo/.herdctl", "agent.name")).toThrow( + "Invalid agent name", + ); + }); + }); + + // =========================================================================== + // Read / Write + // =========================================================================== + + describe("readDynamicSchedules", () => { + it("returns empty schedules when file does not exist", async () => { + const result = await readDynamicSchedules(stateDir, "vector"); + expect(result).toEqual({ version: 1, schedules: {} }); + }); + + it("reads existing schedule file", async () => { + const dir = getDynamicSchedulesDir(stateDir); + await mkdir(dir, { recursive: true }); + const data: DynamicScheduleFile = { + version: 1, + schedules: { + "test-schedule": { + type: "cron", + cron: "0 9 * * *", + prompt: "Do something", + enabled: true, + created_at: "2026-03-01T00:00:00.000Z", + }, + }, + }; + await writeFile(join(dir, "vector.yaml"), stringifyYaml(data)); + + const result = await readDynamicSchedules(stateDir, "vector"); + expect(result.schedules["test-schedule"].cron).toBe("0 9 * * *"); + }); + + it("performs lazy TTL expiration on read", async () => { + const dir = getDynamicSchedulesDir(stateDir); + await mkdir(dir, { recursive: true }); + const pastDate = new Date(Date.now() - 3600000).toISOString(); + const futureDate = new Date(Date.now() + 3600000).toISOString(); + const data: DynamicScheduleFile = { + version: 1, + schedules: { + expired: { + type: "interval", + interval: "1h", + prompt: "Expired task", + enabled: true, + created_at: "2026-01-01T00:00:00.000Z", + ttl_hours: 1, + expires_at: pastDate, + }, + active: { + type: "interval", + interval: "1h", + prompt: "Active task", + enabled: true, + created_at: "2026-03-01T00:00:00.000Z", + ttl_hours: 168, + expires_at: futureDate, + }, + }, + }; + await writeFile(join(dir, "vector.yaml"), stringifyYaml(data)); + + const result = await readDynamicSchedules(stateDir, "vector"); + expect(Object.keys(result.schedules)).toEqual(["active"]); + + // Verify the file was cleaned up on disk + const ondisk = stringifyYaml( + JSON.parse(JSON.stringify(await readDynamicSchedules(stateDir, "vector"))), + ); + expect(ondisk).not.toContain("expired"); + }); + }); + + // =========================================================================== + // CRUD: Create + // =========================================================================== + + describe("createDynamicSchedule", () => { + it("creates a cron schedule", async () => { + const schedule = await createDynamicSchedule( + stateDir, + "vector", + { + name: "daily-check", + type: "cron", + cron: "0 9 * * *", + prompt: "Check for updates", + }, + defaultOptions, + ); + + expect(schedule.type).toBe("cron"); + expect(schedule.cron).toBe("0 9 * * *"); + expect(schedule.enabled).toBe(true); + expect(schedule.created_at).toBeDefined(); + }); + + it("creates an interval schedule", async () => { + const schedule = await createDynamicSchedule( + stateDir, + "vector", + { + name: "hourly-check", + type: "interval", + interval: "1h", + prompt: "Check hourly", + }, + defaultOptions, + ); + + expect(schedule.type).toBe("interval"); + expect(schedule.interval).toBe("1h"); + }); + + it("creates a schedule with TTL", async () => { + const schedule = await createDynamicSchedule( + stateDir, + "vector", + { + name: "temp-check", + type: "interval", + interval: "30m", + prompt: "Temporary check", + ttl_hours: 24, + }, + defaultOptions, + ); + + expect(schedule.ttl_hours).toBe(24); + expect(schedule.expires_at).toBeDefined(); + const expiresAt = new Date(schedule.expires_at!); + const createdAt = new Date(schedule.created_at); + const diffHours = (expiresAt.getTime() - createdAt.getTime()) / 3600000; + expect(Math.abs(diffHours - 24)).toBeLessThan(1); + }); + + it("persists to disk", async () => { + await createDynamicSchedule( + stateDir, + "vector", + { + name: "persisted", + type: "interval", + interval: "1h", + prompt: "Test persistence", + }, + defaultOptions, + ); + + const filePath = getDynamicScheduleFilePath(stateDir, "vector"); + const content = await readFile(filePath, "utf-8"); + expect(content).toContain("persisted"); + }); + + it("rejects invalid schedule names", async () => { + await expect( + createDynamicSchedule( + stateDir, + "vector", + { + name: "../evil", + type: "interval", + interval: "1h", + prompt: "Bad name", + }, + defaultOptions, + ), + ).rejects.toThrow("Invalid schedule name"); + }); + + it("rejects invalid cron expressions", async () => { + await expect( + createDynamicSchedule( + stateDir, + "vector", + { + name: "bad-cron", + type: "cron", + cron: "not a cron", + prompt: "Bad cron", + }, + defaultOptions, + ), + ).rejects.toThrow("Invalid cron expression"); + }); + + it("rejects cron schedule missing cron field", async () => { + await expect( + createDynamicSchedule( + stateDir, + "vector", + { + name: "missing-cron", + type: "cron", + prompt: "No cron", + }, + defaultOptions, + ), + ).rejects.toThrow("requires a cron expression"); + }); + + it("rejects interval schedule missing interval field", async () => { + await expect( + createDynamicSchedule( + stateDir, + "vector", + { + name: "missing-interval", + type: "interval", + prompt: "No interval", + }, + defaultOptions, + ), + ).rejects.toThrow("requires an interval value"); + }); + + it("rejects duplicate schedule names", async () => { + await createDynamicSchedule( + stateDir, + "vector", + { + name: "unique", + type: "interval", + interval: "1h", + prompt: "First", + }, + defaultOptions, + ); + + await expect( + createDynamicSchedule( + stateDir, + "vector", + { + name: "unique", + type: "interval", + interval: "2h", + prompt: "Duplicate", + }, + defaultOptions, + ), + ).rejects.toThrow(ScheduleNameConflictError); + }); + + it("rejects when static schedule name collision exists", async () => { + const options = { ...defaultOptions, staticScheduleNames: ["daily-report"] }; + + await expect( + createDynamicSchedule( + stateDir, + "vector", + { + name: "daily-report", + type: "interval", + interval: "1h", + prompt: "Collision", + }, + options, + ), + ).rejects.toThrow(ScheduleNameConflictError); + }); + + it("enforces max schedule count", async () => { + const options = { ...defaultOptions, maxSchedules: 2 }; + + await createDynamicSchedule( + stateDir, + "vector", + { + name: "one", + type: "interval", + interval: "1h", + prompt: "First", + }, + options, + ); + + await createDynamicSchedule( + stateDir, + "vector", + { + name: "two", + type: "interval", + interval: "2h", + prompt: "Second", + }, + options, + ); + + await expect( + createDynamicSchedule( + stateDir, + "vector", + { + name: "three", + type: "interval", + interval: "3h", + prompt: "Third", + }, + options, + ), + ).rejects.toThrow(ScheduleLimitExceededError); + }); + + it("enforces minimum interval", async () => { + const options = { ...defaultOptions, minInterval: "10m" }; + + await expect( + createDynamicSchedule( + stateDir, + "vector", + { + name: "too-fast", + type: "interval", + interval: "1m", + prompt: "Too fast", + }, + options, + ), + ).rejects.toThrow(MinIntervalViolationError); + }); + + it("enforces minimum interval for cron schedules", async () => { + const options = { ...defaultOptions, minInterval: "10m" }; + + await expect( + createDynamicSchedule( + stateDir, + "vector", + { + name: "too-fast-cron", + type: "cron", + cron: "* * * * *", // every minute + prompt: "Too fast cron", + }, + options, + ), + ).rejects.toThrow(MinIntervalViolationError); + }); + }); + + // =========================================================================== + // CRUD: Update + // =========================================================================== + + describe("updateDynamicSchedule", () => { + it("updates prompt", async () => { + await createDynamicSchedule( + stateDir, + "vector", + { + name: "updatable", + type: "interval", + interval: "1h", + prompt: "Original prompt", + }, + defaultOptions, + ); + + const updated = await updateDynamicSchedule( + stateDir, + "vector", + "updatable", + { + prompt: "New prompt", + }, + defaultOptions, + ); + + expect(updated.prompt).toBe("New prompt"); + expect(updated.interval).toBe("1h"); + }); + + it("updates enabled status", async () => { + await createDynamicSchedule( + stateDir, + "vector", + { + name: "toggleable", + type: "interval", + interval: "1h", + prompt: "Toggle test", + }, + defaultOptions, + ); + + const updated = await updateDynamicSchedule( + stateDir, + "vector", + "toggleable", + { + enabled: false, + }, + defaultOptions, + ); + + expect(updated.enabled).toBe(false); + }); + + it("removes TTL when set to null", async () => { + await createDynamicSchedule( + stateDir, + "vector", + { + name: "ttl-remove", + type: "interval", + interval: "1h", + prompt: "TTL test", + ttl_hours: 24, + }, + defaultOptions, + ); + + const updated = await updateDynamicSchedule( + stateDir, + "vector", + "ttl-remove", + { + ttl_hours: null, + }, + defaultOptions, + ); + + expect(updated.ttl_hours).toBeUndefined(); + expect(updated.expires_at).toBeNull(); + }); + + it("throws for nonexistent schedule", async () => { + await expect( + updateDynamicSchedule( + stateDir, + "vector", + "nonexistent", + { + prompt: "Test", + }, + defaultOptions, + ), + ).rejects.toThrow('Schedule "nonexistent" not found'); + }); + }); + + // =========================================================================== + // CRUD: Delete + // =========================================================================== + + describe("deleteDynamicSchedule", () => { + it("deletes a schedule", async () => { + await createDynamicSchedule( + stateDir, + "vector", + { + name: "deletable", + type: "interval", + interval: "1h", + prompt: "Delete me", + }, + defaultOptions, + ); + + await deleteDynamicSchedule(stateDir, "vector", "deletable"); + + const schedules = await listDynamicSchedules(stateDir, "vector"); + expect(Object.keys(schedules)).toHaveLength(0); + }); + + it("removes file when last schedule is deleted", async () => { + await createDynamicSchedule( + stateDir, + "vector", + { + name: "last-one", + type: "interval", + interval: "1h", + prompt: "Last", + }, + defaultOptions, + ); + + await deleteDynamicSchedule(stateDir, "vector", "last-one"); + + // File should be gone + const result = await readDynamicSchedules(stateDir, "vector"); + expect(result.schedules).toEqual({}); + }); + + it("throws for nonexistent schedule", async () => { + await expect(deleteDynamicSchedule(stateDir, "vector", "nonexistent")).rejects.toThrow( + 'Schedule "nonexistent" not found', + ); + }); + }); + + // =========================================================================== + // CRUD: List + // =========================================================================== + + describe("listDynamicSchedules", () => { + it("returns empty map when no schedules exist", async () => { + const result = await listDynamicSchedules(stateDir, "vector"); + expect(result).toEqual({}); + }); + + it("returns all schedules", async () => { + await createDynamicSchedule( + stateDir, + "vector", + { + name: "one", + type: "interval", + interval: "1h", + prompt: "First", + }, + defaultOptions, + ); + + await createDynamicSchedule( + stateDir, + "vector", + { + name: "two", + type: "cron", + cron: "0 9 * * *", + prompt: "Second", + }, + defaultOptions, + ); + + const result = await listDynamicSchedules(stateDir, "vector"); + expect(Object.keys(result)).toEqual(["one", "two"]); + }); + }); + + // =========================================================================== + // Loader (loadAllDynamicSchedules) + // =========================================================================== + + describe("loadAllDynamicSchedules", () => { + it("returns empty map when no dynamic-schedules dir exists", async () => { + const result = await loadAllDynamicSchedules(stateDir); + expect(result.size).toBe(0); + }); + + it("loads schedules from multiple agents", async () => { + await createDynamicSchedule( + stateDir, + "vector", + { + name: "v-schedule", + type: "interval", + interval: "1h", + prompt: "Vector", + }, + defaultOptions, + ); + + await createDynamicSchedule( + stateDir, + "briefing", + { + name: "b-schedule", + type: "cron", + cron: "0 6 * * *", + prompt: "Briefing", + }, + defaultOptions, + ); + + const result = await loadAllDynamicSchedules(stateDir); + expect(result.size).toBe(2); + expect(result.has("vector")).toBe(true); + expect(result.has("briefing")).toBe(true); + expect(result.get("vector")!["v-schedule"].type).toBe("interval"); + expect(result.get("briefing")!["b-schedule"].type).toBe("cron"); + }); + + it("skips non-yaml files", async () => { + const dir = getDynamicSchedulesDir(stateDir); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "readme.txt"), "not a schedule file"); + + const result = await loadAllDynamicSchedules(stateDir); + expect(result.size).toBe(0); + }); + }); +}); diff --git a/packages/core/src/scheduler/dynamic-schedules.ts b/packages/core/src/scheduler/dynamic-schedules.ts new file mode 100644 index 00000000..4b1d559f --- /dev/null +++ b/packages/core/src/scheduler/dynamic-schedules.ts @@ -0,0 +1,486 @@ +/** + * Dynamic schedule store for agent self-scheduling + * + * Manages per-agent YAML files in .herdctl/dynamic-schedules/ that hold + * schedules created by agents at runtime via the scheduler MCP server. + * + * Design: + * - One file per agent: eliminates cross-agent write contention + * - Atomic writes: uses the same atomicWriteYaml pattern as state.yaml + * - TTL expiration: lazy cleanup on read (no background timer needed) + * - Namespace isolation: agent name validated against AGENT_NAME_PATTERN + */ + +import { mkdir, readFile, unlink } from "node:fs/promises"; +import { join } from "node:path"; +import { parse as parseYaml } from "yaml"; +import { z } from "zod"; +import { AGENT_NAME_PATTERN } from "../config/schema.js"; +import { atomicWriteYaml } from "../state/utils/atomic.js"; +import { calculateNextCronTrigger, isValidCronExpression } from "./cron.js"; +import { parseInterval } from "./interval.js"; + +// ============================================================================= +// Schema +// ============================================================================= + +/** + * Schema for a single dynamic schedule entry + */ +export const DynamicScheduleSchema = z.object({ + type: z.enum(["cron", "interval"]), + cron: z.string().optional(), + interval: z.string().optional(), + prompt: z.string().max(10000), + enabled: z.boolean().default(true), + created_at: z.string(), + ttl_hours: z.number().positive().optional(), + expires_at: z.string().nullable().optional(), +}); + +export type DynamicSchedule = z.infer; + +/** + * Schema for the per-agent dynamic schedules YAML file + */ +export const DynamicScheduleFileSchema = z.object({ + version: z.number().int().positive().default(1), + schedules: z.record(z.string(), DynamicScheduleSchema).default({}), +}); + +export type DynamicScheduleFile = z.infer; + +/** + * Schedule name pattern — same rules as agent names + */ +const SCHEDULE_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/; + +// ============================================================================= +// Error Classes +// ============================================================================= + +export class DynamicScheduleError extends Error { + constructor(message: string) { + super(message); + this.name = "DynamicScheduleError"; + } +} + +export class ScheduleLimitExceededError extends DynamicScheduleError { + constructor(agentName: string, max: number) { + super(`Agent "${agentName}" has reached the maximum of ${max} dynamic schedules`); + this.name = "ScheduleLimitExceededError"; + } +} + +export class MinIntervalViolationError extends DynamicScheduleError { + constructor(scheduleName: string, actual: string, minimum: string) { + super(`Schedule "${scheduleName}" interval "${actual}" is below the minimum "${minimum}"`); + this.name = "MinIntervalViolationError"; + } +} + +export class ScheduleNameConflictError extends DynamicScheduleError { + constructor(scheduleName: string, reason: string) { + super(`Cannot create schedule "${scheduleName}": ${reason}`); + this.name = "ScheduleNameConflictError"; + } +} + +// ============================================================================= +// Path Helpers +// ============================================================================= + +/** + * Get the directory for dynamic schedule files + */ +export function getDynamicSchedulesDir(stateDir: string): string { + return join(stateDir, "dynamic-schedules"); +} + +/** + * Get the file path for a specific agent's dynamic schedules + */ +export function getDynamicScheduleFilePath(stateDir: string, agentName: string): string { + // Validate agent name to prevent path traversal + if (!AGENT_NAME_PATTERN.test(agentName)) { + throw new DynamicScheduleError( + `Invalid agent name "${agentName}": must match ${AGENT_NAME_PATTERN.source}`, + ); + } + return join(getDynamicSchedulesDir(stateDir), `${agentName}.yaml`); +} + +// ============================================================================= +// Read / Write +// ============================================================================= + +/** + * Read dynamic schedules for an agent, performing lazy TTL expiration. + * + * Returns an empty schedule map if the file doesn't exist. + * Expired schedules are removed from the file on read. + */ +export async function readDynamicSchedules( + stateDir: string, + agentName: string, +): Promise { + const filePath = getDynamicScheduleFilePath(stateDir, agentName); + + let content: string; + try { + content = await readFile(filePath, "utf-8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { version: 1, schedules: {} }; + } + throw error; + } + + const raw = parseYaml(content); + if (!raw || typeof raw !== "object") { + return { version: 1, schedules: {} }; + } + + const parsed = DynamicScheduleFileSchema.parse(raw); + + // Lazy TTL expiration + const now = new Date(); + let expired = false; + const activeSchedules: Record = {}; + + for (const [name, schedule] of Object.entries(parsed.schedules)) { + if (schedule.expires_at && new Date(schedule.expires_at) <= now) { + expired = true; + continue; // Skip expired schedule + } + activeSchedules[name] = schedule; + } + + // Write back if any schedules were expired + if (expired) { + const cleaned: DynamicScheduleFile = { version: 1, schedules: activeSchedules }; + await writeDynamicSchedules(stateDir, agentName, cleaned); + return cleaned; + } + + return parsed; +} + +/** + * Write dynamic schedules for an agent atomically + */ +async function writeDynamicSchedules( + stateDir: string, + agentName: string, + data: DynamicScheduleFile, +): Promise { + const dir = getDynamicSchedulesDir(stateDir); + await mkdir(dir, { recursive: true }); + const filePath = getDynamicScheduleFilePath(stateDir, agentName); + await atomicWriteYaml(filePath, data); +} + +// ============================================================================= +// CRUD Operations +// ============================================================================= + +export interface CreateScheduleInput { + name: string; + type: "cron" | "interval"; + cron?: string; + interval?: string; + prompt: string; + ttl_hours?: number; + enabled?: boolean; +} + +export interface UpdateScheduleInput { + cron?: string; + interval?: string; + prompt?: string; + ttl_hours?: number | null; + enabled?: boolean; +} + +export interface DynamicScheduleOptions { + maxSchedules: number; + minInterval: string; + /** Static schedule names for this agent — used to prevent name collisions */ + staticScheduleNames?: string[]; +} + +/** + * Create a new dynamic schedule for an agent + */ +export async function createDynamicSchedule( + stateDir: string, + agentName: string, + input: CreateScheduleInput, + options: DynamicScheduleOptions, +): Promise { + // Validate schedule name + if (!SCHEDULE_NAME_PATTERN.test(input.name)) { + throw new DynamicScheduleError( + `Invalid schedule name "${input.name}": must match ${SCHEDULE_NAME_PATTERN.source}`, + ); + } + + // Validate schedule type matches provided fields + if (input.type === "cron") { + if (!input.cron) { + throw new DynamicScheduleError("Cron schedule requires a cron expression"); + } + if (!isValidCronExpression(input.cron)) { + throw new DynamicScheduleError(`Invalid cron expression: "${input.cron}"`); + } + } else if (input.type === "interval") { + if (!input.interval) { + throw new DynamicScheduleError("Interval schedule requires an interval value"); + } + // parseInterval throws on invalid format + parseInterval(input.interval); + } + + // Validate minimum interval + validateMinInterval(input, options.minInterval); + + // Check for static schedule name collision + if (options.staticScheduleNames?.includes(input.name)) { + throw new ScheduleNameConflictError( + input.name, + "a static schedule with this name already exists", + ); + } + + // Read current schedules + const file = await readDynamicSchedules(stateDir, agentName); + + // Check for duplicate name + if (input.name in file.schedules) { + throw new ScheduleNameConflictError( + input.name, + "a dynamic schedule with this name already exists", + ); + } + + // Check schedule count limit + if (Object.keys(file.schedules).length >= options.maxSchedules) { + throw new ScheduleLimitExceededError(agentName, options.maxSchedules); + } + + // Build the schedule + const now = new Date().toISOString(); + const schedule: DynamicSchedule = { + type: input.type, + ...(input.cron && { cron: input.cron }), + ...(input.interval && { interval: input.interval }), + prompt: input.prompt, + enabled: input.enabled ?? true, + created_at: now, + ...(input.ttl_hours != null && { + ttl_hours: input.ttl_hours, + expires_at: new Date(Date.now() + input.ttl_hours * 3600000).toISOString(), + }), + }; + + file.schedules[input.name] = schedule; + await writeDynamicSchedules(stateDir, agentName, file); + + return schedule; +} + +/** + * Update an existing dynamic schedule + */ +export async function updateDynamicSchedule( + stateDir: string, + agentName: string, + scheduleName: string, + updates: UpdateScheduleInput, + options: DynamicScheduleOptions, +): Promise { + const file = await readDynamicSchedules(stateDir, agentName); + + if (!(scheduleName in file.schedules)) { + throw new DynamicScheduleError(`Schedule "${scheduleName}" not found`); + } + + const existing = file.schedules[scheduleName]; + + // If updating cron, validate it + if (updates.cron !== undefined) { + if (!isValidCronExpression(updates.cron)) { + throw new DynamicScheduleError(`Invalid cron expression: "${updates.cron}"`); + } + } + + // If updating interval, validate it + if (updates.interval !== undefined) { + parseInterval(updates.interval); + } + + // Validate minimum interval with the updated values + const effectiveSchedule = { + type: existing.type, + cron: updates.cron ?? existing.cron, + interval: updates.interval ?? existing.interval, + }; + validateMinInterval(effectiveSchedule, options.minInterval); + + // Apply updates + const updated: DynamicSchedule = { ...existing }; + if (updates.cron !== undefined) updated.cron = updates.cron; + if (updates.interval !== undefined) updated.interval = updates.interval; + if (updates.prompt !== undefined) updated.prompt = updates.prompt; + if (updates.enabled !== undefined) updated.enabled = updates.enabled; + + // Handle TTL updates + if (updates.ttl_hours !== undefined) { + if (updates.ttl_hours === null) { + // Remove TTL + updated.ttl_hours = undefined; + updated.expires_at = null; + } else { + updated.ttl_hours = updates.ttl_hours; + updated.expires_at = new Date( + new Date(updated.created_at).getTime() + updates.ttl_hours * 3600000, + ).toISOString(); + } + } + + file.schedules[scheduleName] = updated; + await writeDynamicSchedules(stateDir, agentName, file); + + return updated; +} + +/** + * Delete a dynamic schedule + */ +export async function deleteDynamicSchedule( + stateDir: string, + agentName: string, + scheduleName: string, +): Promise { + const file = await readDynamicSchedules(stateDir, agentName); + + if (!(scheduleName in file.schedules)) { + throw new DynamicScheduleError(`Schedule "${scheduleName}" not found`); + } + + delete file.schedules[scheduleName]; + + // If no schedules remain, delete the file + if (Object.keys(file.schedules).length === 0) { + const filePath = getDynamicScheduleFilePath(stateDir, agentName); + try { + await unlink(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + return; + } + + await writeDynamicSchedules(stateDir, agentName, file); +} + +/** + * List all dynamic schedules for an agent + */ +export async function listDynamicSchedules( + stateDir: string, + agentName: string, +): Promise> { + const file = await readDynamicSchedules(stateDir, agentName); + return file.schedules; +} + +// ============================================================================= +// Loader for Scheduler Integration +// ============================================================================= + +/** + * Load all dynamic schedules across all agents. + * + * Returns a map of agentName -> { scheduleName -> schedule }. + * Used by the scheduler to merge dynamic schedules with static ones. + */ +export async function loadAllDynamicSchedules( + stateDir: string, +): Promise>> { + const { readdir } = await import("node:fs/promises"); + const dir = getDynamicSchedulesDir(stateDir); + const result = new Map>(); + + let files: string[]; + try { + files = await readdir(dir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return result; + } + throw error; + } + + for (const file of files) { + if (!file.endsWith(".yaml")) continue; + const agentName = file.replace(/\.yaml$/, ""); + + try { + const schedules = await listDynamicSchedules(stateDir, agentName); + if (Object.keys(schedules).length > 0) { + result.set(agentName, schedules); + } + } catch { + // Skip files that fail to parse — don't crash the scheduler + } + } + + return result; +} + +// ============================================================================= +// Validation Helpers +// ============================================================================= + +/** + * Validate that a schedule respects the minimum interval constraint. + * + * For interval schedules, the interval must be >= min_interval. + * For cron schedules, we estimate the gap between consecutive triggers + * and reject if it's below the minimum. + */ +function validateMinInterval( + schedule: { type: string; cron?: string; interval?: string }, + minInterval: string, +): void { + const minMs = parseInterval(minInterval); + + if (schedule.type === "interval" && schedule.interval) { + const scheduleMs = parseInterval(schedule.interval); + if (scheduleMs < minMs) { + throw new MinIntervalViolationError(schedule.interval, schedule.interval, minInterval); + } + } + + if (schedule.type === "cron" && schedule.cron) { + // Estimate the gap between consecutive cron triggers + try { + const now = new Date(); + const first = calculateNextCronTrigger(schedule.cron, now); + const second = calculateNextCronTrigger(schedule.cron, first); + const gapMs = second.getTime() - first.getTime(); + if (gapMs < minMs) { + throw new MinIntervalViolationError( + schedule.cron, + `~${Math.round(gapMs / 1000)}s`, + minInterval, + ); + } + } catch (error) { + // Re-throw our own errors, ignore cron parsing failures + if (error instanceof MinIntervalViolationError) throw error; + } + } +} diff --git a/packages/core/src/scheduler/index.ts b/packages/core/src/scheduler/index.ts index de018370..56d94bb3 100644 --- a/packages/core/src/scheduler/index.ts +++ b/packages/core/src/scheduler/index.ts @@ -14,6 +14,26 @@ export { type ParsedCronExpression, parseCronExpression, } from "./cron.js"; +// Dynamic schedules (agent self-scheduling) +export { + type CreateScheduleInput, + createDynamicSchedule, + type DynamicSchedule, + DynamicScheduleError, + type DynamicScheduleFile, + type DynamicScheduleOptions, + deleteDynamicSchedule, + getDynamicScheduleFilePath, + getDynamicSchedulesDir, + listDynamicSchedules, + loadAllDynamicSchedules, + MinIntervalViolationError, + readDynamicSchedules, + ScheduleLimitExceededError, + ScheduleNameConflictError, + type UpdateScheduleInput, + updateDynamicSchedule, +} from "./dynamic-schedules.js"; // Errors export * from "./errors.js"; // Interval parsing and scheduling diff --git a/packages/core/src/scheduler/scheduler.ts b/packages/core/src/scheduler/scheduler.ts index e69b46f8..f151350b 100644 --- a/packages/core/src/scheduler/scheduler.ts +++ b/packages/core/src/scheduler/scheduler.ts @@ -12,6 +12,7 @@ import { calculatePreviousCronTrigger, isValidCronExpression, } from "./cron.js"; +import { loadAllDynamicSchedules } from "./dynamic-schedules.js"; import { SchedulerShutdownError } from "./errors.js"; import { calculateNextTrigger, isScheduleDue } from "./interval.js"; import { @@ -106,6 +107,17 @@ export class Scheduler { private lastCheckAt: string | null = null; private warnedSchedules = new Set(); + // Dynamic schedule cache — re-read from disk every N ticks to avoid excessive I/O + private dynamicScheduleCache: Map< + string, + Record< + string, + { type: string; interval?: string; cron?: string; prompt?: string; enabled?: boolean } + > + > = new Map(); + private dynamicScheduleLastLoad = 0; + private static readonly DYNAMIC_SCHEDULE_RELOAD_INTERVAL = 10; // reload every 10 ticks (~10s) + constructor(options: SchedulerOptions) { this.checkInterval = options.checkInterval ?? DEFAULT_CHECK_INTERVAL; this.stateDir = options.stateDir; @@ -297,18 +309,48 @@ export class Scheduler { } /** - * Check all agents' schedules and trigger due ones + * Check all agents' schedules and trigger due ones. + * Merges static schedules from config with dynamic schedules from disk. + * Static schedules take precedence on name collision (operator has final say). */ private async checkAllSchedules(): Promise { this.checkCount++; this.lastCheckAt = new Date(Date.now()).toISOString(); + // Periodically reload dynamic schedules from disk + if ( + this.checkCount - this.dynamicScheduleLastLoad >= + Scheduler.DYNAMIC_SCHEDULE_RELOAD_INTERVAL + ) { + try { + const loaded = await loadAllDynamicSchedules(this.stateDir); + this.dynamicScheduleCache = loaded as Map< + string, + Record< + string, + { type: string; interval?: string; cron?: string; prompt?: string; enabled?: boolean } + > + >; + this.dynamicScheduleLastLoad = this.checkCount; + } catch (error) { + this.logger.error( + `Failed to load dynamic schedules: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + for (const agent of this.agents) { - if (!agent.schedules) { + const staticSchedules = agent.schedules ?? {}; + const agentDynamic = this.dynamicScheduleCache.get(agent.qualifiedName) ?? {}; + + // Merge: static takes precedence on name collision + const mergedSchedules = { ...agentDynamic, ...staticSchedules }; + + if (Object.keys(mergedSchedules).length === 0) { continue; } - for (const [scheduleName, schedule] of Object.entries(agent.schedules)) { + for (const [scheduleName, schedule] of Object.entries(mergedSchedules)) { const result = await this.checkSchedule(agent, scheduleName, schedule); if (result.shouldTrigger) { From 3761e589ca8f2cb98d7c528fd4b8a02276726e06 Mon Sep 17 00:00:00 2001 From: agent Date: Tue, 3 Mar 2026 16:40:08 -0800 Subject: [PATCH 02/48] fix(core): address review findings in self-scheduling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix qualified names with dots (e.g., "fleet.agent") rejected by file path validation — allow dots but block ".." path traversal sequences - Fix getSchedule/enableSchedule/disableSchedule not finding dynamic schedules — fall back to dynamic schedule lookup from disk - Fix validateMinInterval passing interval value as schedule name in MinIntervalViolationError - Add prompt max length (10,000 chars) to MCP tool schema to match storage schema validation - Add console.warn for corrupt dynamic schedule files instead of silent catch Co-Authored-By: Claude Opus 4.6 --- .changeset/agent-self-scheduling.md | 14 +++ .../src/fleet-manager/schedule-management.ts | 92 +++++++++++-------- packages/core/src/mcp/scheduler-mcp.ts | 3 +- .../__tests__/dynamic-schedules.test.ts | 12 ++- .../core/src/scheduler/dynamic-schedules.ts | 36 +++++--- 5 files changed, 105 insertions(+), 52 deletions(-) create mode 100644 .changeset/agent-self-scheduling.md diff --git a/.changeset/agent-self-scheduling.md b/.changeset/agent-self-scheduling.md new file mode 100644 index 00000000..6fd9a552 --- /dev/null +++ b/.changeset/agent-self-scheduling.md @@ -0,0 +1,14 @@ +--- +"@herdctl/core": minor +--- + +Add agent self-scheduling via MCP server + +Agents can now create, update, and delete their own schedules at runtime through a capability-gated MCP server. Enable with `self_scheduling.enabled: true` in agent config. + +- Per-agent YAML store at `.herdctl/dynamic-schedules/.yaml` +- Standalone `herdctl-scheduler` stdio MCP server with 4 tools (create, list, update, delete) +- Auto-injected when `self_scheduling.enabled` is true — no manual MCP config needed +- Safety: namespace isolation, configurable max schedules, minimum interval enforcement, optional TTL +- Dynamic schedules merged with static in scheduler (static wins on name collision) +- `source: "static" | "dynamic"` tag on ScheduleInfo for fleet operator visibility diff --git a/packages/core/src/fleet-manager/schedule-management.ts b/packages/core/src/fleet-manager/schedule-management.ts index 030fb7af..567162f7 100644 --- a/packages/core/src/fleet-manager/schedule-management.ts +++ b/packages/core/src/fleet-manager/schedule-management.ts @@ -7,7 +7,7 @@ * @module schedule-management */ -import { loadAllDynamicSchedules } from "../scheduler/dynamic-schedules.js"; +import { listDynamicSchedules, loadAllDynamicSchedules } from "../scheduler/dynamic-schedules.js"; import type { FleetManagerContext } from "./context.js"; import { AgentNotFoundError, ScheduleNotFoundError } from "./errors.js"; import { buildScheduleInfoList, type FleetStateSnapshot } from "./status-queries.js"; @@ -114,29 +114,55 @@ export class ScheduleManagement { }); } - if (!agent.schedules || !(scheduleName in agent.schedules)) { - const availableSchedules = agent.schedules ? Object.keys(agent.schedules) : []; - throw new ScheduleNotFoundError(agentName, scheduleName, { - availableSchedules, - }); - } - const fleetState = await this.readFleetStateSnapshotFn(); const agentState = fleetState.agents[agent.qualifiedName]; - const schedule = agent.schedules[scheduleName]; - const scheduleState = agentState?.schedules?.[scheduleName]; - - return { - name: scheduleName, - agentName, - type: schedule.type, - interval: schedule.interval, - cron: schedule.cron, - status: scheduleState?.status ?? "idle", - lastRunAt: scheduleState?.last_run_at ?? null, - nextRunAt: scheduleState?.next_run_at ?? null, - lastError: scheduleState?.last_error ?? null, - }; + + // Check static schedules first + if (agent.schedules && scheduleName in agent.schedules) { + const schedule = agent.schedules[scheduleName]; + const scheduleState = agentState?.schedules?.[scheduleName]; + return { + name: scheduleName, + agentName: agent.qualifiedName, + type: schedule.type, + interval: schedule.interval, + cron: schedule.cron, + status: scheduleState?.status ?? "idle", + lastRunAt: scheduleState?.last_run_at ?? null, + nextRunAt: scheduleState?.next_run_at ?? null, + lastError: scheduleState?.last_error ?? null, + source: "static", + }; + } + + // Fall back to dynamic schedules + const stateDir = this.ctx.getStateDir(); + try { + const dynamic = await listDynamicSchedules(stateDir, agent.qualifiedName); + if (scheduleName in dynamic) { + const schedule = dynamic[scheduleName]; + const scheduleState = agentState?.schedules?.[scheduleName]; + return { + name: scheduleName, + agentName: agent.qualifiedName, + type: schedule.type, + interval: schedule.interval, + cron: schedule.cron, + status: scheduleState?.status ?? "idle", + lastRunAt: scheduleState?.last_run_at ?? null, + nextRunAt: scheduleState?.next_run_at ?? null, + lastError: scheduleState?.last_error ?? null, + source: "dynamic", + }; + } + } catch { + // Ignore dynamic schedule load errors + } + + const availableSchedules = agent.schedules ? Object.keys(agent.schedules) : []; + throw new ScheduleNotFoundError(agentName, scheduleName, { + availableSchedules, + }); } /** @@ -157,9 +183,8 @@ export class ScheduleManagement { const logger = this.ctx.getLogger(); const stateDir = this.ctx.getStateDir(); - // Validate the agent and schedule exist + // Validate the agent exists const agents = config?.agents ?? []; - // Try qualified name first, fall back to local name const agent = agents.find((a) => a.qualifiedName === agentName) ?? agents.find((a) => a.name === agentName); @@ -169,12 +194,8 @@ export class ScheduleManagement { }); } - if (!agent.schedules || !(scheduleName in agent.schedules)) { - const availableSchedules = agent.schedules ? Object.keys(agent.schedules) : []; - throw new ScheduleNotFoundError(agentName, scheduleName, { - availableSchedules, - }); - } + // Verify the schedule exists (static or dynamic) + await this.getSchedule(agent.qualifiedName, scheduleName); // Update schedule state to enabled (idle) — use qualifiedName as the state key const { updateScheduleState } = await import("../scheduler/schedule-state.js"); @@ -211,9 +232,8 @@ export class ScheduleManagement { const logger = this.ctx.getLogger(); const stateDir = this.ctx.getStateDir(); - // Validate the agent and schedule exist + // Validate the agent exists const agents = config?.agents ?? []; - // Try qualified name first, fall back to local name const agent = agents.find((a) => a.qualifiedName === agentName) ?? agents.find((a) => a.name === agentName); @@ -223,12 +243,8 @@ export class ScheduleManagement { }); } - if (!agent.schedules || !(scheduleName in agent.schedules)) { - const availableSchedules = agent.schedules ? Object.keys(agent.schedules) : []; - throw new ScheduleNotFoundError(agentName, scheduleName, { - availableSchedules, - }); - } + // Verify the schedule exists (static or dynamic) + await this.getSchedule(agent.qualifiedName, scheduleName); // Update schedule state to disabled — use qualifiedName as the state key const { updateScheduleState } = await import("../scheduler/schedule-state.js"); diff --git a/packages/core/src/mcp/scheduler-mcp.ts b/packages/core/src/mcp/scheduler-mcp.ts index 7be1ac86..5edac438 100644 --- a/packages/core/src/mcp/scheduler-mcp.ts +++ b/packages/core/src/mcp/scheduler-mcp.ts @@ -99,8 +99,9 @@ server.tool( ), prompt: z .string() + .max(10000) .describe( - "The prompt to execute when this schedule triggers. This is what the agent will be asked to do.", + "The prompt to execute when this schedule triggers. This is what the agent will be asked to do. Max 10,000 characters.", ), ttl_hours: z .number() diff --git a/packages/core/src/scheduler/__tests__/dynamic-schedules.test.ts b/packages/core/src/scheduler/__tests__/dynamic-schedules.test.ts index 1de8078d..2163c3c9 100644 --- a/packages/core/src/scheduler/__tests__/dynamic-schedules.test.ts +++ b/packages/core/src/scheduler/__tests__/dynamic-schedules.test.ts @@ -68,8 +68,16 @@ describe("dynamic-schedules", () => { ); }); - it("rejects agent names with dots", () => { - expect(() => getDynamicScheduleFilePath("/foo/.herdctl", "agent.name")).toThrow( + it("allows dotted qualified names (e.g., fleet.agent)", () => { + const result = getDynamicScheduleFilePath("/foo/.herdctl", "fleet.agent"); + expect(result).toContain("fleet.agent.yaml"); + }); + + it("rejects double-dot sequences to prevent path traversal", () => { + expect(() => getDynamicScheduleFilePath("/foo/.herdctl", "agent..name")).toThrow( + "Invalid agent name", + ); + expect(() => getDynamicScheduleFilePath("/foo/.herdctl", "..agent")).toThrow( "Invalid agent name", ); }); diff --git a/packages/core/src/scheduler/dynamic-schedules.ts b/packages/core/src/scheduler/dynamic-schedules.ts index 4b1d559f..00c97ca3 100644 --- a/packages/core/src/scheduler/dynamic-schedules.ts +++ b/packages/core/src/scheduler/dynamic-schedules.ts @@ -8,14 +8,13 @@ * - One file per agent: eliminates cross-agent write contention * - Atomic writes: uses the same atomicWriteYaml pattern as state.yaml * - TTL expiration: lazy cleanup on read (no background timer needed) - * - Namespace isolation: agent name validated against AGENT_NAME_PATTERN + * - Namespace isolation: qualified name validated against QUALIFIED_NAME_PATTERN */ import { mkdir, readFile, unlink } from "node:fs/promises"; import { join } from "node:path"; import { parse as parseYaml } from "yaml"; import { z } from "zod"; -import { AGENT_NAME_PATTERN } from "../config/schema.js"; import { atomicWriteYaml } from "../state/utils/atomic.js"; import { calculateNextCronTrigger, isValidCronExpression } from "./cron.js"; import { parseInterval } from "./interval.js"; @@ -50,6 +49,13 @@ export const DynamicScheduleFileSchema = z.object({ export type DynamicScheduleFile = z.infer; +/** + * Qualified agent name pattern for file path construction. + * Allows dots (for qualified names like "fleet.agent") but rejects ".." + * sequences to prevent path traversal. + */ +const QUALIFIED_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/; + /** * Schedule name pattern — same rules as agent names */ @@ -102,10 +108,12 @@ export function getDynamicSchedulesDir(stateDir: string): string { * Get the file path for a specific agent's dynamic schedules */ export function getDynamicScheduleFilePath(stateDir: string, agentName: string): string { - // Validate agent name to prevent path traversal - if (!AGENT_NAME_PATTERN.test(agentName)) { + // Validate agent name to prevent path traversal. + // Qualified names like "fleet.agent" contain dots, so we allow single dots + // but reject ".." sequences which would escape the directory. + if (!QUALIFIED_NAME_PATTERN.test(agentName) || agentName.includes("..")) { throw new DynamicScheduleError( - `Invalid agent name "${agentName}": must match ${AGENT_NAME_PATTERN.source}`, + `Invalid agent name "${agentName}": must match ${QUALIFIED_NAME_PATTERN.source} (no ".." sequences)`, ); } return join(getDynamicSchedulesDir(stateDir), `${agentName}.yaml`); @@ -243,7 +251,7 @@ export async function createDynamicSchedule( } // Validate minimum interval - validateMinInterval(input, options.minInterval); + validateMinInterval(input.name, input, options.minInterval); // Check for static schedule name collision if (options.staticScheduleNames?.includes(input.name)) { @@ -326,7 +334,7 @@ export async function updateDynamicSchedule( cron: updates.cron ?? existing.cron, interval: updates.interval ?? existing.interval, }; - validateMinInterval(effectiveSchedule, options.minInterval); + validateMinInterval(scheduleName, effectiveSchedule, options.minInterval); // Apply updates const updated: DynamicSchedule = { ...existing }; @@ -432,8 +440,13 @@ export async function loadAllDynamicSchedules( if (Object.keys(schedules).length > 0) { result.set(agentName, schedules); } - } catch { - // Skip files that fail to parse — don't crash the scheduler + } catch (parseError) { + // Skip files that fail to parse — don't crash the scheduler. + // Log a warning so operators can diagnose corrupt files. + const msg = parseError instanceof Error ? parseError.message : String(parseError); + // Use console.warn as a safety fallback — this code path should rarely fire + // and adding a logger dependency here would complicate the API surface. + console.warn(`[dynamic-schedules] Failed to load schedules for "${agentName}": ${msg}`); } } @@ -452,6 +465,7 @@ export async function loadAllDynamicSchedules( * and reject if it's below the minimum. */ function validateMinInterval( + scheduleName: string, schedule: { type: string; cron?: string; interval?: string }, minInterval: string, ): void { @@ -460,7 +474,7 @@ function validateMinInterval( if (schedule.type === "interval" && schedule.interval) { const scheduleMs = parseInterval(schedule.interval); if (scheduleMs < minMs) { - throw new MinIntervalViolationError(schedule.interval, schedule.interval, minInterval); + throw new MinIntervalViolationError(scheduleName, schedule.interval, minInterval); } } @@ -473,7 +487,7 @@ function validateMinInterval( const gapMs = second.getTime() - first.getTime(); if (gapMs < minMs) { throw new MinIntervalViolationError( - schedule.cron, + scheduleName, `~${Math.round(gapMs / 1000)}s`, minInterval, ); From 8658dd7bf3a920d1c44425b3c75dd3c233483ff7 Mon Sep 17 00:00:00 2001 From: agent Date: Tue, 3 Mar 2026 17:14:34 -0800 Subject: [PATCH 03/48] fix(core): address remaining review findings in self-scheduling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix TTL on update: expires_at now computed from Date.now() instead of created_at — setting ttl_hours: 24 during an update expires 24h from now, not 24h from original creation - Fix read/write race on TTL cleanup: reads no longer write back expired entries. Expired schedules are filtered in-memory during reads and cleaned up during mutations (create/update/delete) which already perform a read-modify-write cycle - Fix dynamic schedules missing from getAgentInfo(): buildAgentInfo now accepts and merges dynamic schedules so they appear in CLI status and web dashboard views Co-Authored-By: Claude Opus 4.6 --- .../core/src/fleet-manager/status-queries.ts | 49 ++++++++++- .../core/src/scheduler/dynamic-schedules.ts | 83 ++++++++++++------- 2 files changed, 99 insertions(+), 33 deletions(-) diff --git a/packages/core/src/fleet-manager/status-queries.ts b/packages/core/src/fleet-manager/status-queries.ts index 3f3c495a..6b843334 100644 --- a/packages/core/src/fleet-manager/status-queries.ts +++ b/packages/core/src/fleet-manager/status-queries.ts @@ -8,6 +8,8 @@ */ import type { ResolvedAgent } from "../config/index.js"; +import type { DynamicSchedule } from "../scheduler/dynamic-schedules.js"; +import { loadAllDynamicSchedules } from "../scheduler/dynamic-schedules.js"; import type { Scheduler } from "../scheduler/index.js"; import { readFleetState } from "../state/fleet-state.js"; import type { AgentState, FleetState } from "../state/schemas/fleet-state.js"; @@ -143,9 +145,18 @@ export class StatusQueries { // Get chat managers for connection status const chatManagers = this.ctx.getChatManagers?.() ?? new Map(); + // Load dynamic schedules for all agents + let dynamicSchedules = new Map>(); + try { + dynamicSchedules = await loadAllDynamicSchedules(this.ctx.getStateDir()); + } catch { + // Ignore — show static schedules at minimum + } + return agents.map((agent) => { const agentState = fleetState.agents[agent.qualifiedName]; - return buildAgentInfo(agent, agentState, this.ctx.getScheduler(), chatManagers); + const agentDynamic = dynamicSchedules.get(agent.qualifiedName); + return buildAgentInfo(agent, agentState, this.ctx.getScheduler(), chatManagers, agentDynamic); }); } @@ -186,7 +197,16 @@ export class StatusQueries { // Get chat managers for connection status const chatManagers = this.ctx.getChatManagers?.() ?? new Map(); - return buildAgentInfo(agent, agentState, this.ctx.getScheduler(), chatManagers); + // Load dynamic schedules for this agent + let agentDynamic: Record | undefined; + try { + const dynamic = await loadAllDynamicSchedules(this.ctx.getStateDir()); + agentDynamic = dynamic.get(agent.qualifiedName); + } catch { + // Ignore — show static schedules at minimum + } + + return buildAgentInfo(agent, agentState, this.ctx.getScheduler(), chatManagers, agentDynamic); } } @@ -201,6 +221,7 @@ export class StatusQueries { * @param agentState - Runtime agent state (optional) * @param scheduler - Scheduler instance for running job counts (optional) * @param chatManagers - Map of chat managers for connection status (optional) + * @param dynamicSchedules - Dynamic schedules for this agent (optional) * @returns Complete AgentInfo object */ export function buildAgentInfo( @@ -208,10 +229,32 @@ export function buildAgentInfo( agentState?: AgentState, scheduler?: Scheduler | null, chatManagers?: Map, + dynamicSchedules?: Record, ): AgentInfo { - // Build schedule info + // Build schedule info (static + dynamic) const schedules = buildScheduleInfoList(agent, agentState); + // Merge dynamic schedules (skip if a static schedule has the same name) + if (dynamicSchedules) { + const staticNames = new Set(schedules.map((s) => s.name)); + for (const [name, schedule] of Object.entries(dynamicSchedules)) { + if (staticNames.has(name)) continue; + const scheduleState = agentState?.schedules?.[name]; + schedules.push({ + name, + agentName: agent.qualifiedName, + type: schedule.type, + interval: schedule.interval, + cron: schedule.cron, + status: scheduleState?.status ?? "idle", + lastRunAt: scheduleState?.last_run_at ?? null, + nextRunAt: scheduleState?.next_run_at ?? null, + lastError: scheduleState?.last_error ?? null, + source: "dynamic", + }); + } + } + // Get running count from scheduler or state (use qualifiedName as the key) const runningCount = scheduler?.getRunningJobCount(agent.qualifiedName) ?? 0; diff --git a/packages/core/src/scheduler/dynamic-schedules.ts b/packages/core/src/scheduler/dynamic-schedules.ts index 00c97ca3..babd76a6 100644 --- a/packages/core/src/scheduler/dynamic-schedules.ts +++ b/packages/core/src/scheduler/dynamic-schedules.ts @@ -124,14 +124,43 @@ export function getDynamicScheduleFilePath(stateDir: string, agentName: string): // ============================================================================= /** - * Read dynamic schedules for an agent, performing lazy TTL expiration. + * Read dynamic schedules for an agent from disk. * - * Returns an empty schedule map if the file doesn't exist. - * Expired schedules are removed from the file on read. + * This is a pure read — it does NOT write back expired schedules. + * TTL filtering is applied in-memory: callers see only non-expired schedules, + * but the file is not modified. This avoids write contention when multiple + * readers (scheduler tick, MCP server, CLI) access the same file concurrently. + * + * Expired entries are cleaned up during mutations (create/update/delete) + * which already perform a read-modify-write cycle. */ export async function readDynamicSchedules( stateDir: string, agentName: string, +): Promise { + const parsed = await readRawDynamicSchedules(stateDir, agentName); + + // Filter expired schedules in-memory (no write-back) + const now = new Date(); + const activeSchedules: Record = {}; + + for (const [name, schedule] of Object.entries(parsed.schedules)) { + if (schedule.expires_at && new Date(schedule.expires_at) <= now) { + continue; // Skip expired schedule + } + activeSchedules[name] = schedule; + } + + return { version: parsed.version, schedules: activeSchedules }; +} + +/** + * Read the raw file content including expired entries. + * Used internally by mutations that will write back anyway. + */ +async function readRawDynamicSchedules( + stateDir: string, + agentName: string, ): Promise { const filePath = getDynamicScheduleFilePath(stateDir, agentName); @@ -150,29 +179,20 @@ export async function readDynamicSchedules( return { version: 1, schedules: {} }; } - const parsed = DynamicScheduleFileSchema.parse(raw); + return DynamicScheduleFileSchema.parse(raw); +} - // Lazy TTL expiration +/** + * Strip expired entries from a schedule file in-place. + * Called during mutations to piggyback cleanup on writes. + */ +function stripExpired(file: DynamicScheduleFile): void { const now = new Date(); - let expired = false; - const activeSchedules: Record = {}; - - for (const [name, schedule] of Object.entries(parsed.schedules)) { + for (const [name, schedule] of Object.entries(file.schedules)) { if (schedule.expires_at && new Date(schedule.expires_at) <= now) { - expired = true; - continue; // Skip expired schedule + delete file.schedules[name]; } - activeSchedules[name] = schedule; - } - - // Write back if any schedules were expired - if (expired) { - const cleaned: DynamicScheduleFile = { version: 1, schedules: activeSchedules }; - await writeDynamicSchedules(stateDir, agentName, cleaned); - return cleaned; } - - return parsed; } /** @@ -261,8 +281,9 @@ export async function createDynamicSchedule( ); } - // Read current schedules - const file = await readDynamicSchedules(stateDir, agentName); + // Read current schedules (raw — includes expired entries for cleanup) + const file = await readRawDynamicSchedules(stateDir, agentName); + stripExpired(file); // Check for duplicate name if (input.name in file.schedules) { @@ -272,7 +293,7 @@ export async function createDynamicSchedule( ); } - // Check schedule count limit + // Check schedule count limit (against non-expired count) if (Object.keys(file.schedules).length >= options.maxSchedules) { throw new ScheduleLimitExceededError(agentName, options.maxSchedules); } @@ -308,7 +329,8 @@ export async function updateDynamicSchedule( updates: UpdateScheduleInput, options: DynamicScheduleOptions, ): Promise { - const file = await readDynamicSchedules(stateDir, agentName); + const file = await readRawDynamicSchedules(stateDir, agentName); + stripExpired(file); if (!(scheduleName in file.schedules)) { throw new DynamicScheduleError(`Schedule "${scheduleName}" not found`); @@ -343,7 +365,9 @@ export async function updateDynamicSchedule( if (updates.prompt !== undefined) updated.prompt = updates.prompt; if (updates.enabled !== undefined) updated.enabled = updates.enabled; - // Handle TTL updates + // Handle TTL updates — always relative to now, not created_at. + // If the user sets ttl_hours: 24 during an update, they expect it to + // expire 24 hours from now, not 24 hours from the original creation time. if (updates.ttl_hours !== undefined) { if (updates.ttl_hours === null) { // Remove TTL @@ -351,9 +375,7 @@ export async function updateDynamicSchedule( updated.expires_at = null; } else { updated.ttl_hours = updates.ttl_hours; - updated.expires_at = new Date( - new Date(updated.created_at).getTime() + updates.ttl_hours * 3600000, - ).toISOString(); + updated.expires_at = new Date(Date.now() + updates.ttl_hours * 3600000).toISOString(); } } @@ -371,7 +393,8 @@ export async function deleteDynamicSchedule( agentName: string, scheduleName: string, ): Promise { - const file = await readDynamicSchedules(stateDir, agentName); + const file = await readRawDynamicSchedules(stateDir, agentName); + stripExpired(file); if (!(scheduleName in file.schedules)) { throw new DynamicScheduleError(`Schedule "${scheduleName}" not found`); From 597ab9a418212d336cd70ebe0580e52270cf208f Mon Sep 17 00:00:00 2001 From: agent Date: Tue, 3 Mar 2026 17:27:10 -0800 Subject: [PATCH 04/48] fix(core): address CodeRabbit PR review feedback - Validate min_interval at config parse time via Zod refine - Validate MCP env vars via Zod schema (prevents NaN from parseInt) - Validate schedule payloads via DynamicScheduleSchema.parse() before writing - Replace console.warn with createLogger in dynamic-schedules - Log warnings instead of silently swallowing dynamic schedule load errors - Include dynamic schedule names in ScheduleNotFoundError context - Force immediate dynamic schedule load on scheduler start (reset cache) - Update pnpm-lock.yaml with @modelcontextprotocol/sdk dependency Co-Authored-By: Claude Opus 4.6 --- packages/core/src/config/schema.ts | 8 +- .../src/fleet-manager/schedule-management.ts | 17 ++- .../core/src/fleet-manager/status-queries.ts | 12 +- packages/core/src/mcp/scheduler-mcp.ts | 26 ++-- .../core/src/scheduler/dynamic-schedules.ts | 18 +-- packages/core/src/scheduler/scheduler.ts | 4 +- pnpm-lock.yaml | 113 ++++++++++++++++++ 7 files changed, 168 insertions(+), 30 deletions(-) diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 4af27a9b..c989ddc2 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -863,7 +863,13 @@ export const SelfSchedulingSchema = z.object({ /** Maximum number of dynamic schedules this agent can create (default: 10) */ max_schedules: z.number().int().positive().optional().default(10), /** Minimum interval between schedule triggers (default: "5m") */ - min_interval: z.string().optional().default("5m"), + min_interval: z + .string() + .optional() + .default("5m") + .refine((value) => /^\d+\s*[smhd]$/i.test(value), { + message: "min_interval must be a valid duration (e.g. 5m, 1h, 30s)", + }), }); // ============================================================================= diff --git a/packages/core/src/fleet-manager/schedule-management.ts b/packages/core/src/fleet-manager/schedule-management.ts index 567162f7..1189373a 100644 --- a/packages/core/src/fleet-manager/schedule-management.ts +++ b/packages/core/src/fleet-manager/schedule-management.ts @@ -55,8 +55,9 @@ export class ScheduleManagement { >(); try { dynamicSchedules = (await loadAllDynamicSchedules(stateDir)) as typeof dynamicSchedules; - } catch { - // Ignore errors loading dynamic schedules — show static ones at minimum + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + this.ctx.getLogger().warn(`Failed to load dynamic schedules: ${msg}`); } for (const agent of agents) { @@ -137,8 +138,10 @@ export class ScheduleManagement { // Fall back to dynamic schedules const stateDir = this.ctx.getStateDir(); + let dynamicScheduleNames: string[] = []; try { const dynamic = await listDynamicSchedules(stateDir, agent.qualifiedName); + dynamicScheduleNames = Object.keys(dynamic); if (scheduleName in dynamic) { const schedule = dynamic[scheduleName]; const scheduleState = agentState?.schedules?.[scheduleName]; @@ -155,11 +158,15 @@ export class ScheduleManagement { source: "dynamic", }; } - } catch { - // Ignore dynamic schedule load errors + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + this.ctx + .getLogger() + .warn(`Failed to load dynamic schedules for ${agent.qualifiedName}: ${msg}`); } - const availableSchedules = agent.schedules ? Object.keys(agent.schedules) : []; + const staticNames = agent.schedules ? Object.keys(agent.schedules) : []; + const availableSchedules = [...new Set([...staticNames, ...dynamicScheduleNames])]; throw new ScheduleNotFoundError(agentName, scheduleName, { availableSchedules, }); diff --git a/packages/core/src/fleet-manager/status-queries.ts b/packages/core/src/fleet-manager/status-queries.ts index 6b843334..0c415d1a 100644 --- a/packages/core/src/fleet-manager/status-queries.ts +++ b/packages/core/src/fleet-manager/status-queries.ts @@ -149,8 +149,9 @@ export class StatusQueries { let dynamicSchedules = new Map>(); try { dynamicSchedules = await loadAllDynamicSchedules(this.ctx.getStateDir()); - } catch { - // Ignore — show static schedules at minimum + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + this.ctx.getLogger().warn(`Failed to load dynamic schedules: ${msg}`); } return agents.map((agent) => { @@ -202,8 +203,11 @@ export class StatusQueries { try { const dynamic = await loadAllDynamicSchedules(this.ctx.getStateDir()); agentDynamic = dynamic.get(agent.qualifiedName); - } catch { - // Ignore — show static schedules at minimum + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + this.ctx + .getLogger() + .warn(`Failed to load dynamic schedules for ${agent.qualifiedName}: ${msg}`); } return buildAgentInfo(agent, agentState, this.ctx.getScheduler(), chatManagers, agentDynamic); diff --git a/packages/core/src/mcp/scheduler-mcp.ts b/packages/core/src/mcp/scheduler-mcp.ts index 5edac438..0a3e4d4b 100644 --- a/packages/core/src/mcp/scheduler-mcp.ts +++ b/packages/core/src/mcp/scheduler-mcp.ts @@ -37,21 +37,25 @@ import { // Configuration from environment // ============================================================================= -const AGENT_NAME = process.env.HERDCTL_AGENT_NAME; -const STATE_DIR = process.env.HERDCTL_STATE_DIR; -const MAX_SCHEDULES = parseInt(process.env.HERDCTL_MAX_SCHEDULES ?? "10", 10); -const MIN_INTERVAL = process.env.HERDCTL_MIN_INTERVAL ?? "5m"; -const STATIC_SCHEDULES = (process.env.HERDCTL_STATIC_SCHEDULES ?? "").split(",").filter(Boolean); +const EnvSchema = z.object({ + HERDCTL_AGENT_NAME: z.string().min(1, "HERDCTL_AGENT_NAME is required"), + HERDCTL_STATE_DIR: z.string().min(1, "HERDCTL_STATE_DIR is required"), + HERDCTL_MAX_SCHEDULES: z.coerce.number().int().positive().default(10), + HERDCTL_MIN_INTERVAL: z.string().default("5m"), + HERDCTL_STATIC_SCHEDULES: z.string().optional().default(""), +}); -if (!AGENT_NAME) { - console.error("HERDCTL_AGENT_NAME environment variable is required"); +const envResult = EnvSchema.safeParse(process.env); +if (!envResult.success) { + console.error(`Invalid environment: ${envResult.error.issues.map((i) => i.message).join(", ")}`); process.exit(1); } -if (!STATE_DIR) { - console.error("HERDCTL_STATE_DIR environment variable is required"); - process.exit(1); -} +const AGENT_NAME = envResult.data.HERDCTL_AGENT_NAME; +const STATE_DIR = envResult.data.HERDCTL_STATE_DIR; +const MAX_SCHEDULES = envResult.data.HERDCTL_MAX_SCHEDULES; +const MIN_INTERVAL = envResult.data.HERDCTL_MIN_INTERVAL; +const STATIC_SCHEDULES = envResult.data.HERDCTL_STATIC_SCHEDULES.split(",").filter(Boolean); const scheduleOptions = { maxSchedules: MAX_SCHEDULES, diff --git a/packages/core/src/scheduler/dynamic-schedules.ts b/packages/core/src/scheduler/dynamic-schedules.ts index babd76a6..162d5f46 100644 --- a/packages/core/src/scheduler/dynamic-schedules.ts +++ b/packages/core/src/scheduler/dynamic-schedules.ts @@ -16,9 +16,12 @@ import { join } from "node:path"; import { parse as parseYaml } from "yaml"; import { z } from "zod"; import { atomicWriteYaml } from "../state/utils/atomic.js"; +import { createLogger } from "../utils/logger.js"; import { calculateNextCronTrigger, isValidCronExpression } from "./cron.js"; import { parseInterval } from "./interval.js"; +const logger = createLogger("dynamic-schedules"); + // ============================================================================= // Schema // ============================================================================= @@ -298,9 +301,9 @@ export async function createDynamicSchedule( throw new ScheduleLimitExceededError(agentName, options.maxSchedules); } - // Build the schedule + // Build and validate the schedule before persisting const now = new Date().toISOString(); - const schedule: DynamicSchedule = { + const schedule = DynamicScheduleSchema.parse({ type: input.type, ...(input.cron && { cron: input.cron }), ...(input.interval && { interval: input.interval }), @@ -311,7 +314,7 @@ export async function createDynamicSchedule( ttl_hours: input.ttl_hours, expires_at: new Date(Date.now() + input.ttl_hours * 3600000).toISOString(), }), - }; + }); file.schedules[input.name] = schedule; await writeDynamicSchedules(stateDir, agentName, file); @@ -379,10 +382,11 @@ export async function updateDynamicSchedule( } } - file.schedules[scheduleName] = updated; + const validated = DynamicScheduleSchema.parse(updated); + file.schedules[scheduleName] = validated; await writeDynamicSchedules(stateDir, agentName, file); - return updated; + return validated; } /** @@ -467,9 +471,7 @@ export async function loadAllDynamicSchedules( // Skip files that fail to parse — don't crash the scheduler. // Log a warning so operators can diagnose corrupt files. const msg = parseError instanceof Error ? parseError.message : String(parseError); - // Use console.warn as a safety fallback — this code path should rarely fire - // and adding a logger dependency here would complicate the API surface. - console.warn(`[dynamic-schedules] Failed to load schedules for "${agentName}": ${msg}`); + logger.warn(`Failed to load schedules for "${agentName}": ${msg}`); } } diff --git a/packages/core/src/scheduler/scheduler.ts b/packages/core/src/scheduler/scheduler.ts index f151350b..b934483a 100644 --- a/packages/core/src/scheduler/scheduler.ts +++ b/packages/core/src/scheduler/scheduler.ts @@ -115,7 +115,7 @@ export class Scheduler { { type: string; interval?: string; cron?: string; prompt?: string; enabled?: boolean } > > = new Map(); - private dynamicScheduleLastLoad = 0; + private dynamicScheduleLastLoad = -10; // force immediate load (matches DYNAMIC_SCHEDULE_RELOAD_INTERVAL) private static readonly DYNAMIC_SCHEDULE_RELOAD_INTERVAL = 10; // reload every 10 ticks (~10s) constructor(options: SchedulerOptions) { @@ -175,6 +175,8 @@ export class Scheduler { this.triggerCount = 0; this.runningSchedules.clear(); this.runningJobs.clear(); + this.dynamicScheduleCache.clear(); + this.dynamicScheduleLastLoad = -Scheduler.DYNAMIC_SCHEDULE_RELOAD_INTERVAL; this.logger.debug( `Scheduler started with ${agents.length} agents, check interval: ${this.checkInterval}ms`, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd9993fb..14924799 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: '@anthropic-ai/claude-agent-sdk': specifier: ^0.1.0 version: 0.1.77(zod@3.25.76) + '@modelcontextprotocol/sdk': + specifier: ^1.27.1 + version: 1.27.1(zod@3.25.76) chokidar: specifier: ^5 version: 5.0.0 @@ -1042,6 +1045,12 @@ packages: engines: {node: '>=6'} hasBin: true + '@hono/node-server@1.19.10': + resolution: {integrity: sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} @@ -1450,6 +1459,16 @@ packages: '@mermaid-js/parser@1.0.0': resolution: {integrity: sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==} + '@modelcontextprotocol/sdk@1.27.1': + resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2500,6 +2519,10 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cose-base@1.0.3: resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} @@ -2976,6 +2999,14 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + execa@9.6.1: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} @@ -2984,6 +3015,12 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.2.1: + resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -3254,6 +3291,10 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + hono@4.12.4: + resolution: {integrity: sha512-ooiZW1Xy8rQ4oELQ++otI2T9DsKpV0M6c6cO6JGx4RTfav9poFFLlet9UMXHZnoM1yG0HWGlQLswBGX3RZmHtg==} + engines: {node: '>=16.9.0'} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -3339,6 +3380,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ip-address@10.0.1: + resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -3452,6 +3497,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3486,6 +3534,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -4028,6 +4079,10 @@ packages: nwsapi@2.2.23: resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -4208,6 +4263,10 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -6177,6 +6236,10 @@ snapshots: protobufjs: 7.5.4 yargs: 17.7.2 + '@hono/node-server@1.19.10(hono@4.12.4)': + dependencies: + hono: 4.12.4 + '@iconify/types@2.0.0': {} '@iconify/utils@3.1.0': @@ -6541,6 +6604,28 @@ snapshots: dependencies: langium: 4.2.1 + '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.10(hono@4.12.4) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.2.1(express@5.2.1) + hono: 4.12.4 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - supports-color + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7673,6 +7758,11 @@ snapshots: cookie@1.1.1: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cose-base@1.0.3: dependencies: layout-base: 1.0.2 @@ -8238,6 +8328,12 @@ snapshots: eventemitter3@5.0.4: {} + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + execa@9.6.1: dependencies: '@sindresorhus/merge-streams': 4.0.0 @@ -8255,6 +8351,11 @@ snapshots: expect-type@1.3.0: {} + express-rate-limit@8.2.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.0.1 + express@5.2.1: dependencies: accepts: 2.0.0 @@ -8722,6 +8823,8 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 + hono@4.12.4: {} + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -8794,6 +8897,8 @@ snapshots: internmap@2.0.3: {} + ip-address@10.0.1: {} + ipaddr.js@1.9.1: {} ipaddr.js@2.3.0: {} @@ -8876,6 +8981,8 @@ snapshots: jiti@2.6.1: {} + jose@6.1.3: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -8924,6 +9031,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json5@2.2.3: {} jsonfile@4.0.0: @@ -9718,6 +9827,8 @@ snapshots: nwsapi@2.2.23: {} + object-assign@4.1.1: {} + object-inspect@1.13.4: {} obliterator@2.0.5: {} @@ -9894,6 +10005,8 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.0.0 + pkce-challenge@5.0.1: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 From cc4bfda16f5d5c074eea4374229e0fd54cecaed6 Mon Sep 17 00:00:00 2001 From: agent Date: Tue, 3 Mar 2026 17:44:39 -0800 Subject: [PATCH 05/48] refactor(core): address CodeRabbit nitpick feedback - Log unexpected errors in cron gap estimation instead of silencing - Use DynamicSchedule type directly for scheduler cache (remove fragile cast) - Use listDynamicSchedules for single-agent lookup in getAgentInfoByName Co-Authored-By: Claude Opus 4.6 --- .../core/src/fleet-manager/status-queries.ts | 10 ++++++---- .../core/src/scheduler/dynamic-schedules.ts | 1 + packages/core/src/scheduler/scheduler.ts | 19 +++---------------- 3 files changed, 10 insertions(+), 20 deletions(-) diff --git a/packages/core/src/fleet-manager/status-queries.ts b/packages/core/src/fleet-manager/status-queries.ts index 0c415d1a..0380bbb3 100644 --- a/packages/core/src/fleet-manager/status-queries.ts +++ b/packages/core/src/fleet-manager/status-queries.ts @@ -9,7 +9,7 @@ import type { ResolvedAgent } from "../config/index.js"; import type { DynamicSchedule } from "../scheduler/dynamic-schedules.js"; -import { loadAllDynamicSchedules } from "../scheduler/dynamic-schedules.js"; +import { listDynamicSchedules, loadAllDynamicSchedules } from "../scheduler/dynamic-schedules.js"; import type { Scheduler } from "../scheduler/index.js"; import { readFleetState } from "../state/fleet-state.js"; import type { AgentState, FleetState } from "../state/schemas/fleet-state.js"; @@ -198,11 +198,13 @@ export class StatusQueries { // Get chat managers for connection status const chatManagers = this.ctx.getChatManagers?.() ?? new Map(); - // Load dynamic schedules for this agent + // Load dynamic schedules for this specific agent (avoids reading all agents' files) let agentDynamic: Record | undefined; try { - const dynamic = await loadAllDynamicSchedules(this.ctx.getStateDir()); - agentDynamic = dynamic.get(agent.qualifiedName); + const dynamic = await listDynamicSchedules(this.ctx.getStateDir(), agent.qualifiedName); + if (Object.keys(dynamic).length > 0) { + agentDynamic = dynamic; + } } catch (error) { const msg = error instanceof Error ? error.message : String(error); this.ctx diff --git a/packages/core/src/scheduler/dynamic-schedules.ts b/packages/core/src/scheduler/dynamic-schedules.ts index 162d5f46..40c80a6c 100644 --- a/packages/core/src/scheduler/dynamic-schedules.ts +++ b/packages/core/src/scheduler/dynamic-schedules.ts @@ -520,6 +520,7 @@ function validateMinInterval( } catch (error) { // Re-throw our own errors, ignore cron parsing failures if (error instanceof MinIntervalViolationError) throw error; + logger.debug(`Unexpected error estimating cron gap for "${schedule.cron}": ${error}`); } } } diff --git a/packages/core/src/scheduler/scheduler.ts b/packages/core/src/scheduler/scheduler.ts index b934483a..9519a5b4 100644 --- a/packages/core/src/scheduler/scheduler.ts +++ b/packages/core/src/scheduler/scheduler.ts @@ -12,7 +12,7 @@ import { calculatePreviousCronTrigger, isValidCronExpression, } from "./cron.js"; -import { loadAllDynamicSchedules } from "./dynamic-schedules.js"; +import { type DynamicSchedule, loadAllDynamicSchedules } from "./dynamic-schedules.js"; import { SchedulerShutdownError } from "./errors.js"; import { calculateNextTrigger, isScheduleDue } from "./interval.js"; import { @@ -108,13 +108,7 @@ export class Scheduler { private warnedSchedules = new Set(); // Dynamic schedule cache — re-read from disk every N ticks to avoid excessive I/O - private dynamicScheduleCache: Map< - string, - Record< - string, - { type: string; interval?: string; cron?: string; prompt?: string; enabled?: boolean } - > - > = new Map(); + private dynamicScheduleCache: Map> = new Map(); private dynamicScheduleLastLoad = -10; // force immediate load (matches DYNAMIC_SCHEDULE_RELOAD_INTERVAL) private static readonly DYNAMIC_SCHEDULE_RELOAD_INTERVAL = 10; // reload every 10 ticks (~10s) @@ -325,14 +319,7 @@ export class Scheduler { Scheduler.DYNAMIC_SCHEDULE_RELOAD_INTERVAL ) { try { - const loaded = await loadAllDynamicSchedules(this.stateDir); - this.dynamicScheduleCache = loaded as Map< - string, - Record< - string, - { type: string; interval?: string; cron?: string; prompt?: string; enabled?: boolean } - > - >; + this.dynamicScheduleCache = await loadAllDynamicSchedules(this.stateDir); this.dynamicScheduleLastLoad = this.checkCount; } catch (error) { this.logger.error( From 80c3885a14abfa03aa4d9f6c55895a257977dbe4 Mon Sep 17 00:00:00 2001 From: agent Date: Sun, 1 Mar 2026 16:33:26 -0800 Subject: [PATCH 06/48] feat(discord): add typing_indicator output config option Adds a `typing_indicator` boolean to `DiscordOutputSchema` (default: true) that controls whether the bot shows a typing indicator while processing. The typing indicator can cause "An unknown error occurred" messages in Discord on long-running jobs, because Discord's client shows an error when the sendTyping() API call fails or races with rate limits during extended processing. Setting `typing_indicator: false` in the agent's Discord output config disables the indicator entirely: ```yaml chat: discord: output: typing_indicator: false ``` Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/config/schema.ts | 3 +++ .../src/__tests__/discord-connector.test.ts | 1 + packages/discord/src/__tests__/logger.test.ts | 1 + packages/discord/src/__tests__/manager.test.ts | 16 ++++++++++++++++ packages/discord/src/manager.ts | 5 +++-- 5 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index e6d200f5..0c055e9f 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -616,11 +616,14 @@ export const ChatOutputSchema = z.object({ * system_status: true * result_summary: false * errors: true + * typing_indicator: true * ``` */ export const DiscordOutputSchema = ChatOutputSchema.extend({ /** Show a summary embed when the agent finishes a turn (cost, tokens, turns) (default: false) */ result_summary: z.boolean().optional().default(false), + /** Show typing indicator while the agent is processing (default: true) */ + typing_indicator: z.boolean().optional().default(true), }); /** diff --git a/packages/discord/src/__tests__/discord-connector.test.ts b/packages/discord/src/__tests__/discord-connector.test.ts index 553e2fa4..c45c203d 100644 --- a/packages/discord/src/__tests__/discord-connector.test.ts +++ b/packages/discord/src/__tests__/discord-connector.test.ts @@ -125,6 +125,7 @@ function createMockDiscordConfig(): AgentChatDiscord { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, }, guilds: [ diff --git a/packages/discord/src/__tests__/logger.test.ts b/packages/discord/src/__tests__/logger.test.ts index fb73859e..4a93729c 100644 --- a/packages/discord/src/__tests__/logger.test.ts +++ b/packages/discord/src/__tests__/logger.test.ts @@ -21,6 +21,7 @@ function createMockDiscordConfig(logLevel: DiscordLogLevel = "standard"): AgentC tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, }, guilds: [], diff --git a/packages/discord/src/__tests__/manager.test.ts b/packages/discord/src/__tests__/manager.test.ts index ce4b123a..68982c7e 100644 --- a/packages/discord/src/__tests__/manager.test.ts +++ b/packages/discord/src/__tests__/manager.test.ts @@ -178,6 +178,7 @@ describe("DiscordManager", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, }, guilds: [], @@ -731,6 +732,7 @@ describe.skip("DiscordManager message handling", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, }, guilds: [], @@ -916,6 +918,7 @@ describe.skip("DiscordManager message handling", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, }, guilds: [], @@ -1063,6 +1066,7 @@ describe.skip("DiscordManager message handling", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, }, guilds: [], @@ -1241,6 +1245,7 @@ describe.skip("DiscordManager message handling", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, }, guilds: [], @@ -1402,6 +1407,7 @@ describe.skip("DiscordManager message handling", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, }, guilds: [], @@ -2470,6 +2476,7 @@ describe.skip("DiscordManager session integration", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, }, guilds: [], @@ -3157,6 +3164,7 @@ describe.skip("DiscordManager lifecycle", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, }, guilds: [], @@ -3301,6 +3309,7 @@ describe.skip("DiscordManager lifecycle", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, }, guilds: [], @@ -3651,6 +3660,7 @@ describe.skip("DiscordManager lifecycle", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, }, guilds: [], @@ -3840,6 +3850,7 @@ describe.skip("DiscordManager output configuration", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, }, guilds: [], @@ -4002,6 +4013,7 @@ describe.skip("DiscordManager output configuration", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, }, guilds: [], @@ -4163,6 +4175,7 @@ describe.skip("DiscordManager output configuration", () => { tool_result_max_length: 900, system_status: false, result_summary: false, + typing_indicator: true, errors: true, }, guilds: [], @@ -4325,6 +4338,7 @@ describe.skip("DiscordManager output configuration", () => { tool_result_max_length: 900, system_status: true, result_summary: true, + typing_indicator: true, errors: true, }, guilds: [], @@ -4490,6 +4504,7 @@ describe.skip("DiscordManager output configuration", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, }, guilds: [], @@ -4650,6 +4665,7 @@ describe.skip("DiscordManager output configuration", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: false, }, guilds: [], diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index 451a5be6..fd57eb86 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -390,6 +390,7 @@ export class DiscordManager implements IChatManager { system_status: true, result_summary: false, errors: true, + typing_indicator: true, }; // Get existing session for this channel (for conversation continuity) @@ -425,8 +426,8 @@ export class DiscordManager implements IChatManager { platformName: "Discord", }); - // Start typing indicator while processing - const stopTyping = event.startTyping(); + // Start typing indicator while processing (if not disabled via output.typing_indicator) + const stopTyping = outputConfig.typing_indicator !== false ? event.startTyping() : () => {}; // Track if we've stopped typing to avoid multiple calls let typingStopped = false; From 3b5c74904421bed6142c29e41e996fe20b17c93c Mon Sep 17 00:00:00 2001 From: agent Date: Sun, 1 Mar 2026 22:12:03 -0800 Subject: [PATCH 07/48] feat(discord,slack): message dedup, ack emoji, voice transcription, file upload Four Discord/Slack integration improvements: 1. Message deduplication (Discord + Slack) - Skip intermediate JSONL snapshots (stop_reason: null) that lack complete text content - Deliver only finalized snapshots, deduplicate by message.id - Fixes "completed the task but no response" fallback message 2. Acknowledgement emoji reaction (Discord) - React with configurable emoji (default: eyes) on message receipt - Remove reaction after response is sent - New output config: acknowledge_emoji 3. Voice message transcription (Discord) - Detect Discord voice messages via MessageFlags.IsVoiceMessage - Download audio, transcribe via OpenAI Whisper API - New config: chat.discord.voice (enabled, provider, api_key_env, model, language) - New module: voice-transcriber.ts 4. File upload / file sender MCP (Discord) - Add uploadFile() to DiscordConnector using AttachmentBuilder - Wire FileSenderContext + createFileSenderDef in Discord manager - Pass injectedMcpServers to trigger() so agents can send files back to the originating channel - Mirrors existing Slack file upload support Co-Authored-By: Claude Opus 4.6 --- .../core/src/config/__tests__/schema.test.ts | 2 + packages/core/src/config/index.ts | 2 + packages/core/src/config/schema.ts | 35 +++ .../src/__tests__/discord-connector.test.ts | 3 +- packages/discord/src/__tests__/logger.test.ts | 3 +- .../discord/src/__tests__/manager.test.ts | 233 +++++++++++++++++- packages/discord/src/discord-connector.ts | 77 ++++++ packages/discord/src/index.ts | 3 + packages/discord/src/manager.ts | 148 ++++++++++- packages/discord/src/types.ts | 33 +++ packages/discord/src/voice-transcriber.ts | 62 +++++ packages/slack/src/manager.ts | 37 ++- 12 files changed, 611 insertions(+), 27 deletions(-) create mode 100644 packages/discord/src/voice-transcriber.ts diff --git a/packages/core/src/config/__tests__/schema.test.ts b/packages/core/src/config/__tests__/schema.test.ts index 11a65a45..e10fef47 100644 --- a/packages/core/src/config/__tests__/schema.test.ts +++ b/packages/core/src/config/__tests__/schema.test.ts @@ -1710,6 +1710,8 @@ describe("AgentChatDiscordSchema", () => { system_status: true, result_summary: false, errors: true, + typing_indicator: true, + acknowledge_emoji: "👀", }); } }); diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index cd283f05..7d6731bf 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -109,6 +109,8 @@ export { type DiscordPresence, // Agent Chat Discord schemas DiscordPresenceSchema, + type DiscordVoice, + DiscordVoiceSchema, type Docker, DockerSchema, // Types diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 0c055e9f..e62c20e7 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -624,6 +624,38 @@ export const DiscordOutputSchema = ChatOutputSchema.extend({ result_summary: z.boolean().optional().default(false), /** Show typing indicator while the agent is processing (default: true) */ typing_indicator: z.boolean().optional().default(true), + /** Emoji to react with when a message is received (empty string to disable, default: "👀") */ + acknowledge_emoji: z.string().optional().default("👀"), +}); + +/** + * Discord voice message transcription configuration + * + * When enabled, voice messages sent in Discord text channels are + * downloaded and transcribed via a speech-to-text provider (currently OpenAI Whisper). + * The transcription is then used as the agent prompt. + * + * @example + * ```yaml + * voice: + * enabled: true + * provider: openai + * api_key_env: OPENAI_API_KEY + * model: whisper-1 + * language: en + * ``` + */ +export const DiscordVoiceSchema = z.object({ + /** Enable voice message transcription (default: false) */ + enabled: z.boolean().optional().default(false), + /** Transcription provider (default: "openai") */ + provider: z.enum(["openai"]).optional().default("openai"), + /** Environment variable name containing the API key (default: "OPENAI_API_KEY") */ + api_key_env: z.string().optional().default("OPENAI_API_KEY"), + /** Model to use for transcription (default: "whisper-1") */ + model: z.string().optional().default("whisper-1"), + /** Language hint for better transcription accuracy (ISO 639-1, e.g., "en") */ + language: z.string().optional(), }); /** @@ -671,6 +703,8 @@ export const AgentChatDiscordSchema = z.object({ guilds: z.array(DiscordGuildSchema), /** Global DM (direct message) configuration - applies to all DMs regardless of guild */ dm: ChatDMSchema.optional(), + /** Voice message transcription configuration */ + voice: DiscordVoiceSchema.optional(), }); // ============================================================================= @@ -1073,6 +1107,7 @@ export type DiscordPresence = z.infer; export type DiscordChannel = z.infer; export type DiscordGuild = z.infer; export type DiscordOutput = z.infer; +export type DiscordVoice = z.infer; export type AgentChatDiscord = z.infer; export type AgentChat = z.infer; // Agent Chat Slack types diff --git a/packages/discord/src/__tests__/discord-connector.test.ts b/packages/discord/src/__tests__/discord-connector.test.ts index c45c203d..7267313c 100644 --- a/packages/discord/src/__tests__/discord-connector.test.ts +++ b/packages/discord/src/__tests__/discord-connector.test.ts @@ -125,8 +125,9 @@ function createMockDiscordConfig(): AgentChatDiscord { tool_result_max_length: 900, system_status: true, result_summary: false, - typing_indicator: true, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", }, guilds: [ { diff --git a/packages/discord/src/__tests__/logger.test.ts b/packages/discord/src/__tests__/logger.test.ts index 4a93729c..cfb3c0c1 100644 --- a/packages/discord/src/__tests__/logger.test.ts +++ b/packages/discord/src/__tests__/logger.test.ts @@ -21,8 +21,9 @@ function createMockDiscordConfig(logLevel: DiscordLogLevel = "standard"): AgentC tool_result_max_length: 900, system_status: true, result_summary: false, - typing_indicator: true, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", }, guilds: [], }; diff --git a/packages/discord/src/__tests__/manager.test.ts b/packages/discord/src/__tests__/manager.test.ts index 68982c7e..da967ca5 100644 --- a/packages/discord/src/__tests__/manager.test.ts +++ b/packages/discord/src/__tests__/manager.test.ts @@ -178,8 +178,9 @@ describe("DiscordManager", () => { tool_result_max_length: 900, system_status: true, result_summary: false, - typing_indicator: true, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", }, guilds: [], }; @@ -405,6 +406,8 @@ describe("DiscordMessageEvent type", () => { console.log("Reply:", content); }, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; expect(event.agentName).toBe("test-agent"); @@ -433,6 +436,8 @@ describe("DiscordMessageEvent type", () => { }, reply: async () => {}, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; expect(event.metadata.guildId).toBeNull(); @@ -732,8 +737,9 @@ describe.skip("DiscordManager message handling", () => { tool_result_max_length: 900, system_status: true, result_summary: false, - typing_indicator: true, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", }, guilds: [], }), @@ -869,6 +875,8 @@ describe.skip("DiscordManager message handling", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; // Emit the message event @@ -920,6 +928,7 @@ describe.skip("DiscordManager message handling", () => { result_summary: false, typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", }, guilds: [], }), @@ -1024,6 +1033,8 @@ describe.skip("DiscordManager message handling", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; // Emit the message event @@ -1068,6 +1079,7 @@ describe.skip("DiscordManager message handling", () => { result_summary: false, typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", }, guilds: [], }), @@ -1172,6 +1184,8 @@ describe.skip("DiscordManager message handling", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; // Emit the message event @@ -1247,6 +1261,7 @@ describe.skip("DiscordManager message handling", () => { result_summary: false, typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", }, guilds: [], }), @@ -1348,6 +1363,8 @@ describe.skip("DiscordManager message handling", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; mockConnector.emit("message", messageEvent); @@ -1409,6 +1426,7 @@ describe.skip("DiscordManager message handling", () => { result_summary: false, typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", }, guilds: [], }), @@ -1510,6 +1528,8 @@ describe.skip("DiscordManager message handling", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; mockConnector.emit("message", messageEvent); @@ -1630,6 +1650,8 @@ describe.skip("DiscordManager message handling", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; // Emit the message event - this will trigger handleMessage which will fail @@ -1756,6 +1778,8 @@ describe.skip("DiscordManager message handling", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; // Emit the message event @@ -1830,6 +1854,8 @@ describe.skip("DiscordManager message handling", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; // Emit the message event @@ -1904,6 +1930,8 @@ describe.skip("DiscordManager message handling", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; // Emit the message event @@ -2476,8 +2504,9 @@ describe.skip("DiscordManager session integration", () => { tool_result_max_length: 900, system_status: true, result_summary: false, - typing_indicator: true, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", }, guilds: [], }), @@ -2571,6 +2600,8 @@ describe.skip("DiscordManager session integration", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; // Emit the message event @@ -2642,6 +2673,8 @@ describe.skip("DiscordManager session integration", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; // Emit the message event @@ -2715,6 +2748,8 @@ describe.skip("DiscordManager session integration", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; // Emit the message event @@ -2790,6 +2825,8 @@ describe.skip("DiscordManager session integration", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; // Emit the message event @@ -3164,8 +3201,9 @@ describe.skip("DiscordManager lifecycle", () => { tool_result_max_length: 900, system_status: true, result_summary: false, - typing_indicator: true, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", }, guilds: [], }), @@ -3264,6 +3302,8 @@ describe.skip("DiscordManager lifecycle", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; // Emit the message event @@ -3309,8 +3349,9 @@ describe.skip("DiscordManager lifecycle", () => { tool_result_max_length: 900, system_status: true, result_summary: false, - typing_indicator: true, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", }, guilds: [], }), @@ -3409,6 +3450,8 @@ describe.skip("DiscordManager lifecycle", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; // Emit the message event @@ -3615,6 +3658,8 @@ describe.skip("DiscordManager lifecycle", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; // Emit the message event @@ -3660,8 +3705,9 @@ describe.skip("DiscordManager lifecycle", () => { tool_result_max_length: 900, system_status: true, result_summary: false, - typing_indicator: true, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", }, guilds: [], }), @@ -3760,6 +3806,8 @@ describe.skip("DiscordManager lifecycle", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; // Emit the message event @@ -3850,8 +3898,9 @@ describe.skip("DiscordManager output configuration", () => { tool_result_max_length: 900, system_status: true, result_summary: false, - typing_indicator: true, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", }, guilds: [], }, @@ -3954,6 +4003,8 @@ describe.skip("DiscordManager output configuration", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; mockConnector.emit("message", messageEvent); @@ -4013,8 +4064,9 @@ describe.skip("DiscordManager output configuration", () => { tool_result_max_length: 900, system_status: true, result_summary: false, - typing_indicator: true, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", }, guilds: [], }, @@ -4117,6 +4169,8 @@ describe.skip("DiscordManager output configuration", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; mockConnector.emit("message", messageEvent); @@ -4175,8 +4229,9 @@ describe.skip("DiscordManager output configuration", () => { tool_result_max_length: 900, system_status: false, result_summary: false, - typing_indicator: true, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", }, guilds: [], }, @@ -4279,6 +4334,8 @@ describe.skip("DiscordManager output configuration", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; mockConnector.emit("message", messageEvent); @@ -4338,8 +4395,9 @@ describe.skip("DiscordManager output configuration", () => { tool_result_max_length: 900, system_status: true, result_summary: true, - typing_indicator: true, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", }, guilds: [], }, @@ -4442,6 +4500,8 @@ describe.skip("DiscordManager output configuration", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; mockConnector.emit("message", messageEvent); @@ -4504,8 +4564,9 @@ describe.skip("DiscordManager output configuration", () => { tool_result_max_length: 900, system_status: true, result_summary: false, - typing_indicator: true, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", }, guilds: [], }, @@ -4608,6 +4669,8 @@ describe.skip("DiscordManager output configuration", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; mockConnector.emit("message", messageEvent); @@ -4665,8 +4728,9 @@ describe.skip("DiscordManager output configuration", () => { tool_result_max_length: 900, system_status: true, result_summary: false, - typing_indicator: true, + typing_indicator: true, errors: false, + acknowledge_emoji: "eyes", }, guilds: [], }, @@ -4769,6 +4833,8 @@ describe.skip("DiscordManager output configuration", () => { }, reply: replyMock, startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), }; mockConnector.emit("message", messageEvent); @@ -4782,6 +4848,149 @@ describe.skip("DiscordManager output configuration", () => { expect(embedCalls.length).toBe(0); }, 10000); + describe("file sender MCP injection", () => { + it("passes injectedMcpServers to trigger when agent has working_directory", async () => { + const triggerMock = vi.fn().mockResolvedValue({ + jobId: "job-file", + agentName: "test", + scheduleName: null, + startedAt: new Date().toISOString(), + success: true, + }); + + const agentWithWorkDir = { + ...createDiscordAgent("file-agent", { + bot_token_env: "TEST_BOT_TOKEN", + session_expiry_hours: 24, + log_level: "standard", + output: { + tool_results: true, + tool_result_max_length: 900, + system_status: true, + result_summary: false, + typing_indicator: true, + errors: true, + acknowledge_emoji: "", + }, + guilds: [], + }), + working_directory: "/tmp/test-workspace", + } as ResolvedAgent; + + const config: ResolvedConfig = { + fleet: { name: "test-fleet" } as unknown as ResolvedConfig["fleet"], + agents: [agentWithWorkDir], + configPath: "/test/herdctl.yaml", + configDir: "/test", + }; + + const mockContext: FleetManagerContext = { + getConfig: () => config, + getStateDir: () => "/tmp/test-state", + getStateDirInfo: () => null, + getLogger: () => mockLogger, + getScheduler: () => null, + getStatus: () => "running", + getInitializedAt: () => "2024-01-01T00:00:00.000Z", + getStartedAt: () => "2024-01-01T00:00:01.000Z", + getStoppedAt: () => null, + getLastError: () => null, + getCheckInterval: () => 1000, + emit: vi.fn(), + getEmitter: () => new EventEmitter(), + trigger: triggerMock, + }; + + const manager = new DiscordManager(mockContext); + + const mockConnector = new EventEmitter() as EventEmitter & { + connect: ReturnType; + disconnect: ReturnType; + isConnected: ReturnType; + getState: ReturnType; + uploadFile: ReturnType; + agentName: string; + sessionManager: { + getSession: ReturnType; + setSession: ReturnType; + touchSession: ReturnType; + getActiveSessionCount: ReturnType; + }; + }; + mockConnector.connect = vi.fn().mockResolvedValue(undefined); + mockConnector.disconnect = vi.fn().mockResolvedValue(undefined); + mockConnector.isConnected = vi.fn().mockReturnValue(true); + mockConnector.getState = vi.fn().mockReturnValue({ + status: "connected", + connectedAt: "2024-01-01T00:00:00.000Z", + disconnectedAt: null, + reconnectAttempts: 0, + lastError: null, + botUser: { id: "bot1", username: "TestBot", discriminator: "0000" }, + rateLimits: { + totalCount: 0, + lastRateLimitAt: null, + isRateLimited: false, + currentResetTime: 0, + }, + messageStats: { received: 0, sent: 0, ignored: 0 }, + } satisfies DiscordConnectorState); + mockConnector.uploadFile = vi.fn().mockResolvedValue({ fileId: "file-123" }); + mockConnector.agentName = "file-agent"; + mockConnector.sessionManager = { + getSession: vi.fn().mockResolvedValue(null), + setSession: vi.fn().mockResolvedValue(undefined), + touchSession: vi.fn().mockResolvedValue(undefined), + getActiveSessionCount: vi.fn().mockResolvedValue(0), + }; + + // @ts-expect-error - accessing private property for testing + manager.connectors.set("file-agent", mockConnector); + // @ts-expect-error - accessing private property for testing + manager.initialized = true; + + await manager.start(); + + const messageEvent: DiscordMessageEvent = { + agentName: "file-agent", + prompt: "Send me the file", + context: { messages: [], wasMentioned: true, prompt: "Send me the file" }, + metadata: { + guildId: "guild1", + channelId: "channel1", + messageId: "msg1", + userId: "user1", + username: "TestUser", + wasMentioned: true, + mode: "mention", + }, + reply: vi.fn().mockResolvedValue(undefined), + startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), + }; + + mockConnector.emit("message", messageEvent); + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify trigger was called with injectedMcpServers + expect(triggerMock).toHaveBeenCalledWith( + "file-agent", + undefined, + expect.objectContaining({ + injectedMcpServers: expect.objectContaining({ + "herdctl-file-sender": expect.objectContaining({ + name: "herdctl-file-sender", + tools: expect.arrayContaining([ + expect.objectContaining({ name: "herdctl_send_file" }), + ]), + }), + }), + }), + ); + }, 10000); + }); + describe("buildToolEmbed with custom maxOutputChars", () => { it("respects custom maxOutputChars parameter", () => { const ctx = createMockContext(null); diff --git a/packages/discord/src/discord-connector.ts b/packages/discord/src/discord-connector.ts index a3cd5b27..0d25f276 100644 --- a/packages/discord/src/discord-connector.ts +++ b/packages/discord/src/discord-connector.ts @@ -11,6 +11,7 @@ import { type RateLimitData, RESTEvents } from "@discordjs/rest"; import type { IChatSessionManager } from "@herdctl/chat"; import type { AgentChatDiscord, AgentConfig } from "@herdctl/core"; import { + AttachmentBuilder, Client, type ClientOptions, type DMChannel, @@ -18,6 +19,7 @@ import { GatewayIntentBits, type Interaction, type Message, + MessageFlags, type NewsChannel, Partials, type TextChannel, @@ -40,6 +42,7 @@ import type { DiscordConnectorLogger, DiscordConnectorOptions, DiscordConnectorState, + DiscordFileUploadParams, DiscordReplyPayload, IDiscordConnector, } from "./types.js"; @@ -305,6 +308,37 @@ export class DiscordConnector extends EventEmitter implements IDiscordConnector }; } + // =========================================================================== + // File Upload + // =========================================================================== + + async uploadFile(params: DiscordFileUploadParams): Promise<{ fileId: string }> { + if (!this._client?.isReady()) { + throw new Error("Cannot upload file: not connected to Discord"); + } + + const channel = await this._client.channels.fetch(params.channelId); + if (!channel || !channel.isTextBased() || !("send" in channel)) { + throw new Error(`Channel ${params.channelId} is not a text channel`); + } + + const attachment = new AttachmentBuilder(params.fileBuffer, { name: params.filename }); + const sent = await (channel as TextChannel).send({ + content: params.message || undefined, + files: [attachment], + }); + + const fileId = sent.attachments.first()?.id ?? sent.id; + this._logger.info("File uploaded to Discord", { + fileId, + filename: params.filename, + channelId: params.channelId, + size: params.fileBuffer.length, + }); + + return { fileId }; + } + /** * Set up event handlers for the discord.js client */ @@ -681,6 +715,44 @@ export class DiscordConnector extends EventEmitter implements IDiscordConnector }; }; + // Detect voice messages (audio recordings in text channels) + // Discord sets the IsVoiceMessage flag (8192) on voice messages + const isVoiceMessage = message.flags?.has(MessageFlags.IsVoiceMessage) ?? false; + let voiceAttachmentUrl: string | undefined; + let voiceAttachmentName: string | undefined; + if (isVoiceMessage) { + const voiceAttachment = message.attachments.first(); + if (voiceAttachment) { + voiceAttachmentUrl = voiceAttachment.url; + voiceAttachmentName = voiceAttachment.name; + } + } + + // Create reaction functions for acknowledgement emoji support + const addReaction = async (emoji: string): Promise => { + try { + await message.react(emoji); + } catch (err) { + this._logger.debug("Failed to add reaction", { + emoji, + error: err instanceof Error ? err.message : String(err), + }); + } + }; + + const removeReaction = async (emoji: string): Promise => { + try { + const reaction = message.reactions.cache.get(emoji); + if (reaction && this._botUser?.id) { + await reaction.users.remove(this._botUser.id); + } + } catch (err) { + this._logger.debug("Failed to remove reaction", { + error: err instanceof Error ? err.message : String(err), + }); + } + }; + // Emit message event const payload: DiscordConnectorEventMap["message"] = { agentName: this.agentName, @@ -694,9 +766,14 @@ export class DiscordConnector extends EventEmitter implements IDiscordConnector username: message.author.username, wasMentioned: context.wasMentioned, mode, + isVoiceMessage, + voiceAttachmentUrl, + voiceAttachmentName, }, reply, startTyping, + addReaction, + removeReaction, }; this.emit("message", payload); } diff --git a/packages/discord/src/index.ts b/packages/discord/src/index.ts index 933d9f9f..e4ba3e52 100644 --- a/packages/discord/src/index.ts +++ b/packages/discord/src/index.ts @@ -108,3 +108,6 @@ export { sendWithTyping, startTypingIndicator, } from "./utils/index.js"; +// Voice transcription +export type { TranscribeOptions, TranscribeResult } from "./voice-transcriber.js"; +export { transcribeAudio } from "./voice-transcriber.js"; diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index fd57eb86..0c8f497b 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -21,11 +21,14 @@ import type { ChatManagerConnectorState, FleetManagerContext, IChatManager, + InjectedMcpServerDef, ResolvedAgent, } from "@herdctl/core"; import { + createFileSenderDef, extractToolResults, extractToolUseBlocks, + type FileSenderContext, getToolInputSummary, TOOL_EMOJIS, } from "@herdctl/core"; @@ -36,6 +39,7 @@ import type { DiscordReplyEmbed, DiscordReplyEmbedField, } from "./types.js"; +import { transcribeAudio } from "./voice-transcriber.js"; // ============================================================================= // Discord Manager @@ -391,6 +395,7 @@ export class DiscordManager implements IChatManager { result_summary: false, errors: true, typing_indicator: true, + acknowledge_emoji: "👀", }; // Get existing session for this channel (for conversation continuity) @@ -416,6 +421,26 @@ export class DiscordManager implements IChatManager { } } + // Create file sender definition for this message context + let injectedMcpServers: Record | undefined; + const workingDir = this.resolveWorkingDirectory(agent); + if (connector && workingDir) { + const agentConnector = connector; + const fileSenderContext: FileSenderContext = { + workingDirectory: workingDir, + uploadFile: async (params) => { + return agentConnector.uploadFile({ + channelId: event.metadata.channelId, + fileBuffer: params.fileBuffer, + filename: params.filename, + message: params.message, + }); + }, + }; + const fileSenderDef = createFileSenderDef(fileSenderContext); + injectedMcpServers = { [fileSenderDef.name]: fileSenderDef }; + } + // Create streaming responder for incremental message delivery const streamer = new StreamingResponder({ reply: (content: string) => event.reply(content), @@ -432,7 +457,67 @@ export class DiscordManager implements IChatManager { // Track if we've stopped typing to avoid multiple calls let typingStopped = false; + // Add acknowledgement reaction if configured + const ackEmoji = outputConfig.acknowledge_emoji; + if (ackEmoji) { + await event.addReaction(ackEmoji); + } + try { + // Handle voice messages: transcribe audio before triggering the agent + let prompt = event.prompt; + const voiceConfig = agent.chat?.discord?.voice; + if (event.metadata.isVoiceMessage) { + if (!voiceConfig?.enabled) { + await event.reply( + "Voice messages are not enabled for this agent. Please send a text message instead.", + ); + return; + } + + const apiKey = process.env[voiceConfig.api_key_env ?? "OPENAI_API_KEY"]; + if (!apiKey) { + logger.error( + `Voice transcription API key not found in env var '${voiceConfig.api_key_env}'`, + ); + await event.reply( + "Voice transcription is misconfigured. Please contact an administrator.", + ); + return; + } + + if (!event.metadata.voiceAttachmentUrl) { + await event.reply("Could not find audio attachment in voice message."); + return; + } + + try { + logger.debug("Downloading voice message audio..."); + const audioResponse = await fetch(event.metadata.voiceAttachmentUrl); + if (!audioResponse.ok) { + throw new Error(`Failed to download audio: ${audioResponse.status}`); + } + const audioBuffer = Buffer.from(await audioResponse.arrayBuffer()); + const filename = event.metadata.voiceAttachmentName ?? "voice-message.ogg"; + + logger.debug("Transcribing voice message..."); + const transcription = await transcribeAudio(audioBuffer, filename, { + apiKey, + model: voiceConfig.model, + language: voiceConfig.language, + }); + + prompt = transcription.text; + logger.info(`Voice message transcribed: "${prompt.substring(0, 80)}..."`); + } catch (transcribeError) { + const errMsg = + transcribeError instanceof Error ? transcribeError.message : String(transcribeError); + logger.error(`Voice transcription failed: ${errMsg}`); + await event.reply(`Failed to transcribe voice message: ${errMsg}`); + return; + } + } + // Track pending tool_use blocks so we can pair them with results const pendingToolUses = new Map< string, @@ -440,26 +525,28 @@ export class DiscordManager implements IChatManager { >(); let embedsSent = 0; + // Deduplicate assistant messages by finalized snapshot. Claude Code can emit + // intermediate snapshots (stop_reason: null) before the final assistant message. + // We skip intermediates and deliver the first finalized snapshot per message.id. + const deliveredAssistantIds = new Set(); + // Execute job via FleetManager.trigger() through the context // Pass resume option for conversation continuity // The onMessage callback streams output incrementally to Discord const result = await this.ctx.trigger(qualifiedName, undefined, { triggerType: "discord", - prompt: event.prompt, + prompt, resume: existingSessionId, + injectedMcpServers, onMessage: async (message) => { // Extract text content from assistant messages and stream to Discord if (message.type === "assistant") { // Cast to the SDKMessage shape expected by extractMessageContent // The chat package's SDKMessage type expects a specific structure const sdkMessage = message as unknown as Parameters[0]; - const content = extractMessageContent(sdkMessage); - if (content) { - // Each assistant message is a complete turn - send immediately - await streamer.addMessageAndSend(content); - } - // Track tool_use blocks for pairing with results later + // Always track tool_use blocks (even from duplicate messages) + // so tool results can be paired correctly const toolUseBlocks = extractToolUseBlocks(sdkMessage); for (const block of toolUseBlocks) { if (block.id) { @@ -470,6 +557,30 @@ export class DiscordManager implements IChatManager { }); } } + + // Deduplicate assistant messages by message.id. + // Claude Code emits multiple JSONL lines per turn with the same id: + // intermediate snapshots (stop_reason: null) may lack text content, + // while the final (stop_reason: "end_turn") has the complete response. + // Skip intermediates, deliver and deduplicate finals. + const messageId = (message as { message?: { id?: string } }).message?.id; + const stopReason = (message as { message?: { stop_reason?: unknown } }).message + ?.stop_reason; + if (messageId && stopReason === null) { + return; // Skip intermediate snapshot — text may be incomplete + } + if (messageId) { + if (deliveredAssistantIds.has(messageId)) { + return; + } + deliveredAssistantIds.add(messageId); + } + + const content = extractMessageContent(sdkMessage); + if (content) { + // Each assistant message is a complete turn - send immediately + await streamer.addMessageAndSend(content); + } } // Build and send embeds for tool results @@ -695,6 +806,10 @@ export class DiscordManager implements IChatManager { if (!typingStopped) { stopTyping(); } + // Remove acknowledgement reaction now that processing is complete + if (ackEmoji) { + await event.removeReaction(ackEmoji); + } } } @@ -868,4 +983,23 @@ export class DiscordManager implements IChatManager { await reply(chunk); } } + + // =========================================================================== + // Utility Methods + // =========================================================================== + + /** + * Resolve the agent's working directory to an absolute path string + */ + private resolveWorkingDirectory(agent: ResolvedAgent): string | undefined { + if (!agent.working_directory) { + return undefined; + } + + if (typeof agent.working_directory === "string") { + return agent.working_directory; + } + + return agent.working_directory.root; + } } diff --git a/packages/discord/src/types.ts b/packages/discord/src/types.ts index 4cfe3d72..4da7814b 100644 --- a/packages/discord/src/types.ts +++ b/packages/discord/src/types.ts @@ -176,6 +176,24 @@ export interface DiscordConnectorState { }; } +// ============================================================================= +// File Upload Types +// ============================================================================= + +/** + * Parameters for uploading a file to a Discord channel + */ +export interface DiscordFileUploadParams { + /** Channel ID to upload to */ + channelId: string; + /** File contents */ + fileBuffer: Buffer; + /** Filename for the upload */ + filename: string; + /** Optional message to accompany the file */ + message?: string; +} + // ============================================================================= // Connector Interface // ============================================================================= @@ -219,6 +237,11 @@ export interface IDiscordConnector { */ getState(): DiscordConnectorState; + /** + * Upload a file to a Discord channel + */ + uploadFile(params: DiscordFileUploadParams): Promise<{ fileId: string }>; + /** * Name of the agent this connector is for */ @@ -337,6 +360,12 @@ export interface DiscordConnectorEventMap { wasMentioned: boolean; /** Channel mode that was applied */ mode: "mention" | "auto"; + /** Whether this message is a voice message (audio recording in text channel) */ + isVoiceMessage?: boolean; + /** URL to download the voice message audio attachment */ + voiceAttachmentUrl?: string; + /** Filename of the voice message attachment */ + voiceAttachmentName?: string; }; /** Function to send a reply in the same channel (text or embed) */ reply: (content: string | DiscordReplyPayload) => Promise; @@ -346,6 +375,10 @@ export interface DiscordConnectorEventMap { * The indicator auto-refreshes every 8 seconds until stopped. */ startTyping: () => () => void; + /** Add a Unicode emoji reaction to the user's message */ + addReaction: (emoji: string) => Promise; + /** Remove the bot's reaction from the user's message */ + removeReaction: (emoji: string) => Promise; }; /** diff --git a/packages/discord/src/voice-transcriber.ts b/packages/discord/src/voice-transcriber.ts new file mode 100644 index 00000000..aa1b1edb --- /dev/null +++ b/packages/discord/src/voice-transcriber.ts @@ -0,0 +1,62 @@ +/** + * Voice message transcription using OpenAI Whisper API + * + * Transcribes audio buffers to text using the OpenAI audio transcription endpoint. + * Uses native fetch + FormData (Node 18+), no additional npm dependencies required. + * + * @module voice-transcriber + */ + +export interface TranscribeOptions { + /** OpenAI API key */ + apiKey: string; + /** Whisper model to use (default: "whisper-1") */ + model?: string; + /** Language hint for better accuracy (ISO 639-1, e.g., "en") */ + language?: string; +} + +export interface TranscribeResult { + /** The transcribed text */ + text: string; +} + +/** + * Transcribe an audio buffer using the OpenAI Whisper API + * + * @param audioBuffer - The audio file contents as a Buffer + * @param filename - Original filename (used for content-type detection) + * @param options - Transcription options including API key and model + * @returns The transcription result with text + * @throws Error if the API call fails + */ +export async function transcribeAudio( + audioBuffer: Buffer, + filename: string, + options: TranscribeOptions, +): Promise { + const model = options.model ?? "whisper-1"; + + const formData = new FormData(); + formData.append("file", new Blob([audioBuffer]), filename); + formData.append("model", model); + if (options.language) { + formData.append("language", options.language); + } + + const response = await fetch("https://api.openai.com/v1/audio/transcriptions", { + method: "POST", + headers: { + Authorization: `Bearer ${options.apiKey}`, + }, + body: formData, + }); + + if (!response.ok) { + const errorBody = await response.text().catch(() => "unknown error"); + throw new Error(`OpenAI Whisper API error (${response.status}): ${errorBody}`); + } + + const result = (await response.json()) as { text: string }; + return { text: result.text }; +} diff --git a/packages/slack/src/manager.ts b/packages/slack/src/manager.ts index a8679b64..e8af6b03 100644 --- a/packages/slack/src/manager.ts +++ b/packages/slack/src/manager.ts @@ -474,6 +474,11 @@ export class SlackManager implements IChatManager { { name: string; input?: unknown; startTime: number } >(); + // Deduplicate assistant messages by finalized snapshot. Claude Code can emit + // intermediate snapshots (stop_reason: null) before the final assistant message. + // We skip intermediates and deliver the first finalized snapshot per message.id. + const deliveredAssistantIds = new Set(); + // Execute job via FleetManager.trigger() through the context // Pass resume option for conversation continuity // The onMessage callback streams output incrementally to Slack @@ -487,13 +492,9 @@ export class SlackManager implements IChatManager { if (message.type === "assistant") { // Cast to the SDKMessage shape expected by extractMessageContent const sdkMessage = message as unknown as Parameters[0]; - const content = extractMessageContent(sdkMessage); - if (content) { - // Each assistant message is a complete turn - send immediately - await streamer.addMessageAndSend(content); - } - // Track tool_use blocks for pairing with results later + // Always track tool_use blocks (even from duplicate messages) + // so tool results can be paired correctly const toolUseBlocks = extractToolUseBlocks(sdkMessage); for (const block of toolUseBlocks) { if (block.id) { @@ -504,6 +505,30 @@ export class SlackManager implements IChatManager { }); } } + + // Deduplicate assistant messages by message.id. + // Claude Code emits multiple JSONL lines per turn with the same id: + // intermediate snapshots (stop_reason: null) may lack text content, + // while the final (stop_reason: "end_turn") has the complete response. + // Skip intermediates, deliver and deduplicate finals. + const messageId = (message as { message?: { id?: string } }).message?.id; + const stopReason = (message as { message?: { stop_reason?: unknown } }).message + ?.stop_reason; + if (messageId && stopReason === null) { + return; // Skip intermediate snapshot — text may be incomplete + } + if (messageId) { + if (deliveredAssistantIds.has(messageId)) { + return; + } + deliveredAssistantIds.add(messageId); + } + + const content = extractMessageContent(sdkMessage); + if (content) { + // Each assistant message is a complete turn - send immediately + await streamer.addMessageAndSend(content); + } } // Send tool results as Slack messages From 8ec60b549da598d86efa28ba1b8e2d391e7210bc Mon Sep 17 00:00:00 2001 From: agent Date: Mon, 2 Mar 2026 06:28:33 -0800 Subject: [PATCH 08/48] feat(discord): reduce verbosity with final-answer-only mode, concise prompts, and progress indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord conversations with Claude Code agents are verbose — they narrate tool usage, emit intermediate reasoning, and produce full diffs. This adds three features that work together to make the Discord experience feel like texting a coworker: - **concise_mode** (default: true): Appends a system prompt telling the agent to skip narration and give only its final answer. Threaded through a new `systemPromptAppend` field on TriggerOptions → RunnerOptions → RuntimeExecuteOptions, applied in both SDK (preset append) and CLI (--system-prompt) runtimes. - **final_answer_only** (default: true): Buffers all assistant messages during execution and sends only the last one (the actual answer). Suppresses tool result embeds and system status embeds in this mode. - **progress_indicator** (default: true): Shows a single "Working..." embed that updates in-place with tool names as they run, throttled to 2s intervals. Deleted on job completion. Powered by a new `replyWithRef` function on the Discord connector that returns edit/delete handles. All three options default to the new quiet behavior. Set to `false` in agent config to restore verbose output. Slack, Web, and existing configs are unaffected. Co-Authored-By: Claude Opus 4.6 --- .../core/src/config/__tests__/schema.test.ts | 3 + packages/core/src/config/schema.ts | 6 + .../core/src/fleet-manager/job-control.ts | 1 + packages/core/src/fleet-manager/types.ts | 8 + packages/core/src/runner/job-executor.ts | 1 + .../core/src/runner/runtime/cli-runtime.ts | 11 +- packages/core/src/runner/runtime/interface.ts | 3 + .../core/src/runner/runtime/sdk-runtime.ts | 19 +++ packages/core/src/runner/types.ts | 2 + .../src/__tests__/discord-connector.test.ts | 3 + packages/discord/src/__tests__/logger.test.ts | 3 + .../discord/src/__tests__/manager.test.ts | 155 ++++++++++++++++++ packages/discord/src/discord-connector.ts | 21 +++ packages/discord/src/manager.ts | 111 ++++++++++++- packages/discord/src/types.ts | 8 + 15 files changed, 349 insertions(+), 6 deletions(-) diff --git a/packages/core/src/config/__tests__/schema.test.ts b/packages/core/src/config/__tests__/schema.test.ts index e10fef47..6f7bec89 100644 --- a/packages/core/src/config/__tests__/schema.test.ts +++ b/packages/core/src/config/__tests__/schema.test.ts @@ -1712,6 +1712,9 @@ describe("AgentChatDiscordSchema", () => { errors: true, typing_indicator: true, acknowledge_emoji: "👀", + final_answer_only: true, + progress_indicator: true, + concise_mode: true, }); } }); diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index e62c20e7..71b0ce28 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -626,6 +626,12 @@ export const DiscordOutputSchema = ChatOutputSchema.extend({ typing_indicator: z.boolean().optional().default(true), /** Emoji to react with when a message is received (empty string to disable, default: "👀") */ acknowledge_emoji: z.string().optional().default("👀"), + /** Only send the final assistant message, not intermediate turns (default: true) */ + final_answer_only: z.boolean().optional().default(true), + /** Show a progress indicator embed with tool names while working (default: true) */ + progress_indicator: z.boolean().optional().default(true), + /** Inject concise-mode system prompt to reduce verbosity (default: true) */ + concise_mode: z.boolean().optional().default(true), }); /** diff --git a/packages/core/src/fleet-manager/job-control.ts b/packages/core/src/fleet-manager/job-control.ts index 34045c62..56173d42 100644 --- a/packages/core/src/fleet-manager/job-control.ts +++ b/packages/core/src/fleet-manager/job-control.ts @@ -165,6 +165,7 @@ export class JobControl { onMessage: options?.onMessage, resume: sessionId, injectedMcpServers: options?.injectedMcpServers, + systemPromptAppend: options?.systemPromptAppend, }); // Emit job:created event diff --git a/packages/core/src/fleet-manager/types.ts b/packages/core/src/fleet-manager/types.ts index bceff8aa..fd52d5de 100644 --- a/packages/core/src/fleet-manager/types.ts +++ b/packages/core/src/fleet-manager/types.ts @@ -588,6 +588,14 @@ export interface TriggerOptions { * - ContainerRunner: HTTP MCP bridge over Docker network */ injectedMcpServers?: Record; + + /** + * Text to append to the agent's system prompt for this trigger + * + * Used by chat connectors to inject platform-specific instructions + * (e.g., telling the agent to be concise on Discord). + */ + systemPromptAppend?: string; } /** diff --git a/packages/core/src/runner/job-executor.ts b/packages/core/src/runner/job-executor.ts index 878a190f..d709ac61 100644 --- a/packages/core/src/runner/job-executor.ts +++ b/packages/core/src/runner/job-executor.ts @@ -329,6 +329,7 @@ export class JobExecutor { fork: options.fork ? true : undefined, abortController: options.abortController, injectedMcpServers: options.injectedMcpServers, + systemPromptAppend: options.systemPromptAppend, }); } catch (initError) { // Wrap initialization errors with context diff --git a/packages/core/src/runner/runtime/cli-runtime.ts b/packages/core/src/runner/runtime/cli-runtime.ts index 43471771..2d63401e 100644 --- a/packages/core/src/runner/runtime/cli-runtime.ts +++ b/packages/core/src/runner/runtime/cli-runtime.ts @@ -146,9 +146,16 @@ export class CLIRuntime implements RuntimeInterface { args.push("--model", options.agent.model); } - // Add system prompt if specified - if (options.agent.system_prompt) { + // Add system prompt if specified, with optional append for chat platforms + if (options.agent.system_prompt && options.systemPromptAppend) { + args.push( + "--system-prompt", + options.agent.system_prompt + "\n\n" + options.systemPromptAppend, + ); + } else if (options.agent.system_prompt) { args.push("--system-prompt", options.agent.system_prompt); + } else if (options.systemPromptAppend) { + args.push("--system-prompt", options.systemPromptAppend); } // Add allowed tools if specified (direct passthrough to CLI) diff --git a/packages/core/src/runner/runtime/interface.ts b/packages/core/src/runner/runtime/interface.ts index 050627fb..1a4e9502 100644 --- a/packages/core/src/runner/runtime/interface.ts +++ b/packages/core/src/runner/runtime/interface.ts @@ -33,6 +33,9 @@ export interface RuntimeExecuteOptions { /** MCP servers to inject at runtime (SDK and Docker runtimes) */ injectedMcpServers?: Record; + + /** Text to append to the agent's system prompt for this run */ + systemPromptAppend?: string; } /** diff --git a/packages/core/src/runner/runtime/sdk-runtime.ts b/packages/core/src/runner/runtime/sdk-runtime.ts index 4329e11f..aa58730d 100644 --- a/packages/core/src/runner/runtime/sdk-runtime.ts +++ b/packages/core/src/runner/runtime/sdk-runtime.ts @@ -111,6 +111,25 @@ export class SDKRuntime implements RuntimeInterface { fork: options.fork, }); + // Apply system prompt append if provided (e.g., concise mode for chat platforms) + if (options.systemPromptAppend) { + const current = sdkOptions.systemPrompt; + if (typeof current === "string") { + sdkOptions.systemPrompt = current + "\n\n" + options.systemPromptAppend; + } else if (current && typeof current === "object" && current.type === "preset") { + sdkOptions.systemPrompt = { + ...current, + append: (current.append ? current.append + "\n\n" : "") + options.systemPromptAppend, + }; + } else { + sdkOptions.systemPrompt = { + type: "preset", + preset: "claude_code", + append: options.systemPromptAppend, + }; + } + } + // Convert injected MCP server defs to in-process SDK MCP servers if (options.injectedMcpServers && Object.keys(options.injectedMcpServers).length > 0) { const configServers = sdkOptions.mcpServers ?? {}; diff --git a/packages/core/src/runner/types.ts b/packages/core/src/runner/types.ts index 025d8196..da6b47db 100644 --- a/packages/core/src/runner/types.ts +++ b/packages/core/src/runner/types.ts @@ -37,6 +37,8 @@ export interface RunnerOptions { abortController?: AbortController; /** MCP servers to inject at runtime (SDK and Docker runtimes) */ injectedMcpServers?: Record; + /** Text to append to the agent's system prompt for this run */ + systemPromptAppend?: string; } /** diff --git a/packages/discord/src/__tests__/discord-connector.test.ts b/packages/discord/src/__tests__/discord-connector.test.ts index 7267313c..08525717 100644 --- a/packages/discord/src/__tests__/discord-connector.test.ts +++ b/packages/discord/src/__tests__/discord-connector.test.ts @@ -128,6 +128,9 @@ function createMockDiscordConfig(): AgentChatDiscord { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", + final_answer_only: true, + progress_indicator: true, + concise_mode: true, }, guilds: [ { diff --git a/packages/discord/src/__tests__/logger.test.ts b/packages/discord/src/__tests__/logger.test.ts index cfb3c0c1..6f6fbf9b 100644 --- a/packages/discord/src/__tests__/logger.test.ts +++ b/packages/discord/src/__tests__/logger.test.ts @@ -24,6 +24,9 @@ function createMockDiscordConfig(logLevel: DiscordLogLevel = "standard"): AgentC typing_indicator: true, errors: true, acknowledge_emoji: "eyes", + final_answer_only: true, + progress_indicator: true, + concise_mode: true, }, guilds: [], }; diff --git a/packages/discord/src/__tests__/manager.test.ts b/packages/discord/src/__tests__/manager.test.ts index da967ca5..7e3af2fd 100644 --- a/packages/discord/src/__tests__/manager.test.ts +++ b/packages/discord/src/__tests__/manager.test.ts @@ -181,6 +181,9 @@ describe("DiscordManager", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", + final_answer_only: true, + progress_indicator: true, + concise_mode: true, }, guilds: [], }; @@ -408,6 +411,10 @@ describe("DiscordMessageEvent type", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; expect(event.agentName).toBe("test-agent"); @@ -438,6 +445,10 @@ describe("DiscordMessageEvent type", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; expect(event.metadata.guildId).toBeNull(); @@ -740,6 +751,9 @@ describe.skip("DiscordManager message handling", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", + final_answer_only: true, + progress_indicator: true, + concise_mode: true, }, guilds: [], }), @@ -877,6 +891,10 @@ describe.skip("DiscordManager message handling", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; // Emit the message event @@ -929,6 +947,9 @@ describe.skip("DiscordManager message handling", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", + final_answer_only: true, + progress_indicator: true, + concise_mode: true, }, guilds: [], }), @@ -1035,6 +1056,10 @@ describe.skip("DiscordManager message handling", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; // Emit the message event @@ -1080,6 +1105,9 @@ describe.skip("DiscordManager message handling", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", + final_answer_only: true, + progress_indicator: true, + concise_mode: true, }, guilds: [], }), @@ -1186,6 +1214,10 @@ describe.skip("DiscordManager message handling", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; // Emit the message event @@ -1262,6 +1294,9 @@ describe.skip("DiscordManager message handling", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", + final_answer_only: true, + progress_indicator: true, + concise_mode: true, }, guilds: [], }), @@ -1365,6 +1400,10 @@ describe.skip("DiscordManager message handling", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; mockConnector.emit("message", messageEvent); @@ -1427,6 +1466,9 @@ describe.skip("DiscordManager message handling", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", + final_answer_only: true, + progress_indicator: true, + concise_mode: true, }, guilds: [], }), @@ -1530,6 +1572,10 @@ describe.skip("DiscordManager message handling", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; mockConnector.emit("message", messageEvent); @@ -1652,6 +1698,10 @@ describe.skip("DiscordManager message handling", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; // Emit the message event - this will trigger handleMessage which will fail @@ -1780,6 +1830,10 @@ describe.skip("DiscordManager message handling", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; // Emit the message event @@ -1856,6 +1910,10 @@ describe.skip("DiscordManager message handling", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; // Emit the message event @@ -1932,6 +1990,10 @@ describe.skip("DiscordManager message handling", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; // Emit the message event @@ -2507,6 +2569,9 @@ describe.skip("DiscordManager session integration", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", + final_answer_only: true, + progress_indicator: true, + concise_mode: true, }, guilds: [], }), @@ -2602,6 +2667,10 @@ describe.skip("DiscordManager session integration", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; // Emit the message event @@ -2675,6 +2744,10 @@ describe.skip("DiscordManager session integration", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; // Emit the message event @@ -2750,6 +2823,10 @@ describe.skip("DiscordManager session integration", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; // Emit the message event @@ -2827,6 +2904,10 @@ describe.skip("DiscordManager session integration", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; // Emit the message event @@ -3204,6 +3285,9 @@ describe.skip("DiscordManager lifecycle", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", + final_answer_only: true, + progress_indicator: true, + concise_mode: true, }, guilds: [], }), @@ -3304,6 +3388,10 @@ describe.skip("DiscordManager lifecycle", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; // Emit the message event @@ -3352,6 +3440,9 @@ describe.skip("DiscordManager lifecycle", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", + final_answer_only: true, + progress_indicator: true, + concise_mode: true, }, guilds: [], }), @@ -3452,6 +3543,10 @@ describe.skip("DiscordManager lifecycle", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; // Emit the message event @@ -3660,6 +3755,10 @@ describe.skip("DiscordManager lifecycle", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; // Emit the message event @@ -3708,6 +3807,9 @@ describe.skip("DiscordManager lifecycle", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", + final_answer_only: true, + progress_indicator: true, + concise_mode: true, }, guilds: [], }), @@ -3808,6 +3910,10 @@ describe.skip("DiscordManager lifecycle", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; // Emit the message event @@ -3901,6 +4007,9 @@ describe.skip("DiscordManager output configuration", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", + final_answer_only: true, + progress_indicator: true, + concise_mode: true, }, guilds: [], }, @@ -4005,6 +4114,10 @@ describe.skip("DiscordManager output configuration", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; mockConnector.emit("message", messageEvent); @@ -4067,6 +4180,9 @@ describe.skip("DiscordManager output configuration", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", + final_answer_only: true, + progress_indicator: true, + concise_mode: true, }, guilds: [], }, @@ -4171,6 +4287,10 @@ describe.skip("DiscordManager output configuration", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; mockConnector.emit("message", messageEvent); @@ -4232,6 +4352,9 @@ describe.skip("DiscordManager output configuration", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", + final_answer_only: true, + progress_indicator: true, + concise_mode: true, }, guilds: [], }, @@ -4336,6 +4459,10 @@ describe.skip("DiscordManager output configuration", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; mockConnector.emit("message", messageEvent); @@ -4398,6 +4525,9 @@ describe.skip("DiscordManager output configuration", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", + final_answer_only: true, + progress_indicator: true, + concise_mode: true, }, guilds: [], }, @@ -4502,6 +4632,10 @@ describe.skip("DiscordManager output configuration", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; mockConnector.emit("message", messageEvent); @@ -4567,6 +4701,9 @@ describe.skip("DiscordManager output configuration", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", + final_answer_only: true, + progress_indicator: true, + concise_mode: true, }, guilds: [], }, @@ -4671,6 +4808,10 @@ describe.skip("DiscordManager output configuration", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; mockConnector.emit("message", messageEvent); @@ -4731,6 +4872,9 @@ describe.skip("DiscordManager output configuration", () => { typing_indicator: true, errors: false, acknowledge_emoji: "eyes", + final_answer_only: true, + progress_indicator: true, + concise_mode: true, }, guilds: [], }, @@ -4835,6 +4979,10 @@ describe.skip("DiscordManager output configuration", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; mockConnector.emit("message", messageEvent); @@ -4871,6 +5019,9 @@ describe.skip("DiscordManager output configuration", () => { typing_indicator: true, errors: true, acknowledge_emoji: "", + final_answer_only: true, + progress_indicator: true, + concise_mode: true, }, guilds: [], }), @@ -4968,6 +5119,10 @@ describe.skip("DiscordManager output configuration", () => { startTyping: () => () => {}, addReaction: vi.fn().mockResolvedValue(undefined), removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), }; mockConnector.emit("message", messageEvent); diff --git a/packages/discord/src/discord-connector.ts b/packages/discord/src/discord-connector.ts index 0d25f276..0405b3c8 100644 --- a/packages/discord/src/discord-connector.ts +++ b/packages/discord/src/discord-connector.ts @@ -688,6 +688,26 @@ export class DiscordConnector extends EventEmitter implements IDiscordConnector }); }; + // Create reply-with-reference function for editable messages (progress embeds) + const replyWithRef = async ( + content: string | DiscordReplyPayload, + ): Promise<{ + edit: (c: string | DiscordReplyPayload) => Promise; + delete: () => Promise; + }> => { + const textChannel = channel as TextChannel | DMChannel | NewsChannel | ThreadChannel; + const sentMessage = await textChannel.send(content as Parameters[0]); + this._messagesSent++; + return { + edit: async (newContent: string | DiscordReplyPayload) => { + await sentMessage.edit(newContent as Parameters[0]); + }, + delete: async () => { + await sentMessage.delete(); + }, + }; + }; + // Create typing indicator function // Returns a stop function that should be called when done const startTyping = (): (() => void) => { @@ -771,6 +791,7 @@ export class DiscordConnector extends EventEmitter implements IDiscordConnector voiceAttachmentName, }, reply, + replyWithRef, startTyping, addReaction, removeReaction, diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index 0c8f497b..b2a5d9be 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -38,9 +38,25 @@ import type { DiscordConnectorEventMap, DiscordReplyEmbed, DiscordReplyEmbedField, + DiscordReplyPayload, } from "./types.js"; import { transcribeAudio } from "./voice-transcriber.js"; +// ============================================================================= +// Constants +// ============================================================================= + +/** + * System prompt appended for Discord conversations to reduce verbosity. + * Injected when concise_mode is enabled (default: true). + */ +const DISCORD_SYSTEM_PROMPT_APPEND = `You are responding in a Discord chat. Follow these rules strictly: +- Give ONLY your final answer. Do NOT narrate your actions ("Let me check...", "I'll read...") +- Do NOT describe which tools you are using or what you found in intermediate steps +- Do NOT show file diffs or large code blocks unless the user explicitly asked to see code +- Keep responses concise — prefer summaries over exhaustive detail +- The user cannot see your tool calls, so never reference them`; + // ============================================================================= // Discord Manager // ============================================================================= @@ -396,8 +412,16 @@ export class DiscordManager implements IChatManager { errors: true, typing_indicator: true, acknowledge_emoji: "👀", + final_answer_only: true, + progress_indicator: true, + concise_mode: true, }; + // Resolve new output modes (default to true for quiet Discord experience) + const finalAnswerOnly = outputConfig.final_answer_only !== false; + const showProgressIndicator = outputConfig.progress_indicator !== false && finalAnswerOnly; + const conciseMode = outputConfig.concise_mode !== false; + // Get existing session for this channel (for conversation continuity) const connector = this.connectors.get(qualifiedName); let existingSessionId: string | undefined; @@ -530,6 +554,23 @@ export class DiscordManager implements IChatManager { // We skip intermediates and deliver the first finalized snapshot per message.id. const deliveredAssistantIds = new Set(); + // Final-answer-only mode: buffer assistant messages and send only the last one + const bufferedAssistantMessages: string[] = []; + + // Progress indicator: track tool names for in-place-updating embed + interface ProgressEmbedHandle { + edit: (content: string | DiscordReplyPayload) => Promise; + delete: () => Promise; + } + const toolNamesRun: string[] = []; + let progressEmbedHandle: ProgressEmbedHandle | null = null; + let lastProgressUpdate = 0; + + // Effective tool_results setting: suppress in final_answer_only mode unless explicitly configured + const showToolResults = finalAnswerOnly + ? agent.chat?.discord?.output?.tool_results === true + : outputConfig.tool_results; + // Execute job via FleetManager.trigger() through the context // Pass resume option for conversation continuity // The onMessage callback streams output incrementally to Discord @@ -538,6 +579,7 @@ export class DiscordManager implements IChatManager { prompt, resume: existingSessionId, injectedMcpServers, + systemPromptAppend: conciseMode ? DISCORD_SYSTEM_PROMPT_APPEND : undefined, onMessage: async (message) => { // Extract text content from assistant messages and stream to Discord if (message.type === "assistant") { @@ -556,6 +598,44 @@ export class DiscordManager implements IChatManager { startTime: Date.now(), }); } + + // Track tool names for progress indicator + if (block.name && showProgressIndicator) { + const emoji = TOOL_EMOJIS[block.name] ?? "\u{1F527}"; + const displayName = `${emoji} ${block.name}`; + toolNamesRun.push(displayName); + + // Update progress embed (throttled to every 2s) + const now = Date.now(); + if (now - lastProgressUpdate >= 2000) { + lastProgressUpdate = now; + const description = toolNamesRun.join(" \u2192 "); + const embedPayload = { + embeds: [ + { + title: "\u2699\uFE0F Working\u2026", + description: + description.length > 4000 + ? `\u2026${description.slice(-3997)}` + : description, + color: DiscordManager.EMBED_COLOR_SYSTEM, + }, + ], + }; + + try { + if (!progressEmbedHandle) { + progressEmbedHandle = await event.replyWithRef(embedPayload); + } else { + await progressEmbedHandle.edit(embedPayload); + } + } catch (progressError) { + logger.warn( + `Failed to update progress embed: ${(progressError as Error).message}`, + ); + } + } + } } // Deduplicate assistant messages by message.id. @@ -578,13 +658,18 @@ export class DiscordManager implements IChatManager { const content = extractMessageContent(sdkMessage); if (content) { - // Each assistant message is a complete turn - send immediately - await streamer.addMessageAndSend(content); + if (finalAnswerOnly) { + // Buffer assistant messages — only the last one will be sent + bufferedAssistantMessages.push(content); + } else { + // Legacy behavior: send every assistant turn immediately + await streamer.addMessageAndSend(content); + } } } // Build and send embeds for tool results - if (message.type === "user" && outputConfig.tool_results) { + if (message.type === "user" && showToolResults) { // Cast to the shape expected by extractToolResults const userMessage = message as { type: string; @@ -615,7 +700,7 @@ export class DiscordManager implements IChatManager { } // Show system status messages (e.g., "compacting context...") - if (message.type === "system" && outputConfig.system_status) { + if (message.type === "system" && outputConfig.system_status && !finalAnswerOnly) { const sysMessage = message as { subtype?: string; status?: string | null }; if (sysMessage.subtype === "status" && sysMessage.status) { const statusText = @@ -724,6 +809,24 @@ export class DiscordManager implements IChatManager { typingStopped = true; } + // Clean up progress embed now that the job is done + // Note: progressEmbedHandle is assigned inside the onMessage async callback, + // so TypeScript's control flow analysis sees it as always null. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if ((progressEmbedHandle as unknown as ProgressEmbedHandle | null) !== null) { + try { + await progressEmbedHandle!.delete(); + } catch (progressError) { + logger.warn(`Failed to delete progress embed: ${(progressError as Error).message}`); + } + } + + // In final-answer-only mode, send only the last buffered assistant message + if (finalAnswerOnly && bufferedAssistantMessages.length > 0) { + const finalMessage = bufferedAssistantMessages[bufferedAssistantMessages.length - 1]; + await streamer.addMessageAndSend(finalMessage); + } + // Flush any remaining buffered content await streamer.flush(); diff --git a/packages/discord/src/types.ts b/packages/discord/src/types.ts index 4da7814b..15984f7e 100644 --- a/packages/discord/src/types.ts +++ b/packages/discord/src/types.ts @@ -379,6 +379,14 @@ export interface DiscordConnectorEventMap { addReaction: (emoji: string) => Promise; /** Remove the bot's reaction from the user's message */ removeReaction: (emoji: string) => Promise; + /** + * Send a reply and return a handle for editing or deleting it. + * Used for progress indicator embeds that update in-place. + */ + replyWithRef: (content: string | DiscordReplyPayload) => Promise<{ + edit: (content: string | DiscordReplyPayload) => Promise; + delete: () => Promise; + }>; }; /** From 778ec1c3b2aeca6739b148ebc761f0cdcfbc9474 Mon Sep 17 00:00:00 2001 From: agent Date: Mon, 2 Mar 2026 12:02:08 -0800 Subject: [PATCH 09/48] fix(discord): ensure final answer delivery in finalAnswerOnly mode Add resultText fallback from SDK result message when all assistant turns are tool-only (no text blocks). Update concise mode system prompt to instruct Claude to always end with a text summary. Add config merge support for new discord output fields. Add 10 handleMessage pipeline tests covering dedup, buffering, fallback, tool result embeds, concise prompt injection, and session storage. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/config/merge.ts | 11 + packages/core/src/config/schema.ts | 6 + .../discord/src/__tests__/manager.test.ts | 685 ++++++++++++++++++ packages/discord/src/manager.ts | 34 +- 4 files changed, 730 insertions(+), 6 deletions(-) diff --git a/packages/core/src/config/merge.ts b/packages/core/src/config/merge.ts index 3488b63b..7a14edce 100644 --- a/packages/core/src/config/merge.ts +++ b/packages/core/src/config/merge.ts @@ -11,6 +11,7 @@ import type { AgentConfig, AgentWorkingDirectory, Docker, + McpServer, PermissionMode, Session, WorkSource, @@ -124,6 +125,7 @@ export interface ExtendedDefaults { permission_mode?: PermissionMode; allowed_tools?: string[]; denied_tools?: string[]; + mcp_servers?: Record; } // ============================================================================= @@ -235,6 +237,15 @@ export function mergeAgentConfig( result.denied_tools = defaults.denied_tools; } + // Merge mcp_servers (deep merge — agent servers override defaults with same name, + // default servers not present in agent config are inherited) + if (defaults.mcp_servers || agent.mcp_servers) { + result.mcp_servers = deepMerge( + defaults.mcp_servers as Record | undefined, + agent.mcp_servers as Record | undefined, + ) as AgentConfig["mcp_servers"]; + } + return result; } diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 71b0ce28..5615b36a 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -413,6 +413,12 @@ export const DefaultsSchema = z.object({ permission_mode: PermissionModeSchema.optional(), allowed_tools: z.array(z.string()).optional(), denied_tools: z.array(z.string()).optional(), + mcp_servers: z + .record( + z.string(), + z.lazy(() => McpServerSchema), + ) + .optional(), }); // ============================================================================= diff --git a/packages/discord/src/__tests__/manager.test.ts b/packages/discord/src/__tests__/manager.test.ts index 7e3af2fd..c0e1b4fa 100644 --- a/packages/discord/src/__tests__/manager.test.ts +++ b/packages/discord/src/__tests__/manager.test.ts @@ -718,6 +718,691 @@ describe.skip("DiscordManager response splitting", () => { }); }); +// ============================================================================= +// handleMessage pipeline tests (active) +// ============================================================================= + +/** + * These tests exercise the handleMessage() pipeline end-to-end by: + * 1. Creating a DiscordManager with a mock FleetManagerContext + * 2. Injecting a mock connector (EventEmitter with session manager) + * 3. Emitting a "message" event on the connector → triggers handleMessage() + * 4. The mock ctx.trigger() captures and invokes the onMessage callback + * 5. Assertions verify reply calls, embeds, fallback behavior + */ +describe("DiscordManager handleMessage pipeline", () => { + /** + * Helper: build a DiscordManager wired to a custom ctx.trigger mock. + * Returns { manager, connector, triggerMock } ready for emitting events. + */ + function buildManagerWithTrigger( + triggerImpl: (...args: unknown[]) => Promise, + agentOverrides?: Partial>, + ) { + const discordConfig: AgentChatDiscord = { + bot_token_env: "TEST_BOT_TOKEN", + session_expiry_hours: 24, + log_level: "standard", + output: { + tool_results: true, + tool_result_max_length: 900, + system_status: true, + result_summary: false, + typing_indicator: true, + errors: true, + acknowledge_emoji: "", + final_answer_only: true, + progress_indicator: false, // disable for cleaner assertions + concise_mode: true, + }, + guilds: [], + }; + const agent = { + ...createDiscordAgent("test-agent", discordConfig), + ...agentOverrides, + } as ReturnType; + + const emitter = new EventEmitter(); + const ctx: FleetManagerContext = { + getConfig: () => + ({ + fleet: { name: "test-fleet" } as unknown, + agents: [agent], + configPath: "/test/herdctl.yaml", + configDir: "/test", + }) as ReturnType, + getStateDir: () => "/tmp/test-state", + getStateDirInfo: () => null, + getLogger: () => mockLogger, + getScheduler: () => null, + getStatus: () => "running", + getInitializedAt: () => "2024-01-01T00:00:00.000Z", + getStartedAt: () => "2024-01-01T00:00:01.000Z", + getStoppedAt: () => null, + getLastError: () => null, + getCheckInterval: () => 1000, + emit: (event: string, ...args: unknown[]) => emitter.emit(event, ...args), + getEmitter: () => emitter, + trigger: triggerImpl as FleetManagerContext["trigger"], + }; + + const manager = new DiscordManager(ctx); + + // Build mock connector + const connector = new EventEmitter() as EventEmitter & { + connect: ReturnType; + disconnect: ReturnType; + isConnected: ReturnType; + getState: ReturnType; + uploadFile: ReturnType; + agentName: string; + sessionManager: { + getOrCreateSession: ReturnType; + getSession: ReturnType; + setSession: ReturnType; + touchSession: ReturnType; + getActiveSessionCount: ReturnType; + }; + }; + connector.connect = vi.fn().mockResolvedValue(undefined); + connector.disconnect = vi.fn().mockResolvedValue(undefined); + connector.isConnected = vi.fn().mockReturnValue(true); + connector.getState = vi.fn().mockReturnValue({ + status: "connected", + connectedAt: "2024-01-01T00:00:00.000Z", + disconnectedAt: null, + reconnectAttempts: 0, + lastError: null, + botUser: { id: "bot1", username: "TestBot", discriminator: "0000" }, + rateLimits: { + totalCount: 0, + lastRateLimitAt: null, + isRateLimited: false, + currentResetTime: 0, + }, + messageStats: { received: 0, sent: 0, ignored: 0 }, + } satisfies DiscordConnectorState); + connector.uploadFile = vi.fn().mockResolvedValue({ fileId: "f1" }); + connector.agentName = "test-agent"; + connector.sessionManager = { + getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }), + getSession: vi.fn().mockResolvedValue(null), + setSession: vi.fn().mockResolvedValue(undefined), + touchSession: vi.fn().mockResolvedValue(undefined), + getActiveSessionCount: vi.fn().mockResolvedValue(0), + }; + + // Inject connector + // @ts-expect-error - accessing private property for testing + manager.connectors.set("test-agent", connector); + // @ts-expect-error - accessing private property for testing + manager.initialized = true; + + return { manager, connector, ctx }; + } + + /** Create a standard message event with configurable reply mock */ + function createMessageEvent(replyMock?: ReturnType) { + const replyFn = replyMock ?? vi.fn().mockResolvedValue(undefined); + const replyWithRefFn = vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }); + const event = { + agentName: "test-agent", + prompt: "Hello bot!", + context: { messages: [], wasMentioned: true, prompt: "Hello bot!" }, + metadata: { + guildId: "guild1", + channelId: "channel1", + messageId: "msg1", + userId: "user1", + username: "TestUser", + wasMentioned: true, + mode: "mention" as const, + }, + reply: replyFn, + startTyping: () => () => {}, + addReaction: vi.fn().mockResolvedValue(undefined), + removeReaction: vi.fn().mockResolvedValue(undefined), + replyWithRef: replyWithRefFn, + } as unknown as DiscordMessageEvent; + return { event, reply: replyFn, replyWithRef: replyWithRefFn }; + } + + // ---- finalAnswerOnly: basic text delivery ---- + + it("sends only the last assistant message in finalAnswerOnly mode", async () => { + const { manager, connector } = buildManagerWithTrigger(async (...args: unknown[]) => { + const options = args[2] as { onMessage?: (m: unknown) => Promise } | undefined; + if (options?.onMessage) { + // Two assistant turns with text + await options.onMessage({ + type: "assistant", + message: { + id: "msg_1", + stop_reason: "end_turn", + content: [{ type: "text", text: "Checking..." }], + }, + }); + await options.onMessage({ + type: "assistant", + message: { + id: "msg_2", + stop_reason: "end_turn", + content: [{ type: "text", text: "Here is the answer." }], + }, + }); + await options.onMessage({ type: "result", result: "Here is the answer." }); + } + return { + jobId: "j1", + agentName: "test-agent", + scheduleName: null, + startedAt: new Date().toISOString(), + success: true, + sessionId: "sid1", + }; + }); + + await manager.start(); + const { event, reply } = createMessageEvent(); + connector.emit("message", event); + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Only the LAST assistant message should be delivered + const textCalls = reply.mock.calls.filter((call: unknown[]) => typeof call[0] === "string"); + expect(textCalls).toHaveLength(1); + expect(textCalls[0][0]).toBe("Here is the answer."); + }); + + // ---- resultText fallback when assistant messages are tool-only ---- + + it("uses SDK result text as fallback when no assistant text was buffered", async () => { + const { manager, connector } = buildManagerWithTrigger(async (...args: unknown[]) => { + const options = args[2] as { onMessage?: (m: unknown) => Promise } | undefined; + if (options?.onMessage) { + // Assistant message with ONLY tool_use (no text blocks) + await options.onMessage({ + type: "assistant", + message: { + id: "msg_1", + stop_reason: "tool_use", + content: [{ type: "tool_use", name: "Bash", id: "t1", input: { command: "ls" } }], + }, + }); + // Tool result + await options.onMessage({ + type: "user", + message: { + content: [{ type: "tool_result", tool_use_id: "t1", content: "file1 file2" }], + }, + }); + // Result message has the final answer text + await options.onMessage({ + type: "result", + result: "The directory contains file1 and file2.", + }); + } + return { + jobId: "j2", + agentName: "test-agent", + scheduleName: null, + startedAt: new Date().toISOString(), + success: true, + sessionId: "sid2", + }; + }); + + await manager.start(); + const { event, reply } = createMessageEvent(); + connector.emit("message", event); + await new Promise((resolve) => setTimeout(resolve, 200)); + + // The result text should be sent as fallback + const textCalls = reply.mock.calls.filter((call: unknown[]) => typeof call[0] === "string"); + expect(textCalls).toHaveLength(1); + expect(textCalls[0][0]).toBe("The directory contains file1 and file2."); + }); + + // ---- Dedup: skip intermediates (stop_reason: null) ---- + + it("skips intermediate assistant snapshots (stop_reason: null)", async () => { + const { manager, connector } = buildManagerWithTrigger(async (...args: unknown[]) => { + const options = args[2] as { onMessage?: (m: unknown) => Promise } | undefined; + if (options?.onMessage) { + // Intermediate snapshot (stop_reason: null) — should be skipped + await options.onMessage({ + type: "assistant", + message: { + id: "msg_1", + stop_reason: null, + content: [{ type: "text", text: "Partial..." }], + }, + }); + // Final snapshot (stop_reason: "end_turn") — should be delivered + await options.onMessage({ + type: "assistant", + message: { + id: "msg_1", + stop_reason: "end_turn", + content: [{ type: "text", text: "Complete answer." }], + }, + }); + await options.onMessage({ type: "result", result: "Complete answer." }); + } + return { + jobId: "j3", + agentName: "test-agent", + scheduleName: null, + startedAt: new Date().toISOString(), + success: true, + sessionId: "sid3", + }; + }); + + await manager.start(); + const { event, reply } = createMessageEvent(); + connector.emit("message", event); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const textCalls = reply.mock.calls.filter((call: unknown[]) => typeof call[0] === "string"); + expect(textCalls).toHaveLength(1); + expect(textCalls[0][0]).toBe("Complete answer."); + }); + + // ---- Dedup: deduplicate same message.id ---- + + it("deduplicates assistant messages by message.id", async () => { + const { manager, connector } = buildManagerWithTrigger(async (...args: unknown[]) => { + const options = args[2] as { onMessage?: (m: unknown) => Promise } | undefined; + if (options?.onMessage) { + // Same message.id delivered twice (stop_reason not null) — second should be deduped + await options.onMessage({ + type: "assistant", + message: { + id: "msg_1", + stop_reason: "end_turn", + content: [{ type: "text", text: "First delivery." }], + }, + }); + await options.onMessage({ + type: "assistant", + message: { + id: "msg_1", + stop_reason: "end_turn", + content: [{ type: "text", text: "Duplicate delivery." }], + }, + }); + await options.onMessage({ type: "result", result: "First delivery." }); + } + return { + jobId: "j4", + agentName: "test-agent", + scheduleName: null, + startedAt: new Date().toISOString(), + success: true, + sessionId: "sid4", + }; + }); + + await manager.start(); + const { event, reply } = createMessageEvent(); + connector.emit("message", event); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const textCalls = reply.mock.calls.filter((call: unknown[]) => typeof call[0] === "string"); + expect(textCalls).toHaveLength(1); + // Only the first delivery should be buffered (not the duplicate) + expect(textCalls[0][0]).toBe("First delivery."); + }); + + // ---- Fallback when no output at all ---- + + it("shows fallback embed when no messages are sent", async () => { + const { manager, connector } = buildManagerWithTrigger(async (...args: unknown[]) => { + const options = args[2] as { onMessage?: (m: unknown) => Promise } | undefined; + if (options?.onMessage) { + // Only a result message with no text + await options.onMessage({ type: "result", is_error: false }); + } + return { + jobId: "j5", + agentName: "test-agent", + scheduleName: null, + startedAt: new Date().toISOString(), + success: true, + sessionId: "sid5", + }; + }); + + await manager.start(); + const { event, reply } = createMessageEvent(); + connector.emit("message", event); + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Should show the "Task completed" fallback embed + const embedCalls = reply.mock.calls.filter( + (call: unknown[]) => + typeof call[0] === "object" && (call[0] as { embeds?: unknown[] }).embeds, + ); + expect(embedCalls.length).toBeGreaterThanOrEqual(1); + const lastEmbed = embedCalls[embedCalls.length - 1][0] as { embeds: DiscordReplyEmbed[] }; + expect(lastEmbed.embeds[0].description).toContain("Task completed"); + }); + + // ---- Error fallback ---- + + it("shows error fallback when job fails with no output", async () => { + const { manager, connector } = buildManagerWithTrigger(async () => { + return { + jobId: "j6", + agentName: "test-agent", + scheduleName: null, + startedAt: new Date().toISOString(), + success: false, + error: { message: "Agent crashed" }, + errorDetails: { message: "Agent crashed" }, + }; + }); + + await manager.start(); + const { event, reply } = createMessageEvent(); + connector.emit("message", event); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const embedCalls = reply.mock.calls.filter( + (call: unknown[]) => + typeof call[0] === "object" && (call[0] as { embeds?: unknown[] }).embeds, + ); + expect(embedCalls.length).toBeGreaterThanOrEqual(1); + const lastEmbed = embedCalls[embedCalls.length - 1][0] as { embeds: DiscordReplyEmbed[] }; + expect(lastEmbed.embeds[0].description).toContain("Agent crashed"); + }); + + // ---- Tool result embeds ---- + + it("sends tool result embeds when showToolResults is true", async () => { + const { manager, connector } = buildManagerWithTrigger(async (...args: unknown[]) => { + const options = args[2] as { onMessage?: (m: unknown) => Promise } | undefined; + if (options?.onMessage) { + // Assistant with tool_use + await options.onMessage({ + type: "assistant", + message: { + id: "msg_1", + stop_reason: "tool_use", + content: [ + { type: "text", text: "Let me check." }, + { type: "tool_use", name: "Bash", id: "t1", input: { command: "ls" } }, + ], + }, + }); + // Tool result + await options.onMessage({ + type: "user", + message: { + content: [{ type: "tool_result", tool_use_id: "t1", content: "file1.txt\nfile2.txt" }], + }, + }); + // Final answer + await options.onMessage({ + type: "assistant", + message: { + id: "msg_2", + stop_reason: "end_turn", + content: [{ type: "text", text: "Found 2 files." }], + }, + }); + await options.onMessage({ type: "result", result: "Found 2 files." }); + } + return { + jobId: "j7", + agentName: "test-agent", + scheduleName: null, + startedAt: new Date().toISOString(), + success: true, + sessionId: "sid7", + }; + }); + + await manager.start(); + const { event, reply } = createMessageEvent(); + connector.emit("message", event); + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Should have sent: tool result embed + final text + const embedCalls = reply.mock.calls.filter( + (call: unknown[]) => + typeof call[0] === "object" && (call[0] as { embeds?: unknown[] }).embeds, + ); + const textCalls = reply.mock.calls.filter((call: unknown[]) => typeof call[0] === "string"); + + expect(embedCalls.length).toBeGreaterThanOrEqual(1); + const toolEmbed = embedCalls[0][0] as { embeds: DiscordReplyEmbed[] }; + expect(toolEmbed.embeds[0].description).toContain("Bash"); + + expect(textCalls).toHaveLength(1); + expect(textCalls[0][0]).toBe("Found 2 files."); + }); + + // ---- Concise mode system prompt injection ---- + + it("injects concise mode system prompt when enabled", async () => { + let capturedSystemPrompt: string | undefined; + const { manager, connector } = buildManagerWithTrigger(async (...args: unknown[]) => { + const options = args[2] as + | { systemPromptAppend?: string; onMessage?: (m: unknown) => Promise } + | undefined; + capturedSystemPrompt = options?.systemPromptAppend; + if (options?.onMessage) { + await options.onMessage({ + type: "assistant", + message: { + id: "msg_1", + stop_reason: "end_turn", + content: [{ type: "text", text: "Done." }], + }, + }); + await options.onMessage({ type: "result", result: "Done." }); + } + return { + jobId: "j8", + agentName: "test-agent", + scheduleName: null, + startedAt: new Date().toISOString(), + success: true, + sessionId: "sid8", + }; + }); + + await manager.start(); + const { event } = createMessageEvent(); + connector.emit("message", event); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(capturedSystemPrompt).toBeDefined(); + expect(capturedSystemPrompt).toContain("Discord chat"); + expect(capturedSystemPrompt).toContain("final answer"); + }); + + // ---- Session persistence ---- + + it("stores session ID after successful job", async () => { + const { manager, connector } = buildManagerWithTrigger(async (...args: unknown[]) => { + const options = args[2] as { onMessage?: (m: unknown) => Promise } | undefined; + if (options?.onMessage) { + await options.onMessage({ + type: "assistant", + message: { + id: "msg_1", + stop_reason: "end_turn", + content: [{ type: "text", text: "OK." }], + }, + }); + await options.onMessage({ type: "result", result: "OK." }); + } + return { + jobId: "j9", + agentName: "test-agent", + scheduleName: null, + startedAt: new Date().toISOString(), + success: true, + sessionId: "sdk-session-42", + }; + }); + + await manager.start(); + const { event } = createMessageEvent(); + connector.emit("message", event); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(connector.sessionManager.setSession).toHaveBeenCalledWith("channel1", "sdk-session-42"); + }); + + // ---- Mixed: tool-only turns + final text ---- + + it("handles multi-turn tool usage with final text answer", async () => { + const { manager, connector } = buildManagerWithTrigger(async (...args: unknown[]) => { + const options = args[2] as { onMessage?: (m: unknown) => Promise } | undefined; + if (options?.onMessage) { + // Turn 1: text + tool + await options.onMessage({ + type: "assistant", + message: { + id: "msg_1", + stop_reason: "tool_use", + content: [ + { type: "text", text: "Let me look." }, + { type: "tool_use", name: "Read", id: "t1", input: { file_path: "/x.txt" } }, + ], + }, + }); + await options.onMessage({ + type: "user", + message: { + content: [{ type: "tool_result", tool_use_id: "t1", content: "contents" }], + }, + }); + // Turn 2: tool-only (no text) + await options.onMessage({ + type: "assistant", + message: { + id: "msg_2", + stop_reason: "tool_use", + content: [{ type: "tool_use", name: "Bash", id: "t2", input: { command: "wc -l" } }], + }, + }); + await options.onMessage({ + type: "user", + message: { + content: [{ type: "tool_result", tool_use_id: "t2", content: "42" }], + }, + }); + // Turn 3: final text + await options.onMessage({ + type: "assistant", + message: { + id: "msg_3", + stop_reason: "end_turn", + content: [{ type: "text", text: "The file has 42 lines." }], + }, + }); + await options.onMessage({ type: "result", result: "The file has 42 lines." }); + } + return { + jobId: "j10", + agentName: "test-agent", + scheduleName: null, + startedAt: new Date().toISOString(), + success: true, + sessionId: "sid10", + }; + }); + + await manager.start(); + const { event, reply } = createMessageEvent(); + connector.emit("message", event); + await new Promise((resolve) => setTimeout(resolve, 200)); + + // The final text "The file has 42 lines." should be the only text sent + const textCalls = reply.mock.calls.filter((call: unknown[]) => typeof call[0] === "string"); + expect(textCalls).toHaveLength(1); + expect(textCalls[0][0]).toBe("The file has 42 lines."); + }); + + // ---- Non-finalAnswerOnly mode: sends every turn ---- + + it("sends every assistant message immediately when finalAnswerOnly is false", async () => { + const { manager, connector } = buildManagerWithTrigger( + async (...args: unknown[]) => { + const options = args[2] as { onMessage?: (m: unknown) => Promise } | undefined; + if (options?.onMessage) { + await options.onMessage({ + type: "assistant", + message: { + id: "msg_1", + stop_reason: "end_turn", + content: [{ type: "text", text: "First turn." }], + }, + }); + await options.onMessage({ + type: "assistant", + message: { + id: "msg_2", + stop_reason: "end_turn", + content: [{ type: "text", text: "Second turn." }], + }, + }); + await options.onMessage({ type: "result", result: "Second turn." }); + } + return { + jobId: "j11", + agentName: "test-agent", + scheduleName: null, + startedAt: new Date().toISOString(), + success: true, + sessionId: "sid11", + }; + }, + // Override: disable finalAnswerOnly + { + chat: { + discord: { + bot_token_env: "TEST_BOT_TOKEN", + session_expiry_hours: 24, + log_level: "standard", + output: { + tool_results: true, + tool_result_max_length: 900, + system_status: true, + result_summary: false, + typing_indicator: true, + errors: true, + acknowledge_emoji: "", + final_answer_only: false, + progress_indicator: false, + concise_mode: false, + }, + guilds: [], + }, + }, + } as Partial>, + ); + + await manager.start(); + const { event, reply } = createMessageEvent(); + connector.emit("message", event); + await new Promise((resolve) => setTimeout(resolve, 2500)); // wait for rate limiting + + // Both messages should be sent + const textCalls = reply.mock.calls.filter((call: unknown[]) => typeof call[0] === "string"); + expect(textCalls).toHaveLength(2); + expect(textCalls[0][0]).toBe("First turn."); + expect(textCalls[1][0]).toBe("Second turn."); + }); +}); + // Message handling tests are skipped pending refactor to work with the new architecture // The new DiscordManager uses this.ctx.trigger() directly instead of emitter.trigger // and delegates message extraction/splitting to @herdctl/chat diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index b2a5d9be..a707507f 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -55,7 +55,7 @@ const DISCORD_SYSTEM_PROMPT_APPEND = `You are responding in a Discord chat. Foll - Do NOT describe which tools you are using or what you found in intermediate steps - Do NOT show file diffs or large code blocks unless the user explicitly asked to see code - Keep responses concise — prefer summaries over exhaustive detail -- The user cannot see your tool calls, so never reference them`; +- IMPORTANT: Always end with a clear text response summarizing the outcome`; // ============================================================================= // Discord Manager @@ -557,6 +557,10 @@ export class DiscordManager implements IChatManager { // Final-answer-only mode: buffer assistant messages and send only the last one const bufferedAssistantMessages: string[] = []; + // Capture the result text from the SDK's "result" message as a fallback + // When all assistant messages are tool-only (no text), this is the last resort + let resultText: string | undefined; + // Progress indicator: track tool names for in-place-updating embed interface ProgressEmbedHandle { edit: (content: string | DiscordReplyPayload) => Promise; @@ -721,6 +725,15 @@ export class DiscordManager implements IChatManager { } } + // Capture result text from the SDK "result" message as a fallback answer. + // This covers cases where all assistant messages were tool-only (no text blocks). + if (message.type === "result") { + const resultMsg = message as { result?: string }; + if (typeof resultMsg.result === "string" && resultMsg.result.trim()) { + resultText = resultMsg.result; + } + } + // Show result summary embed (cost, tokens, turns) if (message.type === "result" && outputConfig.result_summary) { const resultMessage = message as { @@ -821,10 +834,17 @@ export class DiscordManager implements IChatManager { } } - // In final-answer-only mode, send only the last buffered assistant message - if (finalAnswerOnly && bufferedAssistantMessages.length > 0) { - const finalMessage = bufferedAssistantMessages[bufferedAssistantMessages.length - 1]; - await streamer.addMessageAndSend(finalMessage); + // In final-answer-only mode, send the last buffered assistant message. + // If no assistant messages had extractable text (all were tool-only), fall back + // to the SDK's result text which captures the final conversation output. + if (finalAnswerOnly) { + if (bufferedAssistantMessages.length > 0) { + const finalMessage = bufferedAssistantMessages[bufferedAssistantMessages.length - 1]; + await streamer.addMessageAndSend(finalMessage); + } else if (resultText) { + logger.debug("No buffered assistant text — using SDK result text as fallback answer"); + await streamer.addMessageAndSend(resultText); + } } // Flush any remaining buffered content @@ -834,7 +854,9 @@ export class DiscordManager implements IChatManager { `Discord job completed: ${result.jobId} for agent '${qualifiedName}'${result.sessionId ? ` (session: ${result.sessionId})` : ""}`, ); - // If no messages were sent (text or embeds), send an appropriate fallback + // If no text messages were sent, send an appropriate fallback. + // When embedsSent > 0 but no text was delivered, the user saw tool/result embeds + // but may have missed the final answer. Show a brief completion indicator. if (!streamer.hasSentMessages() && embedsSent === 0) { if (result.success) { await event.reply( From e11907e31b046e988974cb981571c0e894a10e90 Mon Sep 17 00:00:00 2001 From: agent Date: Mon, 2 Mar 2026 06:59:37 -0800 Subject: [PATCH 10/48] =?UTF-8?q?feat(discord):=20visual=20polish=20?= =?UTF-8?q?=E2=80=94=20title-less=20embeds,=20refined=20colors,=20branded?= =?UTF-8?q?=20footer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign all Discord embeds for a calm, premium feel: - Remove titles from all embeds (progress, tool results, errors, status, summary) - Add signature footer ("herdctl · agent-name") to all embeds - Refine color palette: soft violet for progress, emerald for success, cool gray for system, sky blue for commands - Compact tool result embeds: collapse title + fields into single description line - Horizontal result summary with centered-dot separators instead of inline fields - Convert /help, /status, /reset slash commands from plain text to styled embeds - Add syntax highlighting (ansi) for Bash tool output - Make DiscordReplyEmbed.title optional to support title-less embeds Co-Authored-By: Claude Opus 4.6 --- .../__tests__/command-manager.test.ts | 10 +- .../src/commands/__tests__/help.test.ts | 13 +- .../src/commands/__tests__/reset.test.ts | 20 +- .../src/commands/__tests__/status.test.ts | 66 +++---- packages/discord/src/commands/help.ts | 28 +-- packages/discord/src/commands/reset.ts | 25 +-- packages/discord/src/commands/status.ts | 47 +++-- packages/discord/src/manager.ts | 187 +++++++++--------- packages/discord/src/types.ts | 2 +- 9 files changed, 209 insertions(+), 189 deletions(-) diff --git a/packages/discord/src/commands/__tests__/command-manager.test.ts b/packages/discord/src/commands/__tests__/command-manager.test.ts index 681b8796..3c50b5b6 100644 --- a/packages/discord/src/commands/__tests__/command-manager.test.ts +++ b/packages/discord/src/commands/__tests__/command-manager.test.ts @@ -362,10 +362,16 @@ describe("CommandManager", () => { await manager.handleInteraction(interaction); const reply = interaction.reply as ReturnType; - // Help command should include agent name in response + // Help command should include agent name in embed footer expect(reply).toHaveBeenCalledWith( expect.objectContaining({ - content: expect.stringContaining("my-agent"), + embeds: expect.arrayContaining([ + expect.objectContaining({ + footer: expect.objectContaining({ + text: expect.stringContaining("my-agent"), + }), + }), + ]), }), ); }); diff --git a/packages/discord/src/commands/__tests__/help.test.ts b/packages/discord/src/commands/__tests__/help.test.ts index 046fd4c2..e4e7af86 100644 --- a/packages/discord/src/commands/__tests__/help.test.ts +++ b/packages/discord/src/commands/__tests__/help.test.ts @@ -115,11 +115,8 @@ describe("helpCommand", () => { await helpCommand.execute(context); - expect(interaction.reply).toHaveBeenCalledWith( - expect.objectContaining({ - content: expect.stringContaining("my-custom-agent"), - }), - ); + const call = interaction.reply.mock.calls[0][0]; + expect(call.embeds[0].footer.text).toContain("my-custom-agent"); }); it("includes command descriptions in response", async () => { @@ -131,8 +128,8 @@ describe("helpCommand", () => { await helpCommand.execute(context); const call = interaction.reply.mock.calls[0][0]; - expect(call.content).toContain("/help"); - expect(call.content).toContain("/status"); - expect(call.content).toContain("/reset"); + expect(call.embeds[0].description).toContain("/help"); + expect(call.embeds[0].description).toContain("/status"); + expect(call.embeds[0].description).toContain("/reset"); }); }); diff --git a/packages/discord/src/commands/__tests__/reset.test.ts b/packages/discord/src/commands/__tests__/reset.test.ts index 0758deaa..9ff13b28 100644 --- a/packages/discord/src/commands/__tests__/reset.test.ts +++ b/packages/discord/src/commands/__tests__/reset.test.ts @@ -118,11 +118,13 @@ describe("resetCommand", () => { await resetCommand.execute(context); expect(interaction.reply).toHaveBeenCalledWith({ - content: expect.stringContaining("my-agent"), - ephemeral: true, - }); - expect(interaction.reply).toHaveBeenCalledWith({ - content: expect.stringContaining("cleared"), + embeds: [ + expect.objectContaining({ + description: "Session cleared. Starting fresh.", + color: 0x22c55e, + footer: { text: "herdctl \u00b7 my-agent" }, + }), + ], ephemeral: true, }); }); @@ -144,7 +146,13 @@ describe("resetCommand", () => { await resetCommand.execute(context); expect(interaction.reply).toHaveBeenCalledWith({ - content: expect.stringContaining("No active session"), + embeds: [ + expect.objectContaining({ + description: "No active session in this channel.", + color: 0x6b7280, + footer: { text: "herdctl \u00b7 my-agent" }, + }), + ], ephemeral: true, }); }); diff --git a/packages/discord/src/commands/__tests__/status.test.ts b/packages/discord/src/commands/__tests__/status.test.ts index 18f06110..44220dc2 100644 --- a/packages/discord/src/commands/__tests__/status.test.ts +++ b/packages/discord/src/commands/__tests__/status.test.ts @@ -116,7 +116,7 @@ describe("statusCommand", () => { ); }); - it("includes agent name in response", async () => { + it("includes agent name in footer", async () => { const context = createMockContext({ agentName: "my-custom-agent" }); const interaction = context.interaction as unknown as { reply: ReturnType; @@ -124,11 +124,9 @@ describe("statusCommand", () => { await statusCommand.execute(context); - expect(interaction.reply).toHaveBeenCalledWith( - expect.objectContaining({ - content: expect.stringContaining("my-custom-agent"), - }), - ); + const call = interaction.reply.mock.calls[0][0]; + expect(call.embeds).toBeDefined(); + expect(call.embeds[0].footer.text).toContain("my-custom-agent"); }); it("includes connection status in response", async () => { @@ -141,11 +139,9 @@ describe("statusCommand", () => { await statusCommand.execute(context); - expect(interaction.reply).toHaveBeenCalledWith( - expect.objectContaining({ - content: expect.stringContaining("connected"), - }), - ); + const call = interaction.reply.mock.calls[0][0]; + expect(call.embeds).toBeDefined(); + expect(call.embeds[0].description).toContain("Connected"); }); it("includes bot username in response", async () => { @@ -164,11 +160,9 @@ describe("statusCommand", () => { await statusCommand.execute(context); - expect(interaction.reply).toHaveBeenCalledWith( - expect.objectContaining({ - content: expect.stringContaining("MyBot"), - }), - ); + const call = interaction.reply.mock.calls[0][0]; + expect(call.embeds).toBeDefined(); + expect(call.embeds[0].description).toContain("MyBot"); }); describe("when session exists", () => { @@ -185,8 +179,9 @@ describe("statusCommand", () => { await statusCommand.execute(context); const call = interaction.reply.mock.calls[0][0]; - expect(call.content).toContain("Session ID"); - expect(call.content).toContain("Last Activity"); + expect(call.embeds).toBeDefined(); + expect(call.embeds[0].description).toContain("Session"); + expect(call.embeds[0].description).toContain("`discord-test-agent-a"); }); it("queries session for correct channel", async () => { @@ -214,11 +209,9 @@ describe("statusCommand", () => { await statusCommand.execute(context); - expect(interaction.reply).toHaveBeenCalledWith( - expect.objectContaining({ - content: expect.stringContaining("No active session"), - }), - ); + const call = interaction.reply.mock.calls[0][0]; + expect(call.embeds).toBeDefined(); + expect(call.embeds[0].description).toContain("No active session"); }); }); @@ -233,11 +226,9 @@ describe("statusCommand", () => { await statusCommand.execute(context); - expect(interaction.reply).toHaveBeenCalledWith( - expect.objectContaining({ - content: expect.stringContaining("Reconnect Attempts"), - }), - ); + const call = interaction.reply.mock.calls[0][0]; + expect(call.embeds).toBeDefined(); + expect(call.embeds[0].description).toContain("Reconnect attempts"); }); it("does not show reconnect attempts when 0", async () => { @@ -251,7 +242,8 @@ describe("statusCommand", () => { await statusCommand.execute(context); const call = interaction.reply.mock.calls[0][0]; - expect(call.content).not.toContain("Reconnect Attempts"); + expect(call.embeds).toBeDefined(); + expect(call.embeds[0].description).not.toContain("Reconnect attempts"); }); it("shows last error when present", async () => { @@ -266,11 +258,9 @@ describe("statusCommand", () => { await statusCommand.execute(context); - expect(interaction.reply).toHaveBeenCalledWith( - expect.objectContaining({ - content: expect.stringContaining("Connection timeout"), - }), - ); + const call = interaction.reply.mock.calls[0][0]; + expect(call.embeds).toBeDefined(); + expect(call.embeds[0].description).toContain("Connection timeout"); }); it("shows uptime when connected", async () => { @@ -284,11 +274,9 @@ describe("statusCommand", () => { await statusCommand.execute(context); - expect(interaction.reply).toHaveBeenCalledWith( - expect.objectContaining({ - content: expect.stringContaining("Uptime"), - }), - ); + const call = interaction.reply.mock.calls[0][0]; + expect(call.embeds).toBeDefined(); + expect(call.embeds[0].description).toContain("Uptime"); }); }); }); diff --git a/packages/discord/src/commands/help.ts b/packages/discord/src/commands/help.ts index 628beff1..4b48a628 100644 --- a/packages/discord/src/commands/help.ts +++ b/packages/discord/src/commands/help.ts @@ -1,7 +1,7 @@ /** * /help command - Show available commands * - * Responds ephemerally with a list of available commands and their descriptions. + * Responds ephemerally with a styled embed listing available commands. */ import type { CommandContext, SlashCommand } from "./types.js"; @@ -13,19 +13,21 @@ export const helpCommand: SlashCommand = { async execute(context: CommandContext): Promise { const { interaction, agentName } = context; - const helpMessage = `**${agentName} Bot Commands** - -**/help** - Show this help message -**/status** - Show agent status and session info -**/reset** - Clear conversation context (start fresh session) - -**Interacting with the bot:** -- Mention the bot in a channel to start a conversation -- In configured channels, the bot may respond automatically -- DMs are supported based on configuration`; - await interaction.reply({ - content: helpMessage, + embeds: [ + { + description: [ + "**/help** \u2014 Show this help message", + "**/status** \u2014 Show agent status and session info", + "**/reset** \u2014 Clear conversation context", + "", + "**Usage**", + "Mention the bot or message in a configured channel. DMs are supported based on configuration.", + ].join("\n"), + color: 0x3b82f6, + footer: { text: `herdctl \u00b7 ${agentName}` }, + }, + ], ephemeral: true, }); }, diff --git a/packages/discord/src/commands/reset.ts b/packages/discord/src/commands/reset.ts index 24468d4f..db8e205c 100644 --- a/packages/discord/src/commands/reset.ts +++ b/packages/discord/src/commands/reset.ts @@ -2,7 +2,7 @@ * /reset command - Clear conversation context * * Clears the session for the current channel, starting a fresh conversation. - * Responds ephemerally with confirmation. + * Responds ephemerally with a color-coded embed confirmation. */ import type { CommandContext, SlashCommand } from "./types.js"; @@ -17,16 +17,17 @@ export const resetCommand: SlashCommand = { const wasCleared = await sessionManager.clearSession(channelId); - if (wasCleared) { - await interaction.reply({ - content: `Conversation context cleared for **${agentName}**. Starting fresh!`, - ephemeral: true, - }); - } else { - await interaction.reply({ - content: `No active session found for **${agentName}** in this channel. You're already starting fresh!`, - ephemeral: true, - }); - } + await interaction.reply({ + embeds: [ + { + description: wasCleared + ? "Session cleared. Starting fresh." + : "No active session in this channel.", + color: wasCleared ? 0x22c55e : 0x6b7280, + footer: { text: `herdctl \u00b7 ${agentName}` }, + }, + ], + ephemeral: true, + }); }, }; diff --git a/packages/discord/src/commands/status.ts b/packages/discord/src/commands/status.ts index b043f6a2..2a95750d 100644 --- a/packages/discord/src/commands/status.ts +++ b/packages/discord/src/commands/status.ts @@ -1,11 +1,11 @@ /** * /status command - Show agent status and session info * - * Responds ephemerally with the current agent status, connection info, + * Responds ephemerally with a condensed embed showing connection status * and session information for the current channel. */ -import { formatDuration, formatTimestamp, getStatusEmoji } from "@herdctl/chat"; +import { formatDuration, getStatusEmoji } from "@herdctl/chat"; import type { CommandContext, SlashCommand } from "./types.js"; export const statusCommand: SlashCommand = { @@ -19,40 +19,49 @@ export const statusCommand: SlashCommand = { // Get session info for this channel const session = await sessionManager.getSession(channelId); - // Build status message + // Build condensed status const statusEmoji = getStatusEmoji(connectorState.status); const botUsername = connectorState.botUser?.username ?? "Unknown"; + const statusLabel = + connectorState.status.charAt(0).toUpperCase() + connectorState.status.slice(1); - let statusMessage = `**${agentName} Status** - -${statusEmoji} **Connection:** ${connectorState.status} -**Bot:** ${botUsername}`; + const lines: string[] = []; + // Connection line: "🟢 **Connected** as MyBot · Uptime 2h 30m" + const connectionParts = [`${statusEmoji} **${statusLabel}**`, `as ${botUsername}`]; if (connectorState.connectedAt) { - statusMessage += `\n**Connected:** ${formatTimestamp(connectorState.connectedAt)}`; - statusMessage += `\n**Uptime:** ${formatDuration(connectorState.connectedAt)}`; + connectionParts.push(`\u00b7 Uptime ${formatDuration(connectorState.connectedAt)}`); } + lines.push(connectionParts.join(" ")); if (connectorState.reconnectAttempts > 0) { - statusMessage += `\n**Reconnect Attempts:** ${connectorState.reconnectAttempts}`; + lines.push(`Reconnect attempts: ${connectorState.reconnectAttempts}`); } - if (connectorState.lastError) { - statusMessage += `\n**Last Error:** ${connectorState.lastError}`; + lines.push(`Last error: ${connectorState.lastError}`); } - // Session info - statusMessage += `\n\n**Session Info**`; + // Session section + lines.push(""); if (session) { - statusMessage += `\n**Session ID:** \`${session.sessionId.substring(0, 20)}...\``; - statusMessage += `\n**Last Activity:** ${formatTimestamp(session.lastMessageAt)}`; - statusMessage += `\n**Session Age:** ${formatDuration(session.lastMessageAt)}`; + lines.push("**Session**"); + lines.push( + `\`${session.sessionId.substring(0, 20)}\u2026\` \u00b7 Active ${formatDuration(session.lastMessageAt)} ago`, + ); } else { - statusMessage += `\nNo active session in this channel.`; + lines.push("**Session**"); + lines.push("No active session in this channel."); } await interaction.reply({ - content: statusMessage, + embeds: [ + { + description: lines.join("\n"), + color: 0x3b82f6, + footer: { text: `herdctl \u00b7 ${agentName}` }, + timestamp: new Date().toISOString(), + }, + ], ephemeral: true, }); }, diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index a707507f..2e1d9f75 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -14,6 +14,7 @@ import { type ChatConnectorLogger, ChatSessionManager, extractMessageContent, + formatCompactNumber, StreamingResponder, splitMessage, } from "@herdctl/chat"; @@ -613,16 +614,16 @@ export class DiscordManager implements IChatManager { const now = Date.now(); if (now - lastProgressUpdate >= 2000) { lastProgressUpdate = now; - const description = toolNamesRun.join(" \u2192 "); + const description = toolNamesRun.join(" \u2192 "); const embedPayload = { embeds: [ { - title: "\u2699\uFE0F Working\u2026", description: description.length > 4000 ? `\u2026${description.slice(-3997)}` : description, - color: DiscordManager.EMBED_COLOR_SYSTEM, + color: DiscordManager.EMBED_COLOR_WORKING, + footer: DiscordManager.buildFooter(qualifiedName), }, ], }; @@ -694,6 +695,7 @@ export class DiscordManager implements IChatManager { toolUse ?? null, toolResult, outputConfig.tool_result_max_length, + qualifiedName, ); // Flush any buffered text before sending embed to preserve ordering @@ -708,14 +710,11 @@ export class DiscordManager implements IChatManager { const sysMessage = message as { subtype?: string; status?: string | null }; if (sysMessage.subtype === "status" && sysMessage.status) { const statusText = - sysMessage.status === "compacting" - ? "Compacting context..." - : `Status: ${sysMessage.status}`; + sysMessage.status === "compacting" ? "Compacting context\u2026" : sysMessage.status; await streamer.flush(); await event.reply({ embeds: [ { - title: "\u2699\uFE0F System", description: statusText, color: DiscordManager.EMBED_COLOR_SYSTEM, }, @@ -743,52 +742,38 @@ export class DiscordManager implements IChatManager { num_turns?: number; usage?: { input_tokens?: number; output_tokens?: number }; }; - const fields: DiscordReplyEmbedField[] = []; + const isError = resultMessage.is_error === true; + // Build compact summary: "**Task complete** in 45s · 3 turns · $0.0045 · 15.7k tokens" + const summaryParts: string[] = []; + summaryParts.push(isError ? "**Task failed**" : "**Task complete**"); if (resultMessage.duration_ms !== undefined) { - fields.push({ - name: "Duration", - value: DiscordManager.formatDuration(resultMessage.duration_ms), - inline: true, - }); + summaryParts[0] += ` in ${DiscordManager.formatDuration(resultMessage.duration_ms)}`; } - if (resultMessage.num_turns !== undefined) { - fields.push({ - name: "Turns", - value: String(resultMessage.num_turns), - inline: true, - }); + summaryParts.push( + `${resultMessage.num_turns} turn${resultMessage.num_turns !== 1 ? "s" : ""}`, + ); } - if (resultMessage.total_cost_usd !== undefined) { - fields.push({ - name: "Cost", - value: `$${resultMessage.total_cost_usd.toFixed(4)}`, - inline: true, - }); + summaryParts.push(`$${resultMessage.total_cost_usd.toFixed(4)}`); } - if (resultMessage.usage) { - const inputTokens = resultMessage.usage.input_tokens ?? 0; - const outputTokens = resultMessage.usage.output_tokens ?? 0; - fields.push({ - name: "Tokens", - value: `${inputTokens.toLocaleString()} in / ${outputTokens.toLocaleString()} out`, - inline: true, - }); + const total = + (resultMessage.usage.input_tokens ?? 0) + (resultMessage.usage.output_tokens ?? 0); + summaryParts.push(`${formatCompactNumber(total)} tokens`); } - const isError = resultMessage.is_error === true; await streamer.flush(); await event.reply({ embeds: [ { - title: isError ? "\u274C Task Failed" : "\u2705 Task Complete", + description: summaryParts.join(" \u00b7 "), color: isError ? DiscordManager.EMBED_COLOR_ERROR : DiscordManager.EMBED_COLOR_SUCCESS, - fields, + footer: DiscordManager.buildFooter(qualifiedName), + timestamp: new Date().toISOString(), }, ], }); @@ -803,10 +788,10 @@ export class DiscordManager implements IChatManager { await event.reply({ embeds: [ { - title: "\u274C Error", - description: - errorText.length > 4000 ? `${errorText.substring(0, 4000)}...` : errorText, + description: `**Error:** ${errorText.length > 4000 ? errorText.substring(0, 4000) + "\u2026" : errorText}`, color: DiscordManager.EMBED_COLOR_ERROR, + footer: DiscordManager.buildFooter(qualifiedName), + timestamp: new Date().toISOString(), }, ], }); @@ -859,16 +844,29 @@ export class DiscordManager implements IChatManager { // but may have missed the final answer. Show a brief completion indicator. if (!streamer.hasSentMessages() && embedsSent === 0) { if (result.success) { - await event.reply( - "I've completed the task, but I don't have a specific response to share.", - ); + await event.reply({ + embeds: [ + { + description: "Task completed \u2014 no additional output to share.", + color: DiscordManager.EMBED_COLOR_SUCCESS, + footer: DiscordManager.buildFooter(qualifiedName), + }, + ], + }); } else { // Job failed without streaming any messages - send error details const errorMessage = result.errorDetails?.message ?? result.error?.message ?? "An unknown error occurred"; - await event.reply( - `\u274C **Error:** ${errorMessage}\n\nThe task could not be completed. Please check the logs for more details.`, - ); + await event.reply({ + embeds: [ + { + description: `**Error:** ${errorMessage}\n\nThe task could not be completed.`, + color: DiscordManager.EMBED_COLOR_ERROR, + footer: DiscordManager.buildFooter(qualifiedName), + timestamp: new Date().toISOString(), + }, + ], + }); } // Stop typing after sending fallback message (if not already stopped) @@ -912,7 +910,7 @@ export class DiscordManager implements IChatManager { // Send user-friendly error message using the formatted error method try { - await event.reply(this.formatErrorMessage(err)); + await event.reply(this.formatErrorMessage(err, qualifiedName)); } catch (replyError) { logger.error(`Failed to send error reply: ${(replyError as Error).message}`); } @@ -946,10 +944,20 @@ export class DiscordManager implements IChatManager { private static readonly TOOL_OUTPUT_MAX_CHARS = 900; /** Embed colors */ - private static readonly EMBED_COLOR_DEFAULT = 0x5865f2; // Discord blurple - private static readonly EMBED_COLOR_ERROR = 0xef4444; // Red - private static readonly EMBED_COLOR_SYSTEM = 0x95a5a6; // Gray - private static readonly EMBED_COLOR_SUCCESS = 0x57f287; // Green + private static readonly EMBED_COLOR_BRAND = 0x5865f2; // Discord blurple (tool results) + private static readonly EMBED_COLOR_WORKING = 0x8b5cf6; // Soft violet (progress) + private static readonly EMBED_COLOR_SUCCESS = 0x22c55e; // Emerald (completion) + private static readonly EMBED_COLOR_ERROR = 0xef4444; // Red (errors) + private static readonly EMBED_COLOR_SYSTEM = 0x6b7280; // Cool gray (system status) + private static readonly EMBED_COLOR_INFO = 0x3b82f6; // Sky blue (slash commands) + + /** + * Build a consistent footer for Discord embeds + */ + private static buildFooter(agentName: string): { text: string } { + const shortName = agentName.includes(".") ? agentName.split(".").pop()! : agentName; + return { text: `herdctl \u00b7 ${shortName}` }; + } /** * Format duration in milliseconds to a human-readable string @@ -967,55 +975,39 @@ export class DiscordManager implements IChatManager { * Build a Discord embed for a tool call result * * Combines the tool_use info (name, input) with the tool_result - * (output, error status) into a compact Discord embed. + * (output, error status) into a compact Discord embed with a + * single-line description and optional output field. * * @param toolUse - The tool_use block info (name, input, startTime) * @param toolResult - The tool result (output, isError) * @param maxOutputChars - Maximum characters for output (defaults to TOOL_OUTPUT_MAX_CHARS) + * @param agentName - Agent name for the embed footer */ private buildToolEmbed( toolUse: { name: string; input?: unknown; startTime: number } | null, toolResult: { output: string; isError: boolean }, maxOutputChars?: number, + agentName?: string, ): DiscordReplyEmbed { const toolName = toolUse?.name ?? "Tool"; const emoji = TOOL_EMOJIS[toolName] ?? "\u{1F527}"; // wrench fallback - const isError = toolResult.isError; - // Build description from input summary + // Build compact description: "💻 **Bash** `> ls -la` — 2s" + const parts: string[] = [`${emoji} **${toolName}**`]; const inputSummary = toolUse ? getToolInputSummary(toolUse.name, toolUse.input) : undefined; - let description: string | undefined; if (inputSummary) { - if (toolName === "Bash" || toolName === "bash") { - description = `\`> ${inputSummary}\``; - } else { - description = `\`${inputSummary}\``; - } + const prefix = toolName === "Bash" || toolName === "bash" ? "> " : ""; + const truncated = + inputSummary.length > 120 ? inputSummary.substring(0, 120) + "\u2026" : inputSummary; + parts.push(`\`${prefix}${truncated}\``); } - - // Build inline fields - const fields: DiscordReplyEmbedField[] = []; - if (toolUse) { const durationMs = Date.now() - toolUse.startTime; - fields.push({ - name: "Duration", - value: DiscordManager.formatDuration(durationMs), - inline: true, - }); + parts.push(`\u2014 ${DiscordManager.formatDuration(durationMs)}`); } - const outputLength = toolResult.output.length; - fields.push({ - name: "Output", - value: - outputLength >= 1000 - ? `${(outputLength / 1000).toFixed(1)}k chars` - : `${outputLength} chars`, - inline: true, - }); - - // Add truncated output as a field if non-empty + // Build output field if non-empty + const fields: DiscordReplyEmbedField[] = []; const trimmedOutput = toolResult.output.trim(); if (trimmedOutput.length > 0) { const maxChars = maxOutputChars ?? DiscordManager.TOOL_OUTPUT_MAX_CHARS; @@ -1023,20 +1015,23 @@ export class DiscordManager implements IChatManager { if (outputText.length > maxChars) { outputText = outputText.substring(0, maxChars) + - `\n... (${outputLength.toLocaleString()} chars total)`; + `\n\u2026 ${trimmedOutput.length.toLocaleString()} chars total`; } + const lang = toolName === "Bash" || toolName === "bash" ? "ansi" : ""; fields.push({ - name: isError ? "Error" : "Result", - value: `\`\`\`\n${outputText}\n\`\`\``, + name: toolResult.isError ? "Error" : "Output", + value: `\`\`\`${lang}\n${outputText}\n\`\`\``, inline: false, }); } return { - title: `${emoji} ${toolName}`, - description, - color: isError ? DiscordManager.EMBED_COLOR_ERROR : DiscordManager.EMBED_COLOR_DEFAULT, - fields, + description: parts.join(" "), + color: toolResult.isError + ? DiscordManager.EMBED_COLOR_ERROR + : DiscordManager.EMBED_COLOR_BRAND, + fields: fields.length > 0 ? fields : undefined, + footer: agentName ? DiscordManager.buildFooter(agentName) : undefined, }; } @@ -1074,12 +1069,26 @@ export class DiscordManager implements IChatManager { * Format an error message for Discord display * * Creates a user-friendly error message with guidance on how to proceed. + * Returns an embed when agentName is provided, plain text otherwise. * * @param error - The error that occurred - * @returns Formatted error message string + * @param agentName - Optional agent name for embed footer + * @returns Formatted error message string or embed payload */ - formatErrorMessage(error: Error): string { - return `\u274C **Error**: ${error.message}\n\nPlease try again or use \`/reset\` to start a new session.`; + formatErrorMessage(error: Error, agentName?: string): string | DiscordReplyPayload { + if (agentName) { + return { + embeds: [ + { + description: `**Error:** ${error.message}\n\nTry again or use \`/reset\` to start a new session.`, + color: DiscordManager.EMBED_COLOR_ERROR, + footer: DiscordManager.buildFooter(agentName), + timestamp: new Date().toISOString(), + }, + ], + }; + } + return `**Error:** ${error.message}\n\nTry again or use \`/reset\` to start a new session.`; } /** diff --git a/packages/discord/src/types.ts b/packages/discord/src/types.ts index 15984f7e..8d000512 100644 --- a/packages/discord/src/types.ts +++ b/packages/discord/src/types.ts @@ -265,7 +265,7 @@ export interface DiscordReplyEmbedField { * A Discord embed for rich message formatting */ export interface DiscordReplyEmbed { - title: string; + title?: string; description?: string; color?: number; fields?: DiscordReplyEmbedField[]; From d03d25ed5d6ad03b85799231f7d665d43aee1243 Mon Sep 17 00:00:00 2001 From: agent Date: Mon, 2 Mar 2026 13:35:34 -0800 Subject: [PATCH 11/48] feat(discord): support file attachments in Discord messages Add processing for non-voice file attachments (images, PDFs, text/code files) uploaded alongside Discord messages. Text files are inlined into the prompt; images and PDFs are saved to disk so the agent can view them via its Read tool. Configurable via chat.discord.attachments with size limits, allowed types, and automatic cleanup. Co-Authored-By: Claude Opus 4.6 --- .changeset/discord-file-attachments.md | 11 + packages/core/src/config/index.ts | 2 + packages/core/src/config/schema.ts | 38 ++ .../discord/src/__tests__/attachments.test.ts | 478 ++++++++++++++++++ packages/discord/src/discord-connector.ts | 47 ++ packages/discord/src/manager.ts | 215 ++++++++ packages/discord/src/types.ts | 29 ++ 7 files changed, 820 insertions(+) create mode 100644 .changeset/discord-file-attachments.md create mode 100644 packages/discord/src/__tests__/attachments.test.ts diff --git a/.changeset/discord-file-attachments.md b/.changeset/discord-file-attachments.md new file mode 100644 index 00000000..cf8724f1 --- /dev/null +++ b/.changeset/discord-file-attachments.md @@ -0,0 +1,11 @@ +--- +"@herdctl/core": minor +"@herdctl/discord": minor +--- + +feat(discord): support file attachments (images, PDFs, text/code files) in Discord messages + +When users upload files alongside a Discord message, the connector now detects and processes them: +- Text/code files are downloaded and inlined directly into the agent's prompt +- Images and PDFs are saved to the agent's working directory with a file path reference so the agent can use its Read tool to view them +- Configurable via `chat.discord.attachments` with options for file size limits, allowed types, and automatic cleanup diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 7d6731bf..d7662b99 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -94,6 +94,8 @@ export { ChatSchema, type Defaults, DefaultsSchema, + type DiscordAttachments, + DiscordAttachmentsSchema, type DiscordChannel, DiscordChannelSchema, type DiscordChat, diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 5615b36a..a3c3b531 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -670,6 +670,41 @@ export const DiscordVoiceSchema = z.object({ language: z.string().optional(), }); +/** + * Discord file attachment handling configuration schema + * + * Controls how non-voice file attachments (images, PDFs, code files) are + * processed when users upload them alongside messages. + * + * @example + * ```yaml + * attachments: + * enabled: true + * max_file_size_mb: 10 + * max_files_per_message: 5 + * allowed_types: + * - "image/*" + * - "application/pdf" + * - "text/*" + * download_dir: ".discord-attachments" + * cleanup_after_processing: true + * ``` + */ +export const DiscordAttachmentsSchema = z.object({ + /** Enable file attachment processing (default: false) */ + enabled: z.boolean().optional().default(false), + /** Maximum file size in MB (default: 10) */ + max_file_size_mb: z.number().positive().optional().default(10), + /** Maximum files per message (default: 5) */ + max_files_per_message: z.number().int().positive().optional().default(5), + /** Allowed MIME type patterns — supports wildcards like "image/*" (default: ["image/*", "application/pdf", "text/*"]) */ + allowed_types: z.array(z.string()).optional().default(["image/*", "application/pdf", "text/*"]), + /** Directory name for downloaded binary attachments, relative to agent working_directory (default: ".discord-attachments") */ + download_dir: z.string().optional().default(".discord-attachments"), + /** Delete downloaded files after the agent finishes processing (default: true) */ + cleanup_after_processing: z.boolean().optional().default(true), +}); + /** * Per-agent Discord bot configuration schema * @@ -717,6 +752,8 @@ export const AgentChatDiscordSchema = z.object({ dm: ChatDMSchema.optional(), /** Voice message transcription configuration */ voice: DiscordVoiceSchema.optional(), + /** File attachment handling configuration */ + attachments: DiscordAttachmentsSchema.optional(), }); // ============================================================================= @@ -1120,6 +1157,7 @@ export type DiscordChannel = z.infer; export type DiscordGuild = z.infer; export type DiscordOutput = z.infer; export type DiscordVoice = z.infer; +export type DiscordAttachments = z.infer; export type AgentChatDiscord = z.infer; export type AgentChat = z.infer; // Agent Chat Slack types diff --git a/packages/discord/src/__tests__/attachments.test.ts b/packages/discord/src/__tests__/attachments.test.ts new file mode 100644 index 00000000..b02487bf --- /dev/null +++ b/packages/discord/src/__tests__/attachments.test.ts @@ -0,0 +1,478 @@ +/** + * Tests for Discord file attachment support + * + * Tests attachment categorization, MIME pattern matching, and the + * processAttachments / cleanupAttachments pipeline. + */ + +import { mkdir, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DiscordAttachmentsSchema } from "@herdctl/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DiscordConnector } from "../discord-connector.js"; +import { DiscordManager } from "../manager.js"; +import type { DiscordAttachmentInfo } from "../types.js"; + +// ============================================================================= +// Mock discord.js (required because DiscordConnector imports it at module level) +// ============================================================================= + +vi.mock("discord.js", () => { + const { EventEmitter } = require("node:events"); + + class MockClient extends EventEmitter { + user = { id: "bot123", username: "TestBot", discriminator: "0001", setActivity: vi.fn() }; + rest = new EventEmitter(); + login = vi.fn().mockResolvedValue("token"); + destroy = vi.fn(); + } + + return { + Client: MockClient, + GatewayIntentBits: { Guilds: 1, GuildMessages: 2, DirectMessages: 4, MessageContent: 8 }, + Partials: { Channel: 0, Message: 1 }, + Events: { ClientReady: "ready", MessageCreate: "messageCreate" }, + MessageFlags: { IsVoiceMessage: 1 << 13 }, + AttachmentBuilder: vi.fn(), + }; +}); + +vi.mock("@discordjs/rest", () => ({ + RESTEvents: { RateLimited: "rateLimited" }, +})); + +// ============================================================================= +// Access private static methods for unit testing +// ============================================================================= + +// biome-ignore lint/suspicious/noExplicitAny: accessing private statics for testing +const ConnectorAny = DiscordConnector as any; +// biome-ignore lint/suspicious/noExplicitAny: accessing private statics for testing +const ManagerAny = DiscordManager as any; + +// ============================================================================= +// Schema Tests +// ============================================================================= + +describe("DiscordAttachmentsSchema", () => { + it("uses correct defaults when no fields are provided", () => { + const result = DiscordAttachmentsSchema.parse({}); + expect(result).toEqual({ + enabled: false, + max_file_size_mb: 10, + max_files_per_message: 5, + allowed_types: ["image/*", "application/pdf", "text/*"], + download_dir: ".discord-attachments", + cleanup_after_processing: true, + }); + }); + + it("accepts full custom configuration", () => { + const result = DiscordAttachmentsSchema.parse({ + enabled: true, + max_file_size_mb: 25, + max_files_per_message: 3, + allowed_types: ["image/png", "text/plain"], + download_dir: "my-downloads", + cleanup_after_processing: false, + }); + expect(result.enabled).toBe(true); + expect(result.max_file_size_mb).toBe(25); + expect(result.max_files_per_message).toBe(3); + expect(result.allowed_types).toEqual(["image/png", "text/plain"]); + expect(result.download_dir).toBe("my-downloads"); + expect(result.cleanup_after_processing).toBe(false); + }); + + it("rejects negative max_file_size_mb", () => { + expect(() => DiscordAttachmentsSchema.parse({ max_file_size_mb: -1 })).toThrow(); + }); + + it("rejects zero max_files_per_message", () => { + expect(() => DiscordAttachmentsSchema.parse({ max_files_per_message: 0 })).toThrow(); + }); +}); + +// ============================================================================= +// Categorize Content Type Tests +// ============================================================================= + +describe("_categorizeContentType", () => { + const categorize = ConnectorAny._categorizeContentType; + + it("categorizes image/* as image", () => { + expect(categorize("image/png")).toBe("image"); + expect(categorize("image/jpeg")).toBe("image"); + expect(categorize("image/gif")).toBe("image"); + expect(categorize("image/webp")).toBe("image"); + }); + + it("categorizes application/pdf as pdf", () => { + expect(categorize("application/pdf")).toBe("pdf"); + }); + + it("categorizes text/* as text", () => { + expect(categorize("text/plain")).toBe("text"); + expect(categorize("text/html")).toBe("text"); + expect(categorize("text/csv")).toBe("text"); + expect(categorize("text/yaml")).toBe("text"); + }); + + it("categorizes common code MIME types as text", () => { + expect(categorize("application/json")).toBe("text"); + expect(categorize("application/javascript")).toBe("text"); + expect(categorize("application/typescript")).toBe("text"); + expect(categorize("application/x-yaml")).toBe("text"); + expect(categorize("application/x-sh")).toBe("text"); + expect(categorize("application/xml")).toBe("text"); + }); + + it("returns unsupported for unknown types", () => { + expect(categorize("application/octet-stream")).toBe("unsupported"); + expect(categorize("application/zip")).toBe("unsupported"); + expect(categorize("video/mp4")).toBe("unsupported"); + expect(categorize("audio/mpeg")).toBe("unsupported"); + }); + + it("handles content types with charset parameters", () => { + expect(categorize("text/plain; charset=utf-8")).toBe("text"); + expect(categorize("application/json; charset=utf-8")).toBe("text"); + }); + + it("is case-insensitive", () => { + expect(categorize("Image/PNG")).toBe("image"); + expect(categorize("APPLICATION/PDF")).toBe("pdf"); + expect(categorize("Text/Plain")).toBe("text"); + }); +}); + +// ============================================================================= +// MIME Pattern Matching Tests +// ============================================================================= + +describe("matchesMimePattern", () => { + const matches = ManagerAny.matchesMimePattern; + + it("matches exact MIME types", () => { + expect(matches("image/png", "image/png")).toBe(true); + expect(matches("text/plain", "text/plain")).toBe(true); + expect(matches("application/pdf", "application/pdf")).toBe(true); + }); + + it("matches wildcard patterns", () => { + expect(matches("image/png", "image/*")).toBe(true); + expect(matches("image/jpeg", "image/*")).toBe(true); + expect(matches("text/plain", "text/*")).toBe(true); + expect(matches("text/html", "text/*")).toBe(true); + }); + + it("does not match different types", () => { + expect(matches("text/plain", "image/*")).toBe(false); + expect(matches("image/png", "text/*")).toBe(false); + expect(matches("application/pdf", "image/*")).toBe(false); + }); + + it("handles content types with parameters", () => { + expect(matches("text/plain; charset=utf-8", "text/*")).toBe(true); + expect(matches("text/plain; charset=utf-8", "text/plain")).toBe(true); + }); + + it("is case-insensitive", () => { + expect(matches("Image/PNG", "image/*")).toBe(true); + expect(matches("image/png", "Image/*")).toBe(true); + }); +}); + +// ============================================================================= +// processAttachments Tests +// ============================================================================= + +describe("processAttachments", () => { + const processAttachments = ManagerAny.processAttachments.bind(ManagerAny); + + const mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + let testDir: string; + + beforeEach(async () => { + vi.clearAllMocks(); + testDir = join(tmpdir(), `herdctl-test-${Date.now()}`); + await mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + const defaultConfig = DiscordAttachmentsSchema.parse({ enabled: true }); + + function makeAttachment(overrides: Partial = {}): DiscordAttachmentInfo { + return { + id: "att_1", + name: "test.txt", + url: "https://cdn.discordapp.com/attachments/test.txt", + contentType: "text/plain", + size: 100, + category: "text", + ...overrides, + }; + } + + it("skips attachments not in allowed_types", async () => { + const config = DiscordAttachmentsSchema.parse({ + enabled: true, + allowed_types: ["image/*"], + }); + const attachment = makeAttachment({ contentType: "text/plain", category: "text" }); + + const result = await processAttachments([attachment], config, testDir, mockLogger); + + expect(result.promptSections).toHaveLength(0); + expect(result.skippedFiles).toHaveLength(1); + expect(result.skippedFiles[0].reason).toContain("not in allowed_types"); + }); + + it("skips attachments exceeding size limit", async () => { + const config = DiscordAttachmentsSchema.parse({ + enabled: true, + max_file_size_mb: 1, + }); + const attachment = makeAttachment({ + size: 2 * 1024 * 1024, // 2MB + }); + + const result = await processAttachments([attachment], config, testDir, mockLogger); + + expect(result.promptSections).toHaveLength(0); + expect(result.skippedFiles).toHaveLength(1); + expect(result.skippedFiles[0].reason).toContain("exceeds"); + }); + + it("limits files to max_files_per_message", async () => { + const config = DiscordAttachmentsSchema.parse({ + enabled: true, + max_files_per_message: 2, + }); + const attachments = [ + makeAttachment({ id: "1", name: "a.txt" }), + makeAttachment({ id: "2", name: "b.txt" }), + makeAttachment({ id: "3", name: "c.txt" }), + ]; + + // Mock fetch for text attachments + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + text: async () => "content", + }), + ); + + const result = await processAttachments(attachments, config, testDir, mockLogger); + + // Only first 2 should be processed, 3rd should be skipped + expect(result.skippedFiles).toContainEqual( + expect.objectContaining({ name: "c.txt", reason: "exceeded max_files_per_message" }), + ); + + vi.unstubAllGlobals(); + }); + + it("inlines text file content into prompt", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + text: async () => "hello world", + }), + ); + + const attachment = makeAttachment({ name: "script.py", contentType: "text/x-python" }); + const result = await processAttachments([attachment], defaultConfig, testDir, mockLogger); + + expect(result.promptSections).toHaveLength(1); + expect(result.promptSections[0]).toContain("--- File: script.py (text/x-python) ---"); + expect(result.promptSections[0]).toContain("hello world"); + expect(result.promptSections[0]).toContain("--- End of script.py ---"); + expect(result.downloadedPaths).toHaveLength(0); + + vi.unstubAllGlobals(); + }); + + it("truncates large text files", async () => { + const largeContent = "x".repeat(60_000); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + text: async () => largeContent, + }), + ); + + const attachment = makeAttachment(); + const result = await processAttachments([attachment], defaultConfig, testDir, mockLogger); + + expect(result.promptSections).toHaveLength(1); + expect(result.promptSections[0]).toContain("truncated at 50000 chars"); + + vi.unstubAllGlobals(); + }); + + it("downloads images to disk and returns file path in prompt", async () => { + const imageData = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: async () => + imageData.buffer.slice(imageData.byteOffset, imageData.byteOffset + imageData.byteLength), + }), + ); + + const attachment = makeAttachment({ + name: "screenshot.png", + contentType: "image/png", + category: "image", + }); + const result = await processAttachments([attachment], defaultConfig, testDir, mockLogger); + + expect(result.promptSections).toHaveLength(1); + expect(result.promptSections[0]).toContain("[Image attached:"); + expect(result.promptSections[0]).toContain("screenshot.png"); + expect(result.promptSections[0]).toContain("Use the Read tool"); + expect(result.downloadedPaths).toHaveLength(1); + + // Verify file was actually written + const fileContents = await readFile(result.downloadedPaths[0]); + expect(Buffer.from(fileContents)).toEqual(Buffer.from(imageData)); + + vi.unstubAllGlobals(); + }); + + it("downloads PDFs to disk and returns file path in prompt", async () => { + const pdfData = Buffer.from("%PDF-1.4"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: async () => pdfData.buffer, + }), + ); + + const attachment = makeAttachment({ + name: "document.pdf", + contentType: "application/pdf", + category: "pdf", + }); + const result = await processAttachments([attachment], defaultConfig, testDir, mockLogger); + + expect(result.promptSections).toHaveLength(1); + expect(result.promptSections[0]).toContain("[PDF attached:"); + expect(result.promptSections[0]).toContain("document.pdf"); + expect(result.downloadedPaths).toHaveLength(1); + + vi.unstubAllGlobals(); + }); + + it("skips binary attachments when no working directory", async () => { + const attachment = makeAttachment({ + name: "photo.jpg", + contentType: "image/jpeg", + category: "image", + }); + + const result = await processAttachments([attachment], defaultConfig, undefined, mockLogger); + + expect(result.promptSections).toHaveLength(0); + expect(result.skippedFiles).toHaveLength(1); + expect(result.skippedFiles[0].reason).toContain("no working_directory"); + }); + + it("handles fetch failures gracefully", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 404, + }), + ); + + const attachment = makeAttachment(); + const result = await processAttachments([attachment], defaultConfig, testDir, mockLogger); + + expect(result.promptSections).toHaveLength(0); + expect(result.skippedFiles).toHaveLength(1); + expect(result.skippedFiles[0].reason).toContain("download/processing failed"); + expect(mockLogger.warn).toHaveBeenCalled(); + + vi.unstubAllGlobals(); + }); + + it("handles network errors gracefully", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Network error"))); + + const attachment = makeAttachment(); + const result = await processAttachments([attachment], defaultConfig, testDir, mockLogger); + + expect(result.promptSections).toHaveLength(0); + expect(result.skippedFiles).toHaveLength(1); + expect(result.skippedFiles[0].reason).toContain("Network error"); + + vi.unstubAllGlobals(); + }); +}); + +// ============================================================================= +// cleanupAttachments Tests +// ============================================================================= + +describe("cleanupAttachments", () => { + const cleanupAttachments = ManagerAny.cleanupAttachments.bind(ManagerAny); + + const mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + let testDir: string; + + beforeEach(async () => { + vi.clearAllMocks(); + testDir = join(tmpdir(), `herdctl-cleanup-${Date.now()}`); + await mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + it("removes downloaded files and empty parent directories", async () => { + const subDir = join(testDir, "timestamp123"); + await mkdir(subDir, { recursive: true }); + const filePath = join(subDir, "test.png"); + await import("node:fs/promises").then((fs) => fs.writeFile(filePath, "data")); + + await cleanupAttachments([filePath], mockLogger); + + // File should be gone + await expect(stat(filePath)).rejects.toThrow(); + // Parent timestamp dir should also be gone + await expect(stat(subDir)).rejects.toThrow(); + }); + + it("handles missing files gracefully", async () => { + const nonexistent = join(testDir, "nope.txt"); + + // Should not throw + await cleanupAttachments([nonexistent], mockLogger); + expect(mockLogger.debug).toHaveBeenCalled(); + }); +}); diff --git a/packages/discord/src/discord-connector.ts b/packages/discord/src/discord-connector.ts index 0405b3c8..8693b81a 100644 --- a/packages/discord/src/discord-connector.ts +++ b/packages/discord/src/discord-connector.ts @@ -36,6 +36,8 @@ import { type TextBasedChannel, } from "./mention-handler.js"; import type { + AttachmentCategory, + DiscordAttachmentInfo, DiscordConnectionStatus, DiscordConnectorEventMap, DiscordConnectorEventName, @@ -748,6 +750,29 @@ export class DiscordConnector extends EventEmitter implements IDiscordConnector } } + // Extract non-voice file attachments (images, PDFs, text/code files) + let attachments: DiscordAttachmentInfo[] | undefined; + if (!isVoiceMessage && message.attachments.size > 0) { + const extracted: DiscordAttachmentInfo[] = []; + for (const [, attachment] of message.attachments) { + const contentType = attachment.contentType ?? "application/octet-stream"; + const category = DiscordConnector._categorizeContentType(contentType); + if (category !== "unsupported") { + extracted.push({ + id: attachment.id, + name: attachment.name ?? "unknown", + url: attachment.url, + contentType, + size: attachment.size, + category, + }); + } + } + if (extracted.length > 0) { + attachments = extracted; + } + } + // Create reaction functions for acknowledgement emoji support const addReaction = async (emoji: string): Promise => { try { @@ -789,6 +814,7 @@ export class DiscordConnector extends EventEmitter implements IDiscordConnector isVoiceMessage, voiceAttachmentUrl, voiceAttachmentName, + attachments, }, reply, replyWithRef, @@ -799,6 +825,27 @@ export class DiscordConnector extends EventEmitter implements IDiscordConnector this.emit("message", payload); } + /** + * Categorize a MIME content type into an attachment processing category + */ + private static _categorizeContentType(contentType: string): AttachmentCategory { + const lower = contentType.toLowerCase().split(";")[0].trim(); + if (lower.startsWith("image/")) return "image"; + if (lower === "application/pdf") return "pdf"; + if (lower.startsWith("text/")) return "text"; + // Common code file MIME types that don't start with text/ + const codeTypes = [ + "application/json", + "application/javascript", + "application/typescript", + "application/x-yaml", + "application/x-sh", + "application/xml", + ]; + if (codeTypes.includes(lower)) return "text"; + return "unsupported"; + } + /** * Set bot presence based on configuration */ diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index 2e1d9f75..69570a08 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -10,6 +10,9 @@ * @module manager */ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + import { type ChatConnectorLogger, ChatSessionManager, @@ -20,6 +23,7 @@ import { } from "@herdctl/chat"; import type { ChatManagerConnectorState, + DiscordAttachments, FleetManagerContext, IChatManager, InjectedMcpServerDef, @@ -36,6 +40,7 @@ import { import { DiscordConnector } from "./discord-connector.js"; import type { + DiscordAttachmentInfo, DiscordConnectorEventMap, DiscordReplyEmbed, DiscordReplyEmbedField, @@ -488,6 +493,10 @@ export class DiscordManager implements IChatManager { await event.addReaction(ackEmoji); } + // Attachment state — declared here so the finally block can clean up + let attachmentDownloadedPaths: string[] = []; + const attachmentConfig = agent.chat?.discord?.attachments; + try { // Handle voice messages: transcribe audio before triggering the agent let prompt = event.prompt; @@ -543,6 +552,40 @@ export class DiscordManager implements IChatManager { } } + // Handle file attachments: download, process, and prepend to prompt + if ( + event.metadata.attachments && + event.metadata.attachments.length > 0 && + attachmentConfig?.enabled + ) { + const result = await DiscordManager.processAttachments( + event.metadata.attachments, + attachmentConfig, + workingDir, + logger as ChatConnectorLogger, + ); + attachmentDownloadedPaths = result.downloadedPaths; + + if (result.skippedFiles.length > 0) { + for (const skipped of result.skippedFiles) { + logger.debug(`Skipped attachment ${skipped.name}: ${skipped.reason}`); + } + } + + if (result.promptSections.length > 0) { + const attachmentBlock = [ + "The user sent the following file attachment(s) with their message:", + "", + ...result.promptSections, + "", + "---", + "", + `User message: ${prompt}`, + ].join("\n"); + prompt = attachmentBlock; + } + } + // Track pending tool_use blocks so we can pair them with results const pendingToolUses = new Map< string, @@ -933,6 +976,16 @@ export class DiscordManager implements IChatManager { if (ackEmoji) { await event.removeReaction(ackEmoji); } + // Clean up downloaded attachment files if configured + if ( + attachmentDownloadedPaths.length > 0 && + attachmentConfig?.cleanup_after_processing !== false + ) { + await DiscordManager.cleanupAttachments( + attachmentDownloadedPaths, + logger as ChatConnectorLogger, + ); + } } } @@ -1136,4 +1189,166 @@ export class DiscordManager implements IChatManager { return agent.working_directory.root; } + + // =========================================================================== + // Attachment Processing + // =========================================================================== + + /** Maximum characters to inline for text/code file content */ + private static readonly TEXT_INLINE_MAX_CHARS = 50_000; + + /** + * Check if a content type matches a MIME pattern (supports wildcards like "image/*") + */ + private static matchesMimePattern(contentType: string, pattern: string): boolean { + const ct = contentType.toLowerCase().split(";")[0].trim(); + const pat = pattern.toLowerCase().trim(); + if (pat === ct) return true; + if (pat.endsWith("/*")) { + const prefix = pat.slice(0, -1); // "image/*" → "image/" + return ct.startsWith(prefix); + } + return false; + } + + /** + * Process file attachments: download, categorize, and prepare prompt sections. + * + * - Text/code files are inlined directly into the prompt + * - Images and PDFs are saved to disk so the agent can use its Read tool + * + * Returns prompt sections to prepend, paths of downloaded files for cleanup, + * and a list of skipped files with reasons. + */ + private static async processAttachments( + attachments: DiscordAttachmentInfo[], + config: DiscordAttachments, + workingDir: string | undefined, + logger: ChatConnectorLogger, + ): Promise<{ + promptSections: string[]; + downloadedPaths: string[]; + skippedFiles: { name: string; reason: string }[]; + }> { + const promptSections: string[] = []; + const downloadedPaths: string[] = []; + const skippedFiles: { name: string; reason: string }[] = []; + const maxBytes = config.max_file_size_mb * 1024 * 1024; + + // Limit to max_files_per_message + const toProcess = attachments.slice(0, config.max_files_per_message); + if (attachments.length > config.max_files_per_message) { + const skipped = attachments.slice(config.max_files_per_message); + for (const a of skipped) { + skippedFiles.push({ name: a.name, reason: "exceeded max_files_per_message" }); + } + } + + // Create timestamp directory for binary downloads + const timestamp = Date.now().toString(); + + for (const attachment of toProcess) { + // Check allowed types + const allowed = config.allowed_types.some((pattern) => + DiscordManager.matchesMimePattern(attachment.contentType, pattern), + ); + if (!allowed) { + skippedFiles.push({ + name: attachment.name, + reason: `type ${attachment.contentType} not in allowed_types`, + }); + continue; + } + + // Check file size + if (attachment.size > maxBytes) { + skippedFiles.push({ + name: attachment.name, + reason: `size ${attachment.size} exceeds ${config.max_file_size_mb}MB limit`, + }); + continue; + } + + try { + if (attachment.category === "text") { + // Text/code: download and inline + const response = await fetch(attachment.url); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + let text = await response.text(); + if (text.length > DiscordManager.TEXT_INLINE_MAX_CHARS) { + text = `${text.substring(0, DiscordManager.TEXT_INLINE_MAX_CHARS)}\n... [truncated at ${DiscordManager.TEXT_INLINE_MAX_CHARS} chars]`; + } + promptSections.push( + `--- File: ${attachment.name} (${attachment.contentType}) ---\n${text}\n--- End of ${attachment.name} ---`, + ); + } else { + // Image/PDF: download to disk + if (!workingDir) { + skippedFiles.push({ + name: attachment.name, + reason: "no working_directory configured for binary attachments", + }); + continue; + } + const response = await fetch(attachment.url); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const buffer = Buffer.from(await response.arrayBuffer()); + const downloadDir = join(workingDir, config.download_dir, timestamp); + await mkdir(downloadDir, { recursive: true }); + const filePath = join(downloadDir, attachment.name); + await writeFile(filePath, buffer); + downloadedPaths.push(filePath); + + const typeLabel = attachment.category === "image" ? "Image" : "PDF"; + promptSections.push( + `[${typeLabel} attached: ${filePath}] (Use the Read tool to view this file)`, + ); + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + logger.warn(`Failed to process attachment ${attachment.name}: ${errMsg}`); + skippedFiles.push({ + name: attachment.name, + reason: `download/processing failed: ${errMsg}`, + }); + } + } + + return { promptSections, downloadedPaths, skippedFiles }; + } + + /** + * Clean up downloaded attachment files after processing + */ + private static async cleanupAttachments( + paths: string[], + logger: ChatConnectorLogger, + ): Promise { + const parentDirs = new Set(); + for (const filePath of paths) { + try { + await rm(filePath); + // Track parent directory for cleanup + const lastSlash = filePath.lastIndexOf("/"); + if (lastSlash > 0) { + parentDirs.add(filePath.substring(0, lastSlash)); + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + logger.debug(`Failed to clean up attachment file ${filePath}: ${errMsg}`); + } + } + // Try to remove empty timestamp directories + for (const dir of parentDirs) { + try { + await rm(dir, { recursive: true }); + } catch { + // Directory may not be empty or already removed — ignore + } + } + } } diff --git a/packages/discord/src/types.ts b/packages/discord/src/types.ts index 8d000512..8914dab4 100644 --- a/packages/discord/src/types.ts +++ b/packages/discord/src/types.ts @@ -280,6 +280,33 @@ export interface DiscordReplyPayload { embeds: DiscordReplyEmbed[]; } +// ============================================================================= +// Attachment Types +// ============================================================================= + +/** + * Category of a Discord file attachment based on its MIME type + */ +export type AttachmentCategory = "image" | "pdf" | "text" | "unsupported"; + +/** + * Metadata for a non-voice file attachment from a Discord message + */ +export interface DiscordAttachmentInfo { + /** Discord attachment ID */ + id: string; + /** Original filename */ + name: string; + /** Download URL */ + url: string; + /** MIME content type */ + contentType: string; + /** File size in bytes */ + size: number; + /** Categorized type for processing */ + category: AttachmentCategory; +} + // ============================================================================= // Event Types // ============================================================================= @@ -366,6 +393,8 @@ export interface DiscordConnectorEventMap { voiceAttachmentUrl?: string; /** Filename of the voice message attachment */ voiceAttachmentName?: string; + /** Non-voice file attachments (images, PDFs, text files) */ + attachments?: DiscordAttachmentInfo[]; }; /** Function to send a reply in the same channel (text or embed) */ reply: (content: string | DiscordReplyPayload) => Promise; From e4fa3203498235d729d53ce2f1ef72658187b111 Mon Sep 17 00:00:00 2001 From: agent Date: Mon, 2 Mar 2026 17:04:33 -0800 Subject: [PATCH 12/48] refactor(discord): replace final_answer_only + concise_mode with assistant_messages enum Replace two blunt output controls with a single `assistant_messages` enum ("answers" | "all") that classifies turns by whether they contain tool_use blocks. Answer turns (no tool_use) are sent immediately; reasoning turns (text + tool_use) are suppressed in "answers" mode. This eliminates message buffering, removes the concise-mode system prompt injection that degraded answer quality, and fixes the common "no additional output to share" fallback. Also flips `result_summary` default to `true` so task stats are always shown. Co-Authored-By: Claude Opus 4.6 --- .../core/src/config/__tests__/schema.test.ts | 5 +- packages/core/src/config/schema.ts | 13 +- .../src/__tests__/discord-connector.test.ts | 5 +- packages/discord/src/__tests__/logger.test.ts | 5 +- .../discord/src/__tests__/manager.test.ts | 213 +++++++++++++----- packages/discord/src/manager.ts | 59 ++--- 6 files changed, 186 insertions(+), 114 deletions(-) diff --git a/packages/core/src/config/__tests__/schema.test.ts b/packages/core/src/config/__tests__/schema.test.ts index 6f7bec89..7427df6c 100644 --- a/packages/core/src/config/__tests__/schema.test.ts +++ b/packages/core/src/config/__tests__/schema.test.ts @@ -1708,13 +1708,12 @@ describe("AgentChatDiscordSchema", () => { tool_results: true, tool_result_max_length: 900, system_status: true, - result_summary: false, + result_summary: true, errors: true, typing_indicator: true, acknowledge_emoji: "👀", - final_answer_only: true, + assistant_messages: "answers", progress_indicator: true, - concise_mode: true, }); } }); diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index a3c3b531..a5e04d18 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -620,24 +620,23 @@ export const ChatOutputSchema = z.object({ * tool_results: true * tool_result_max_length: 900 * system_status: true - * result_summary: false + * result_summary: true * errors: true * typing_indicator: true + * assistant_messages: answers * ``` */ export const DiscordOutputSchema = ChatOutputSchema.extend({ - /** Show a summary embed when the agent finishes a turn (cost, tokens, turns) (default: false) */ - result_summary: z.boolean().optional().default(false), + /** Show a summary embed when the agent finishes a turn (cost, tokens, turns) (default: true) */ + result_summary: z.boolean().optional().default(true), /** Show typing indicator while the agent is processing (default: true) */ typing_indicator: z.boolean().optional().default(true), /** Emoji to react with when a message is received (empty string to disable, default: "👀") */ acknowledge_emoji: z.string().optional().default("👀"), - /** Only send the final assistant message, not intermediate turns (default: true) */ - final_answer_only: z.boolean().optional().default(true), + /** Which assistant turns to send: "answers" (no tool_use blocks) or "all" (default: "answers") */ + assistant_messages: z.enum(["answers", "all"]).optional().default("answers"), /** Show a progress indicator embed with tool names while working (default: true) */ progress_indicator: z.boolean().optional().default(true), - /** Inject concise-mode system prompt to reduce verbosity (default: true) */ - concise_mode: z.boolean().optional().default(true), }); /** diff --git a/packages/discord/src/__tests__/discord-connector.test.ts b/packages/discord/src/__tests__/discord-connector.test.ts index 08525717..e77c19a9 100644 --- a/packages/discord/src/__tests__/discord-connector.test.ts +++ b/packages/discord/src/__tests__/discord-connector.test.ts @@ -124,13 +124,12 @@ function createMockDiscordConfig(): AgentChatDiscord { tool_results: true, tool_result_max_length: 900, system_status: true, - result_summary: false, + result_summary: true, typing_indicator: true, errors: true, acknowledge_emoji: "eyes", - final_answer_only: true, + assistant_messages: "answers" as const, progress_indicator: true, - concise_mode: true, }, guilds: [ { diff --git a/packages/discord/src/__tests__/logger.test.ts b/packages/discord/src/__tests__/logger.test.ts index 6f6fbf9b..6dfe32e8 100644 --- a/packages/discord/src/__tests__/logger.test.ts +++ b/packages/discord/src/__tests__/logger.test.ts @@ -20,13 +20,12 @@ function createMockDiscordConfig(logLevel: DiscordLogLevel = "standard"): AgentC tool_results: true, tool_result_max_length: 900, system_status: true, - result_summary: false, + result_summary: true, typing_indicator: true, errors: true, acknowledge_emoji: "eyes", - final_answer_only: true, + assistant_messages: "answers" as const, progress_indicator: true, - concise_mode: true, }, guilds: [], }; diff --git a/packages/discord/src/__tests__/manager.test.ts b/packages/discord/src/__tests__/manager.test.ts index c0e1b4fa..52306001 100644 --- a/packages/discord/src/__tests__/manager.test.ts +++ b/packages/discord/src/__tests__/manager.test.ts @@ -177,13 +177,12 @@ describe("DiscordManager", () => { tool_results: true, tool_result_max_length: 900, system_status: true, - result_summary: false, + result_summary: true, typing_indicator: true, errors: true, acknowledge_emoji: "eyes", - final_answer_only: true, + assistant_messages: "answers" as const, progress_indicator: true, - concise_mode: true, }, guilds: [], }; @@ -751,9 +750,8 @@ describe("DiscordManager handleMessage pipeline", () => { typing_indicator: true, errors: true, acknowledge_emoji: "", - final_answer_only: true, + assistant_messages: "answers" as const, progress_indicator: false, // disable for cleaner assertions - concise_mode: true, }, guilds: [], }; @@ -870,21 +868,25 @@ describe("DiscordManager handleMessage pipeline", () => { return { event, reply: replyFn, replyWithRef: replyWithRefFn }; } - // ---- finalAnswerOnly: basic text delivery ---- + // ---- answers mode: suppresses reasoning turns, sends answer turns ---- - it("sends only the last assistant message in finalAnswerOnly mode", async () => { + it("suppresses reasoning turns (text + tool_use) in 'answers' mode", async () => { const { manager, connector } = buildManagerWithTrigger(async (...args: unknown[]) => { const options = args[2] as { onMessage?: (m: unknown) => Promise } | undefined; if (options?.onMessage) { - // Two assistant turns with text + // Turn 1: reasoning (text + tool_use) — should be suppressed await options.onMessage({ type: "assistant", message: { id: "msg_1", - stop_reason: "end_turn", - content: [{ type: "text", text: "Checking..." }], + stop_reason: "tool_use", + content: [ + { type: "text", text: "Let me check..." }, + { type: "tool_use", name: "Read", id: "t1", input: { file_path: "/x.txt" } }, + ], }, }); + // Turn 2: answer (text only) — should be sent await options.onMessage({ type: "assistant", message: { @@ -910,15 +912,60 @@ describe("DiscordManager handleMessage pipeline", () => { connector.emit("message", event); await new Promise((resolve) => setTimeout(resolve, 200)); - // Only the LAST assistant message should be delivered + // Only the answer turn (no tool_use) should be delivered const textCalls = reply.mock.calls.filter((call: unknown[]) => typeof call[0] === "string"); expect(textCalls).toHaveLength(1); expect(textCalls[0][0]).toBe("Here is the answer."); }); - // ---- resultText fallback when assistant messages are tool-only ---- + it("sends all answer turns immediately in 'answers' mode", async () => { + const { manager, connector } = buildManagerWithTrigger(async (...args: unknown[]) => { + const options = args[2] as { onMessage?: (m: unknown) => Promise } | undefined; + if (options?.onMessage) { + // Two answer turns (text only, no tool_use) — both should be sent + await options.onMessage({ + type: "assistant", + message: { + id: "msg_1", + stop_reason: "end_turn", + content: [{ type: "text", text: "First answer." }], + }, + }); + await options.onMessage({ + type: "assistant", + message: { + id: "msg_2", + stop_reason: "end_turn", + content: [{ type: "text", text: "Second answer." }], + }, + }); + await options.onMessage({ type: "result", result: "Second answer." }); + } + return { + jobId: "j1b", + agentName: "test-agent", + scheduleName: null, + startedAt: new Date().toISOString(), + success: true, + sessionId: "sid1b", + }; + }); + + await manager.start(); + const { event, reply } = createMessageEvent(); + connector.emit("message", event); + await new Promise((resolve) => setTimeout(resolve, 2500)); // wait for rate limiting + + // Both answer turns should be delivered + const textCalls = reply.mock.calls.filter((call: unknown[]) => typeof call[0] === "string"); + expect(textCalls).toHaveLength(2); + expect(textCalls[0][0]).toBe("First answer."); + expect(textCalls[1][0]).toBe("Second answer."); + }); + + // ---- resultText fallback when all turns are reasoning (tool-only) ---- - it("uses SDK result text as fallback when no assistant text was buffered", async () => { + it("uses SDK result text as fallback when all turns are reasoning", async () => { const { manager, connector } = buildManagerWithTrigger(async (...args: unknown[]) => { const options = args[2] as { onMessage?: (m: unknown) => Promise } | undefined; if (options?.onMessage) { @@ -1186,9 +1233,9 @@ describe("DiscordManager handleMessage pipeline", () => { expect(textCalls[0][0]).toBe("Found 2 files."); }); - // ---- Concise mode system prompt injection ---- + // ---- No system prompt injection (concise_mode removed) ---- - it("injects concise mode system prompt when enabled", async () => { + it("does not inject a systemPromptAppend", async () => { let capturedSystemPrompt: string | undefined; const { manager, connector } = buildManagerWithTrigger(async (...args: unknown[]) => { const options = args[2] as @@ -1221,9 +1268,7 @@ describe("DiscordManager handleMessage pipeline", () => { connector.emit("message", event); await new Promise((resolve) => setTimeout(resolve, 200)); - expect(capturedSystemPrompt).toBeDefined(); - expect(capturedSystemPrompt).toContain("Discord chat"); - expect(capturedSystemPrompt).toContain("final answer"); + expect(capturedSystemPrompt).toBeUndefined(); }); // ---- Session persistence ---- @@ -1325,15 +1370,15 @@ describe("DiscordManager handleMessage pipeline", () => { connector.emit("message", event); await new Promise((resolve) => setTimeout(resolve, 200)); - // The final text "The file has 42 lines." should be the only text sent + // Only the answer turn (no tool_use) should be delivered const textCalls = reply.mock.calls.filter((call: unknown[]) => typeof call[0] === "string"); expect(textCalls).toHaveLength(1); expect(textCalls[0][0]).toBe("The file has 42 lines."); }); - // ---- Non-finalAnswerOnly mode: sends every turn ---- + // ---- "all" mode: sends every assistant turn with text ---- - it("sends every assistant message immediately when finalAnswerOnly is false", async () => { + it("sends every assistant message immediately in 'all' mode", async () => { const { manager, connector } = buildManagerWithTrigger( async (...args: unknown[]) => { const options = args[2] as { onMessage?: (m: unknown) => Promise } | undefined; @@ -1365,7 +1410,7 @@ describe("DiscordManager handleMessage pipeline", () => { sessionId: "sid11", }; }, - // Override: disable finalAnswerOnly + // Override: use "all" mode to send every turn { chat: { discord: { @@ -1380,9 +1425,8 @@ describe("DiscordManager handleMessage pipeline", () => { typing_indicator: true, errors: true, acknowledge_emoji: "", - final_answer_only: false, + assistant_messages: "all" as const, progress_indicator: false, - concise_mode: false, }, guilds: [], }, @@ -1401,6 +1445,79 @@ describe("DiscordManager handleMessage pipeline", () => { expect(textCalls[0][0]).toBe("First turn."); expect(textCalls[1][0]).toBe("Second turn."); }); + + it("sends reasoning turns (text + tool_use) in 'all' mode", async () => { + const { manager, connector } = buildManagerWithTrigger( + async (...args: unknown[]) => { + const options = args[2] as { onMessage?: (m: unknown) => Promise } | undefined; + if (options?.onMessage) { + // Reasoning turn: text + tool_use + await options.onMessage({ + type: "assistant", + message: { + id: "msg_1", + stop_reason: "tool_use", + content: [ + { type: "text", text: "Let me check the file." }, + { type: "tool_use", name: "Read", id: "t1", input: { file_path: "/x.txt" } }, + ], + }, + }); + // Answer turn: text only + await options.onMessage({ + type: "assistant", + message: { + id: "msg_2", + stop_reason: "end_turn", + content: [{ type: "text", text: "The file has 42 lines." }], + }, + }); + await options.onMessage({ type: "result", result: "The file has 42 lines." }); + } + return { + jobId: "j12", + agentName: "test-agent", + scheduleName: null, + startedAt: new Date().toISOString(), + success: true, + sessionId: "sid12", + }; + }, + // Override: use "all" mode to send every turn including reasoning + { + chat: { + discord: { + bot_token_env: "TEST_BOT_TOKEN", + session_expiry_hours: 24, + log_level: "standard", + output: { + tool_results: true, + tool_result_max_length: 900, + system_status: true, + result_summary: false, + typing_indicator: true, + errors: true, + acknowledge_emoji: "", + assistant_messages: "all" as const, + progress_indicator: false, + }, + guilds: [], + }, + }, + } as Partial>, + ); + + await manager.start(); + const { event, reply } = createMessageEvent(); + connector.emit("message", event); + await new Promise((resolve) => setTimeout(resolve, 2500)); // wait for rate limiting + + // Both reasoning and answer turns should be sent in "all" mode + const textCalls = reply.mock.calls.filter((call: unknown[]) => typeof call[0] === "string"); + expect(textCalls).toHaveLength(2); + expect(textCalls[0][0]).toBe("Let me check the file."); + expect(textCalls[1][0]).toBe("The file has 42 lines."); + }); }); // Message handling tests are skipped pending refactor to work with the new architecture @@ -1436,9 +1553,8 @@ describe.skip("DiscordManager message handling", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", - final_answer_only: true, + assistant_messages: "answers" as const, progress_indicator: true, - concise_mode: true, }, guilds: [], }), @@ -1632,9 +1748,8 @@ describe.skip("DiscordManager message handling", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", - final_answer_only: true, + assistant_messages: "answers" as const, progress_indicator: true, - concise_mode: true, }, guilds: [], }), @@ -1790,9 +1905,8 @@ describe.skip("DiscordManager message handling", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", - final_answer_only: true, + assistant_messages: "answers" as const, progress_indicator: true, - concise_mode: true, }, guilds: [], }), @@ -1979,9 +2093,8 @@ describe.skip("DiscordManager message handling", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", - final_answer_only: true, + assistant_messages: "answers" as const, progress_indicator: true, - concise_mode: true, }, guilds: [], }), @@ -2151,9 +2264,8 @@ describe.skip("DiscordManager message handling", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", - final_answer_only: true, + assistant_messages: "answers" as const, progress_indicator: true, - concise_mode: true, }, guilds: [], }), @@ -3254,9 +3366,8 @@ describe.skip("DiscordManager session integration", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", - final_answer_only: true, + assistant_messages: "answers" as const, progress_indicator: true, - concise_mode: true, }, guilds: [], }), @@ -3970,9 +4081,8 @@ describe.skip("DiscordManager lifecycle", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", - final_answer_only: true, + assistant_messages: "answers" as const, progress_indicator: true, - concise_mode: true, }, guilds: [], }), @@ -4125,9 +4235,8 @@ describe.skip("DiscordManager lifecycle", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", - final_answer_only: true, + assistant_messages: "answers" as const, progress_indicator: true, - concise_mode: true, }, guilds: [], }), @@ -4492,9 +4601,8 @@ describe.skip("DiscordManager lifecycle", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", - final_answer_only: true, + assistant_messages: "answers" as const, progress_indicator: true, - concise_mode: true, }, guilds: [], }), @@ -4692,9 +4800,8 @@ describe.skip("DiscordManager output configuration", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", - final_answer_only: true, + assistant_messages: "answers" as const, progress_indicator: true, - concise_mode: true, }, guilds: [], }, @@ -4865,9 +4972,8 @@ describe.skip("DiscordManager output configuration", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", - final_answer_only: true, + assistant_messages: "answers" as const, progress_indicator: true, - concise_mode: true, }, guilds: [], }, @@ -5037,9 +5143,8 @@ describe.skip("DiscordManager output configuration", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", - final_answer_only: true, + assistant_messages: "answers" as const, progress_indicator: true, - concise_mode: true, }, guilds: [], }, @@ -5210,9 +5315,8 @@ describe.skip("DiscordManager output configuration", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", - final_answer_only: true, + assistant_messages: "answers" as const, progress_indicator: true, - concise_mode: true, }, guilds: [], }, @@ -5386,9 +5490,8 @@ describe.skip("DiscordManager output configuration", () => { typing_indicator: true, errors: true, acknowledge_emoji: "eyes", - final_answer_only: true, + assistant_messages: "answers" as const, progress_indicator: true, - concise_mode: true, }, guilds: [], }, @@ -5557,9 +5660,8 @@ describe.skip("DiscordManager output configuration", () => { typing_indicator: true, errors: false, acknowledge_emoji: "eyes", - final_answer_only: true, + assistant_messages: "answers" as const, progress_indicator: true, - concise_mode: true, }, guilds: [], }, @@ -5704,9 +5806,8 @@ describe.skip("DiscordManager output configuration", () => { typing_indicator: true, errors: true, acknowledge_emoji: "", - final_answer_only: true, + assistant_messages: "answers" as const, progress_indicator: true, - concise_mode: true, }, guilds: [], }), diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index 69570a08..997efb22 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -52,17 +52,6 @@ import { transcribeAudio } from "./voice-transcriber.js"; // Constants // ============================================================================= -/** - * System prompt appended for Discord conversations to reduce verbosity. - * Injected when concise_mode is enabled (default: true). - */ -const DISCORD_SYSTEM_PROMPT_APPEND = `You are responding in a Discord chat. Follow these rules strictly: -- Give ONLY your final answer. Do NOT narrate your actions ("Let me check...", "I'll read...") -- Do NOT describe which tools you are using or what you found in intermediate steps -- Do NOT show file diffs or large code blocks unless the user explicitly asked to see code -- Keep responses concise — prefer summaries over exhaustive detail -- IMPORTANT: Always end with a clear text response summarizing the outcome`; - // ============================================================================= // Discord Manager // ============================================================================= @@ -414,19 +403,17 @@ export class DiscordManager implements IChatManager { tool_results: true, tool_result_max_length: 900, system_status: true, - result_summary: false, + result_summary: true, errors: true, typing_indicator: true, acknowledge_emoji: "👀", - final_answer_only: true, + assistant_messages: "answers" as const, progress_indicator: true, - concise_mode: true, }; - // Resolve new output modes (default to true for quiet Discord experience) - const finalAnswerOnly = outputConfig.final_answer_only !== false; - const showProgressIndicator = outputConfig.progress_indicator !== false && finalAnswerOnly; - const conciseMode = outputConfig.concise_mode !== false; + // Resolve output modes + const assistantMessages = outputConfig.assistant_messages ?? "answers"; + const showProgressIndicator = outputConfig.progress_indicator !== false; // Get existing session for this channel (for conversation continuity) const connector = this.connectors.get(qualifiedName); @@ -598,9 +585,6 @@ export class DiscordManager implements IChatManager { // We skip intermediates and deliver the first finalized snapshot per message.id. const deliveredAssistantIds = new Set(); - // Final-answer-only mode: buffer assistant messages and send only the last one - const bufferedAssistantMessages: string[] = []; - // Capture the result text from the SDK's "result" message as a fallback // When all assistant messages are tool-only (no text), this is the last resort let resultText: string | undefined; @@ -614,10 +598,7 @@ export class DiscordManager implements IChatManager { let progressEmbedHandle: ProgressEmbedHandle | null = null; let lastProgressUpdate = 0; - // Effective tool_results setting: suppress in final_answer_only mode unless explicitly configured - const showToolResults = finalAnswerOnly - ? agent.chat?.discord?.output?.tool_results === true - : outputConfig.tool_results; + const showToolResults = outputConfig.tool_results; // Execute job via FleetManager.trigger() through the context // Pass resume option for conversation continuity @@ -627,7 +608,6 @@ export class DiscordManager implements IChatManager { prompt, resume: existingSessionId, injectedMcpServers, - systemPromptAppend: conciseMode ? DISCORD_SYSTEM_PROMPT_APPEND : undefined, onMessage: async (message) => { // Extract text content from assistant messages and stream to Discord if (message.type === "assistant") { @@ -706,11 +686,13 @@ export class DiscordManager implements IChatManager { const content = extractMessageContent(sdkMessage); if (content) { - if (finalAnswerOnly) { - // Buffer assistant messages — only the last one will be sent - bufferedAssistantMessages.push(content); + if (assistantMessages === "answers") { + // Only send turns with no tool_use blocks (answer turns) + if (toolUseBlocks.length === 0) { + await streamer.addMessageAndSend(content); + } } else { - // Legacy behavior: send every assistant turn immediately + // "all" mode: send every turn with text await streamer.addMessageAndSend(content); } } @@ -749,7 +731,7 @@ export class DiscordManager implements IChatManager { } // Show system status messages (e.g., "compacting context...") - if (message.type === "system" && outputConfig.system_status && !finalAnswerOnly) { + if (message.type === "system" && outputConfig.system_status) { const sysMessage = message as { subtype?: string; status?: string | null }; if (sysMessage.subtype === "status" && sysMessage.status) { const statusText = @@ -862,17 +844,10 @@ export class DiscordManager implements IChatManager { } } - // In final-answer-only mode, send the last buffered assistant message. - // If no assistant messages had extractable text (all were tool-only), fall back - // to the SDK's result text which captures the final conversation output. - if (finalAnswerOnly) { - if (bufferedAssistantMessages.length > 0) { - const finalMessage = bufferedAssistantMessages[bufferedAssistantMessages.length - 1]; - await streamer.addMessageAndSend(finalMessage); - } else if (resultText) { - logger.debug("No buffered assistant text — using SDK result text as fallback answer"); - await streamer.addMessageAndSend(resultText); - } + // Fall back to SDK result text if no answer turns produced text + if (!streamer.hasSentMessages() && resultText) { + logger.debug("No answer turns produced text — using SDK result text as fallback"); + await streamer.addMessageAndSend(resultText); } // Flush any remaining buffered content From 0c34ec419c76b6186ac46b2bd956b570d731d873 Mon Sep 17 00:00:00 2001 From: agent Date: Mon, 2 Mar 2026 17:05:21 -0800 Subject: [PATCH 13/48] Fix Discord attachment path collisions and filename overwrites --- .../discord/src/__tests__/attachments.test.ts | 109 +++++++++++++++++- packages/discord/src/manager.ts | 11 +- 2 files changed, 112 insertions(+), 8 deletions(-) diff --git a/packages/discord/src/__tests__/attachments.test.ts b/packages/discord/src/__tests__/attachments.test.ts index b02487bf..300d34e6 100644 --- a/packages/discord/src/__tests__/attachments.test.ts +++ b/packages/discord/src/__tests__/attachments.test.ts @@ -7,7 +7,7 @@ import { mkdir, readFile, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { DiscordAttachmentsSchema } from "@herdctl/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DiscordConnector } from "../discord-connector.js"; @@ -344,9 +344,10 @@ describe("processAttachments", () => { expect(result.promptSections).toHaveLength(1); expect(result.promptSections[0]).toContain("[Image attached:"); - expect(result.promptSections[0]).toContain("screenshot.png"); + expect(result.promptSections[0]).toContain("att_1-screenshot.png"); expect(result.promptSections[0]).toContain("Use the Read tool"); expect(result.downloadedPaths).toHaveLength(1); + expect(basename(result.downloadedPaths[0])).toBe("att_1-screenshot.png"); // Verify file was actually written const fileContents = await readFile(result.downloadedPaths[0]); @@ -374,8 +375,9 @@ describe("processAttachments", () => { expect(result.promptSections).toHaveLength(1); expect(result.promptSections[0]).toContain("[PDF attached:"); - expect(result.promptSections[0]).toContain("document.pdf"); + expect(result.promptSections[0]).toContain("att_1-document.pdf"); expect(result.downloadedPaths).toHaveLength(1); + expect(basename(result.downloadedPaths[0])).toBe("att_1-document.pdf"); vi.unstubAllGlobals(); }); @@ -426,6 +428,107 @@ describe("processAttachments", () => { vi.unstubAllGlobals(); }); + + it("uses different download directories across attachment runs", async () => { + const imageData = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: async () => + imageData.buffer.slice(imageData.byteOffset, imageData.byteOffset + imageData.byteLength), + }), + ); + + const first = await processAttachments( + [ + makeAttachment({ + id: "run1", + name: "one.png", + contentType: "image/png", + category: "image", + }), + ], + defaultConfig, + testDir, + mockLogger, + ); + const second = await processAttachments( + [ + makeAttachment({ + id: "run2", + name: "two.png", + contentType: "image/png", + category: "image", + }), + ], + defaultConfig, + testDir, + mockLogger, + ); + + expect(first.downloadedPaths).toHaveLength(1); + expect(second.downloadedPaths).toHaveLength(1); + expect(dirname(first.downloadedPaths[0])).not.toBe(dirname(second.downloadedPaths[0])); + + vi.unstubAllGlobals(); + }); + + it("preserves both binary attachments when filenames are duplicated", async () => { + const firstPayload = Buffer.from("first"); + const secondPayload = Buffer.from("second"); + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce({ + ok: true, + arrayBuffer: async () => + firstPayload.buffer.slice( + firstPayload.byteOffset, + firstPayload.byteOffset + firstPayload.byteLength, + ), + }) + .mockResolvedValueOnce({ + ok: true, + arrayBuffer: async () => + secondPayload.buffer.slice( + secondPayload.byteOffset, + secondPayload.byteOffset + secondPayload.byteLength, + ), + }), + ); + + const duplicatedName = "same-name.png"; + const attachments = [ + makeAttachment({ + id: "att_101", + name: duplicatedName, + contentType: "image/png", + category: "image", + }), + makeAttachment({ + id: "att_202", + name: duplicatedName, + contentType: "image/png", + category: "image", + }), + ]; + + const result = await processAttachments(attachments, defaultConfig, testDir, mockLogger); + + expect(result.downloadedPaths).toHaveLength(2); + expect(result.downloadedPaths[0]).not.toBe(result.downloadedPaths[1]); + expect(basename(result.downloadedPaths[0])).toBe("att_101-same-name.png"); + expect(basename(result.downloadedPaths[1])).toBe("att_202-same-name.png"); + + const firstBytes = await readFile(result.downloadedPaths[0]); + const secondBytes = await readFile(result.downloadedPaths[1]); + expect(Buffer.from(firstBytes)).toEqual(firstPayload); + expect(Buffer.from(secondBytes)).toEqual(secondPayload); + + vi.unstubAllGlobals(); + }); }); // ============================================================================= diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index 997efb22..79ff0105 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -10,8 +10,9 @@ * @module manager */ +import { randomUUID } from "node:crypto"; import { mkdir, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { type ChatConnectorLogger, @@ -1219,8 +1220,8 @@ export class DiscordManager implements IChatManager { } } - // Create timestamp directory for binary downloads - const timestamp = Date.now().toString(); + // Create one collision-resistant directory per message processing run. + const messageDownloadDir = randomUUID(); for (const attachment of toProcess) { // Check allowed types @@ -1272,9 +1273,9 @@ export class DiscordManager implements IChatManager { throw new Error(`HTTP ${response.status}`); } const buffer = Buffer.from(await response.arrayBuffer()); - const downloadDir = join(workingDir, config.download_dir, timestamp); + const downloadDir = join(workingDir, config.download_dir, messageDownloadDir); await mkdir(downloadDir, { recursive: true }); - const filePath = join(downloadDir, attachment.name); + const filePath = join(downloadDir, `${attachment.id}-${basename(attachment.name)}`); await writeFile(filePath, buffer); downloadedPaths.push(filePath); From 59ef97800d935f3ef02c55f979f6c479a0b15e5b Mon Sep 17 00:00:00 2001 From: agent Date: Mon, 2 Mar 2026 19:55:00 -0800 Subject: [PATCH 14/48] fix(core): increase CLI session startup timeout from 15s to 60s Agents with multiple MCP servers (e.g. gmail, calendar, bear, gdrive, google-contacts) can exceed 15s during initialization, causing the session file watcher to time out before Claude CLI creates the session. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/runner/runtime/cli-runtime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/runner/runtime/cli-runtime.ts b/packages/core/src/runner/runtime/cli-runtime.ts index 2d63401e..7d50ed2c 100644 --- a/packages/core/src/runner/runtime/cli-runtime.ts +++ b/packages/core/src/runner/runtime/cli-runtime.ts @@ -272,7 +272,7 @@ export class CLIRuntime implements RuntimeInterface { // When starting new session, wait for a NEW file created after process start logger.debug("Waiting for new session file..."); sessionFilePath = await waitForNewSessionFile(sessionDir, processStartTime, { - timeoutMs: 15000, // Increase timeout to 15 seconds for debugging + timeoutMs: 60000, // Allow up to 60s for MCP servers to initialize pollIntervalMs: 200, }); logger.debug(`New session, watching newly created file: ${sessionFilePath}`); From 59cf84225bee5cd29c268d4261d873cb289266ee Mon Sep 17 00:00:00 2001 From: agent Date: Tue, 3 Mar 2026 11:42:27 -0800 Subject: [PATCH 15/48] feat(core): add injected MCP server support to CLI runtime CLI runtime silently ignored injectedMcpServers (file sender for Discord/Slack uploads). Reuse existing mcp-http-bridge infrastructure: start HTTP bridges on localhost for each injected server and pass them via --mcp-config as HTTP-type MCP servers. Also includes: - Fix --mcp-config wrapper (mcpServers key was missing, caused CLI hang) - Synthetic result message for CLI runtime usage tracking - Discord embed content extraction in conversation context builder - discord-changes.md documenting all fork changes Co-Authored-By: Claude Opus 4.6 --- .changeset/cli-runtime-injected-mcp.md | 11 + discord-changes.md | 250 ++++++++++++++++++ .../core/src/runner/runtime/cli-runtime.ts | 134 +++++++++- packages/core/src/runner/runtime/interface.ts | 2 +- packages/core/src/runner/types.ts | 2 +- packages/discord/src/mention-handler.ts | 21 ++ 6 files changed, 417 insertions(+), 3 deletions(-) create mode 100644 .changeset/cli-runtime-injected-mcp.md create mode 100644 discord-changes.md diff --git a/.changeset/cli-runtime-injected-mcp.md b/.changeset/cli-runtime-injected-mcp.md new file mode 100644 index 00000000..18f1bc66 --- /dev/null +++ b/.changeset/cli-runtime-injected-mcp.md @@ -0,0 +1,11 @@ +--- +"@herdctl/core": minor +--- + +Add injected MCP server support to CLI runtime via HTTP bridges + +CLI runtime now supports `injectedMcpServers` (e.g., file sender for Discord/Slack uploads). +Previously only SDK and Docker runtimes handled injected MCP servers — CLI silently ignored them. + +The fix reuses existing `mcp-http-bridge.ts` infrastructure: starts HTTP bridges on localhost +for each injected server and passes them via `--mcp-config` as HTTP-type MCP servers. diff --git a/discord-changes.md b/discord-changes.md new file mode 100644 index 00000000..c184d579 --- /dev/null +++ b/discord-changes.md @@ -0,0 +1,250 @@ +# Discord Changes — herdctl Fork + +This document covers all Discord-related changes in the oheckmann74/herdctl fork compared to upstream herdctl. Changes are listed in reverse chronological order (newest first). + +--- + +## 1. Embed Content in Conversation Context + +**File:** `packages/discord/src/mention-handler.ts` + +**Problem:** When a scheduled job's output was posted to Discord via a hook embed, and the user replied to it ("draft an answer"), the agent's Discord session had no idea what the user was referring to. The conversation context builder only read `message.content` — the plain text body of a Discord message. Discord embeds (structured content with titles, fields, descriptions) live in `message.embeds`, which was completely ignored. + +This meant embed-only messages (from herdctl hooks, link previews, other bots) were filtered out as "empty" and invisible to the agent. + +**Change:** The `processMessage()` function now extracts text from embed titles, descriptions, and fields and appends it to the message content. Any embed in the channel history — from hooks, link previews, or other bots — becomes part of the conversation context. + +**Why this matters:** Scheduled jobs (cron, interval) run in isolated CLI sessions with no Discord channel context. The `after_run` hook posts their output to Discord as an embed. Without this fix, replying to a hook notification was a dead end — the agent couldn't see what it had reported. + +--- + +## 2. File Attachments + +**Files:** `packages/discord/src/manager.ts`, `packages/discord/src/types.ts`, `packages/core/src/config/schema.ts` + +**Problem:** Users couldn't send images, PDFs, or text files to agents via Discord. Attachments were silently ignored. + +**Change:** Added full attachment processing pipeline: +- **Text files** (`.txt`, `.json`, `.csv`, etc.) are inlined directly into the prompt — up to 50,000 characters +- **Images and PDFs** are downloaded to a timestamped directory so agents can access them via their Read tool +- **Automatic cleanup** after processing (configurable) +- Collision-safe: concurrent messages with same filenames get isolated directories + +**Config:** +```yaml +chat: + discord: + attachments: + enabled: true + max_files_per_message: 10 + max_file_size_mb: 10 + allowed_types: ["image/*", "application/pdf", "text/*", "application/json"] + cleanup_after_processing: true +``` + +A follow-up commit (dbe4e61) fixed a race condition where concurrent attachments with the same filename could overwrite each other, using timestamp-based directory isolation. + +--- + +## 3. Output Control: `assistant_messages` Enum + +**Files:** `packages/discord/src/manager.ts`, `packages/core/src/config/schema.ts` + +**Problem:** The original Discord output was extremely verbose — every assistant turn (including internal reasoning and tool-use planning) was posted to the channel. Two earlier attempts to fix this (`final_answer_only` + `concise_mode`) introduced complexity: message buffering, system prompt injection that degraded answer quality, and a "no additional output to share" fallback that confused users. + +**Change:** Replaced both boolean flags with a single enum: + +```yaml +chat: + discord: + output: + assistant_messages: "answers" # or "all" +``` + +- `"answers"` (default): Only send turns that contain NO `tool_use` blocks — pure text responses. This is the agent's actual answer. +- `"all"`: Send every turn that has text content, including reasoning during tool use. + +**Why this is better:** +- No message buffering — answer turns are sent immediately +- No system prompt injection — the agent's normal behavior is preserved +- No fallback messages — if a turn has text and no tool use, it's an answer, period +- Simple mental model: you either want just answers or everything + +--- + +## 4. Visual Polish + +**Files:** `packages/discord/src/manager.ts`, `packages/discord/src/commands/help.ts`, `reset.ts`, `status.ts`, `packages/discord/src/types.ts` + +**Problem:** Default embeds were visually noisy — large titles, inconsistent colors, no branding. Tool result embeds took up excessive vertical space. + +**Changes:** +- **Removed titles** from all embeds (progress, tool results, errors, status, summary) — cleaner look +- **Branded footer** on all embeds: `herdctl · agent-name` +- **Refined color palette:** + - Soft violet (`#8B5CF6`) — progress indicators + - Emerald (`#10B981`) — success/completion + - Cool gray (`#6B7280`) — system messages + - Sky blue (`#0EA5E9`) — command output +- **Compact tool results:** collapsed title + fields into a single description line +- **Horizontal result summary** with centered-dot separators instead of inline fields +- **Syntax highlighting** (`ansi`) for Bash tool output +- **Styled slash commands** (`/help`, `/status`, `/reset`) as embeds instead of plain text +- Made `DiscordReplyEmbed.title` optional (was required) + +--- + +## 5. Message Deduplication + +**Files:** `packages/discord/src/manager.ts`, `packages/slack/src/manager.ts` + +**Problem:** The CLI runtime streams output by appending to a JSONL session file. The session watcher picks up intermediate snapshots that have `stop_reason: null` and incomplete text. These were being sent to Discord as partial messages, causing duplicated or garbled output. Users sometimes saw "completed the task but no response" fallback messages. + +**Change:** Skip intermediate JSONL snapshots that lack a complete response. Deliver only finalized snapshots. Deduplicate by `message.id` to prevent the same message from being sent twice. Applied to both Discord and Slack connectors. + +--- + +## 6. Acknowledgement Emoji + +**Files:** `packages/discord/src/manager.ts`, `packages/core/src/config/schema.ts` + +**Problem:** When a user sends a message in Discord, there's no immediate feedback that the bot received it. The agent might take 10-30 seconds before the first response appears, leaving the user wondering if the message was seen. + +**Change:** Bot reacts with a configurable emoji (default: 👀) on message receipt. The reaction is removed once the response is sent. + +```yaml +chat: + discord: + output: + acknowledge_emoji: "👀" +``` + +--- + +## 7. Voice Message Transcription + +**Files:** New file `packages/discord/src/voice-transcriber.ts`, `packages/discord/src/manager.ts`, `packages/discord/src/discord-connector.ts`, `packages/core/src/config/schema.ts` + +**Problem:** Discord supports voice messages (audio recordings sent inline). Agents couldn't process them — the audio was ignored and only an empty message came through. + +**Change:** Added voice message detection and transcription: +- Detects Discord voice messages via `MessageFlags.IsVoiceMessage` +- Downloads the audio attachment +- Transcribes via OpenAI Whisper API using native fetch + FormData (no extra dependencies) +- Inserts the transcription as the message content, prefixed with `[Voice message transcription]:` + +```yaml +chat: + discord: + voice: + enabled: true + api_key_env: OPENAI_API_KEY + language: en # optional ISO 639-1 code +``` + +--- + +## 8. File Upload (Agent → Discord) + +**Files:** `packages/discord/src/discord-connector.ts`, `packages/discord/src/manager.ts` + +**Problem:** Agents could receive files but not send them back. An agent that generates an image, PDF, or CSV had no way to deliver it to the Discord channel. + +**Change:** Added `uploadFile()` method to DiscordConnector using Discord.js `AttachmentBuilder`. Wired through `FileSenderContext` and `createFileSenderDef` in the manager so agents can send files back to the originating channel via an injected MCP server. Mirrors existing Slack file upload support. + +--- + +## 9. Typing Indicator Control + +**Files:** `packages/discord/src/manager.ts`, `packages/core/src/config/schema.ts` + +**Problem:** Discord's typing indicator can cause "An unknown error occurred" for long-running jobs. The Discord client has internal timeouts, and continuous typing indicator refreshes can race against rate limits, producing visible errors in the channel. + +**Change:** Added a config option to disable the typing indicator entirely: + +```yaml +chat: + discord: + output: + typing_indicator: false # default: true +``` + +--- + +## 10. Progress Indicator + +**Files:** `packages/discord/src/manager.ts` + +**Problem:** With verbose output suppressed, users had no visibility into what the agent was doing. Long-running jobs (30s+) appeared frozen. + +**Change:** Added a "Working..." embed that updates in place as tools run. Tool names appear as they execute, throttled to 2-second intervals to avoid rate limits. The embed is deleted when the job completes. Controlled by config: + +```yaml +chat: + discord: + output: + progress_indicator: true # default: true +``` + +Uses `replyWithRef()` to get edit/delete handles on the progress embed. + +--- + +## 11. MCP Config Format Fix + +**File:** `packages/core/src/runner/runtime/cli-runtime.ts` + +**Problem:** The CLI runtime passed MCP server configuration to `claude --mcp-config` as inline JSON without the required `mcpServers` wrapper key. The CLI expects `{"mcpServers": {...}}` (same shape as `.mcp.json` files). Without the wrapper, the Claude CLI hung indefinitely during startup — no error, no timeout, just a stuck process. + +This was a latent bug that never manifested because agents' MCP servers were loaded from workspace `.mcp.json` files, not via `--mcp-config`. The self-scheduling feature (which injects the `herdctl-scheduler` MCP server via config) was the first to actually use `--mcp-config`, exposing the bug. + +**Change:** `JSON.stringify(mcpServers)` → `JSON.stringify({ mcpServers })` + +--- + +## 12. Injected MCP Server Support for CLI Runtime (File Upload Fix) + +**File:** `packages/core/src/runner/runtime/cli-runtime.ts` + +**Problem:** The file upload feature (#8) worked for SDK and Docker runtimes but silently failed for CLI runtime agents. The `FileSenderContext` MCP server was passed via `injectedMcpServers`, but CLI runtime completely ignored that field — it only supported static MCP servers from agent config via `--mcp-config`. + +Since all agents in this deployment use CLI runtime (Max plan pricing), no agent could upload files to Discord. The stlpipeline agent would describe generated images in text but never actually upload them. + +**Root cause:** The SDK runtime can host MCP servers in-process (same Node.js process). The CLI runtime spawns `claude` as a separate subprocess — in-process handlers can't cross the process boundary. The container runner already solved this for Docker by starting HTTP bridges (JSON-RPC over HTTP) and passing them as HTTP-type MCP servers. CLI runtime had no equivalent. + +**Change:** Reused the existing `mcp-http-bridge.ts` infrastructure. When CLI runtime receives `injectedMcpServers`: + +1. Starts an HTTP bridge for each injected server (random localhost port) +2. Merges them into `--mcp-config` as `type: "http"` MCP servers pointing to `http://127.0.0.1:/mcp` +3. Auto-adds `mcp____*` to `--allowedTools` if the agent has an allowlist +4. Sets `CLAUDE_CODE_STREAM_CLOSE_TIMEOUT=120000` for file uploads (same as container runner) +5. Cleans up bridges in `finally` block when the CLI process exits + +**Why this matters:** File upload now works across all three runtimes — SDK, Docker, and CLI. The same `InjectedMcpServerDef` pattern works everywhere. + +--- + +## Summary of Config Options Added + +```yaml +chat: + discord: + output: + assistant_messages: "answers" # "answers" | "all" (replaces final_answer_only + concise_mode) + result_summary: true # show summary embed after job completes + typing_indicator: true # show typing indicator while processing + progress_indicator: true # show updating "Working..." embed + acknowledge_emoji: "👀" # react on message receipt + + attachments: + enabled: true + max_files_per_message: 10 + max_file_size_mb: 10 + allowed_types: ["image/*", "application/pdf", "text/*", "application/json"] + cleanup_after_processing: true + + voice: + enabled: true + api_key_env: OPENAI_API_KEY + language: en +``` diff --git a/packages/core/src/runner/runtime/cli-runtime.ts b/packages/core/src/runner/runtime/cli-runtime.ts index 7d50ed2c..0569baac 100644 --- a/packages/core/src/runner/runtime/cli-runtime.ts +++ b/packages/core/src/runner/runtime/cli-runtime.ts @@ -21,6 +21,7 @@ import type { SDKMessage } from "../types.js"; import { getCliSessionDir, getCliSessionFile, waitForNewSessionFile } from "./cli-session-path.js"; import { CLISessionWatcher } from "./cli-session-watcher.js"; import type { RuntimeExecuteOptions, RuntimeInterface } from "./interface.js"; +import { type McpHttpBridge, startMcpHttpBridge } from "./mcp-http-bridge.js"; const logger = createLogger("CLIRuntime"); @@ -177,12 +178,67 @@ export class CLIRuntime implements RuntimeInterface { // Add MCP servers if specified // Transform agent config format to SDK format and serialize to JSON + // Claude CLI expects: {"mcpServers": { ... }} (same shape as .mcp.json) if (options.agent.mcp_servers && Object.keys(options.agent.mcp_servers).length > 0) { const mcpServers = transformMcpServers(options.agent.mcp_servers); - const mcpConfig = JSON.stringify(mcpServers); + const mcpConfig = JSON.stringify({ mcpServers }); args.push("--mcp-config", mcpConfig); } + // Start HTTP bridges for injected MCP servers (e.g., file sender) + // Same pattern as container-runner: expose in-process handlers via HTTP, + // then pass as HTTP-type MCP servers in --mcp-config + const bridges: McpHttpBridge[] = []; + if (options.injectedMcpServers && Object.keys(options.injectedMcpServers).length > 0) { + for (const [name, def] of Object.entries(options.injectedMcpServers)) { + const bridge = await startMcpHttpBridge(def); + bridges.push(bridge); + + // Build or extend the --mcp-config to include this HTTP server + // Find existing --mcp-config arg index to merge with it + const mcpConfigIdx = args.indexOf("--mcp-config"); + let mcpConfig: { mcpServers: Record }; + + if (mcpConfigIdx !== -1 && mcpConfigIdx + 1 < args.length) { + // Parse existing config and add the bridge + mcpConfig = JSON.parse(args[mcpConfigIdx + 1]); + } else { + mcpConfig = { mcpServers: {} }; + } + + mcpConfig.mcpServers[name] = { + type: "http", + url: `http://127.0.0.1:${bridge.port}/mcp`, + }; + + const configJson = JSON.stringify(mcpConfig); + if (mcpConfigIdx !== -1) { + args[mcpConfigIdx + 1] = configJson; + } else { + args.push("--mcp-config", configJson); + } + + logger.debug(`Started MCP HTTP bridge for '${name}' on port ${bridge.port}`); + } + + // Auto-add injected MCP tool patterns to allowedTools + const allowedToolsIdx = args.indexOf("--allowedTools"); + if (allowedToolsIdx !== -1 && allowedToolsIdx + 1 < args.length) { + const existing = args[allowedToolsIdx + 1]; + const injectedPatterns = Object.keys(options.injectedMcpServers).map( + (name) => `mcp__${name}__*`, + ); + args[allowedToolsIdx + 1] = [existing, ...injectedPatterns].join(","); + } + + // File uploads via MCP tools can take longer than the default 60s timeout + if (options.injectedMcpServers["herdctl-file-sender"]) { + if (!process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT) { + process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = "120000"; + } + } + } + // Add session options if (options.resume) { args.push("--resume", options.resume); @@ -202,6 +258,36 @@ export class CLIRuntime implements RuntimeInterface { let watcher: CLISessionWatcher | undefined; let hasError = false; + // Track usage stats across all assistant turns for synthetic result message + let totalInputTokens = 0; + let totalOutputTokens = 0; + let numTurns = 0; + let lastAssistantText = ""; + + // Helper to accumulate usage from each assistant message + const trackAssistantUsage = (message: SDKMessage) => { + numTurns++; + const msg = message as { + message?: { + content?: Array<{ type: string; text?: string }>; + usage?: { input_tokens?: number; output_tokens?: number }; + }; + }; + const usage = msg.message?.usage; + if (usage) { + totalInputTokens += usage.input_tokens ?? 0; + totalOutputTokens += usage.output_tokens ?? 0; + } + // Capture last text content for result fallback + const content = msg.message?.content; + if (Array.isArray(content)) { + const textParts = content.filter((b) => b.type === "text" && b.text).map((b) => b.text!); + if (textParts.length > 0) { + lastAssistantText = textParts.join(""); + } + } + }; + try { // Determine working directory root for cwd const working_directory = options.agent.working_directory; @@ -330,10 +416,16 @@ export class CLIRuntime implements RuntimeInterface { logger.debug("Yielded synthetic system message with session ID"); // Stream messages from the watcher as they arrive + let gotResultMessage = false; for await (const message of watcher.watch()) { logger.debug(`Received message type: ${message.type}`); yield message; + // Track assistant turn usage for synthetic result message + if (message.type === "assistant") { + trackAssistantUsage(message); + } + // Track errors if (message.type === "error") { hasError = true; @@ -342,6 +434,7 @@ export class CLIRuntime implements RuntimeInterface { // If this is a result message, we're done if (message.type === "result") { logger.debug("Got result message, stopping"); + gotResultMessage = true; break; } } @@ -366,10 +459,19 @@ export class CLIRuntime implements RuntimeInterface { logger.debug(`Yielding remaining message type: ${message.type}`); yield message; + // Track assistant turn usage for synthetic result message + if (message.type === "assistant") { + trackAssistantUsage(message); + } + // Track errors if (message.type === "error") { hasError = true; } + + if (message.type === "result") { + gotResultMessage = true; + } } // If process failed and we didn't yield an error message, create one @@ -380,6 +482,27 @@ export class CLIRuntime implements RuntimeInterface { code: `EXIT_${exitCode}`, }; } + + // Synthesize a result message for CLI runtime + // The Claude CLI doesn't emit "result" messages like the SDK does, + // so we aggregate usage stats from assistant turns and emit one. + if (!gotResultMessage) { + const durationMs = Date.now() - processStartTime; + logger.debug( + `Emitting synthetic result: ${numTurns} turns, ${totalInputTokens}+${totalOutputTokens} tokens, ${durationMs}ms`, + ); + yield { + type: "result", + result: lastAssistantText || "", + is_error: hasError, + duration_ms: durationMs, + num_turns: numTurns, + usage: { + input_tokens: totalInputTokens, + output_tokens: totalOutputTokens, + }, + }; + } } catch (error) { // Handle process errors if (error && typeof error === "object" && "code" in error) { @@ -415,6 +538,15 @@ export class CLIRuntime implements RuntimeInterface { } finally { // Cleanup watcher?.stop(); + + // Close HTTP bridges for injected MCP servers + for (const bridge of bridges) { + try { + await bridge.close(); + } catch (err) { + logger.error(`Failed to close MCP HTTP bridge: ${err}`); + } + } } } } diff --git a/packages/core/src/runner/runtime/interface.ts b/packages/core/src/runner/runtime/interface.ts index 1a4e9502..813d1472 100644 --- a/packages/core/src/runner/runtime/interface.ts +++ b/packages/core/src/runner/runtime/interface.ts @@ -31,7 +31,7 @@ export interface RuntimeExecuteOptions { /** AbortController for cancellation support */ abortController?: AbortController; - /** MCP servers to inject at runtime (SDK and Docker runtimes) */ + /** MCP servers to inject at runtime (all runtimes: SDK, CLI, Docker) */ injectedMcpServers?: Record; /** Text to append to the agent's system prompt for this run */ diff --git a/packages/core/src/runner/types.ts b/packages/core/src/runner/types.ts index da6b47db..6d3d03d9 100644 --- a/packages/core/src/runner/types.ts +++ b/packages/core/src/runner/types.ts @@ -35,7 +35,7 @@ export interface RunnerOptions { outputToFile?: boolean; /** AbortController for canceling the execution */ abortController?: AbortController; - /** MCP servers to inject at runtime (SDK and Docker runtimes) */ + /** MCP servers to inject at runtime (all runtimes: SDK, CLI, Docker) */ injectedMcpServers?: Record; /** Text to append to the agent's system prompt for this run */ systemPromptAppend?: string; diff --git a/packages/discord/src/mention-handler.ts b/packages/discord/src/mention-handler.ts index e7cdb4be..9cc6d3c8 100644 --- a/packages/discord/src/mention-handler.ts +++ b/packages/discord/src/mention-handler.ts @@ -231,6 +231,27 @@ export function processMessage(message: Message, botUserId: string): ContextMess let content = stripBotMention(message.content, botUserId); content = stripBotRoleMentions(content, message, botUserId); + // Extract text from embeds so rich content (hook notifications, link + // previews, other bot output) is visible in conversation context + if (message.embeds?.length > 0) { + const embedTexts: string[] = []; + for (const embed of message.embeds) { + const parts: string[] = []; + if (embed.title) parts.push(embed.title); + if (embed.description) parts.push(embed.description); + if (embed.fields?.length) { + for (const field of embed.fields) { + parts.push(`${field.name}: ${field.value}`); + } + } + if (parts.length > 0) embedTexts.push(parts.join("\n")); + } + if (embedTexts.length > 0) { + const embedContent = embedTexts.join("\n\n"); + content = content ? `${content}\n\n${embedContent}` : embedContent; + } + } + return { authorId: message.author.id, authorName: message.author.displayName ?? message.author.username, From 1562090e81316d6f936e0429233f8f7338821db3 Mon Sep 17 00:00:00 2001 From: agent Date: Tue, 3 Mar 2026 21:29:29 -0800 Subject: [PATCH 16/48] feat(discord): buffer file uploads to attach below answer text Files uploaded by agents via MCP are now buffered and attached to the next answer message instead of sent as standalone messages above it. Also updates discord-changes.md with corrected descriptions. Co-Authored-By: Claude Opus 4.6 --- discord-changes.md | 201 ++++++++---------- .../discord/src/__tests__/manager.test.ts | 10 +- packages/discord/src/manager.ts | 41 +++- packages/discord/src/types.ts | 10 +- 4 files changed, 140 insertions(+), 122 deletions(-) diff --git a/discord-changes.md b/discord-changes.md index c184d579..acf7fb9d 100644 --- a/discord-changes.md +++ b/discord-changes.md @@ -1,24 +1,14 @@ -# Discord Changes — herdctl Fork +# Discord Connector Improvements — oheckmann74/herdctl Fork -This document covers all Discord-related changes in the oheckmann74/herdctl fork compared to upstream herdctl. Changes are listed in reverse chronological order (newest first). +This document covers Discord-related changes in the `oheckmann74/herdctl` fork compared to upstream. All changes are production-tested across three agents running on CLI runtime with Max plan pricing. ---- - -## 1. Embed Content in Conversation Context - -**File:** `packages/discord/src/mention-handler.ts` - -**Problem:** When a scheduled job's output was posted to Discord via a hook embed, and the user replied to it ("draft an answer"), the agent's Discord session had no idea what the user was referring to. The conversation context builder only read `message.content` — the plain text body of a Discord message. Discord embeds (structured content with titles, fields, descriptions) live in `message.embeds`, which was completely ignored. - -This meant embed-only messages (from herdctl hooks, link previews, other bots) were filtered out as "empty" and invisible to the agent. - -**Change:** The `processMessage()` function now extracts text from embed titles, descriptions, and fields and appends it to the message content. Any embed in the channel history — from hooks, link previews, or other bots — becomes part of the conversation context. - -**Why this matters:** Scheduled jobs (cron, interval) run in isolated CLI sessions with no Discord channel context. The `after_run` hook posts their output to Discord as an embed. Without this fix, replying to a hook notification was a dead end — the agent couldn't see what it had reported. +Changes are grouped by area: input handling, output control, UX polish, and core runtime fixes that unblock Discord features. --- -## 2. File Attachments +## Input: What agents can receive + +### 1. File Attachments (User → Agent) **Files:** `packages/discord/src/manager.ts`, `packages/discord/src/types.ts`, `packages/core/src/config/schema.ts` @@ -26,11 +16,10 @@ This meant embed-only messages (from herdctl hooks, link previews, other bots) w **Change:** Added full attachment processing pipeline: - **Text files** (`.txt`, `.json`, `.csv`, etc.) are inlined directly into the prompt — up to 50,000 characters -- **Images and PDFs** are downloaded to a timestamped directory so agents can access them via their Read tool +- **Images and PDFs** are downloaded to a UUID-isolated directory so agents can access them via their Read tool - **Automatic cleanup** after processing (configurable) -- Collision-safe: concurrent messages with same filenames get isolated directories +- Collision-safe: each message gets a UUID-based download directory, so concurrent messages with the same filenames don't conflict -**Config:** ```yaml chat: discord: @@ -42,17 +31,48 @@ chat: cleanup_after_processing: true ``` -A follow-up commit (dbe4e61) fixed a race condition where concurrent attachments with the same filename could overwrite each other, using timestamp-based directory isolation. +### 2. Voice Message Transcription + +**Files:** New file `packages/discord/src/voice-transcriber.ts`, `packages/discord/src/manager.ts`, `packages/discord/src/discord-connector.ts`, `packages/core/src/config/schema.ts` + +**Problem:** Discord voice messages (audio recordings sent inline in text channels) were silently ignored. Only an empty message came through to the agent. + +**Change:** Added voice message detection and transcription: +- Detects Discord voice messages via `MessageFlags.IsVoiceMessage` +- Downloads the audio attachment +- Transcribes via OpenAI Whisper API using native `fetch` + `FormData` (Node 18+, no extra dependencies) +- Inserts the transcription as the message content, prefixed with `[Voice message transcription]:` + +```yaml +chat: + discord: + voice: + enabled: true + api_key_env: OPENAI_API_KEY + language: en # optional ISO 639-1 code +``` + +### 3. Embed Content in Conversation Context + +**File:** `packages/discord/src/mention-handler.ts` + +**Problem:** The conversation context builder only read `message.content` — the plain text body of a Discord message. Discord embeds (structured content with titles, fields, descriptions) live in `message.embeds`, which was completely ignored. This meant embed-only messages (from herdctl hooks, link previews, other bots) were filtered out as "empty" and invisible to the agent. + +The practical impact: scheduled jobs (cron, interval) run in isolated CLI sessions with no Discord channel context. The `after_run` hook posts their output to Discord as an embed. Without this fix, replying to a hook notification was a dead end — the agent couldn't see what it had reported. + +**Change:** `processMessage()` now extracts text from embed titles, descriptions, and fields and appends it to the message content. Any embed in the channel history becomes part of the conversation context the agent sees. --- -## 3. Output Control: `assistant_messages` Enum +## Output: What users see + +### 4. Output Control: `assistant_messages` Enum **Files:** `packages/discord/src/manager.ts`, `packages/core/src/config/schema.ts` **Problem:** The original Discord output was extremely verbose — every assistant turn (including internal reasoning and tool-use planning) was posted to the channel. Two earlier attempts to fix this (`final_answer_only` + `concise_mode`) introduced complexity: message buffering, system prompt injection that degraded answer quality, and a "no additional output to share" fallback that confused users. -**Change:** Replaced both boolean flags with a single enum: +**Change:** Replaced both boolean flags with a single enum (`z.enum(["answers", "all"])`): ```yaml chat: @@ -67,50 +87,40 @@ chat: **Why this is better:** - No message buffering — answer turns are sent immediately - No system prompt injection — the agent's normal behavior is preserved -- No fallback messages — if a turn has text and no tool use, it's an answer, period +- No fallback messages — if a turn has text and no tool use, it's an answer - Simple mental model: you either want just answers or everything ---- - -## 4. Visual Polish +### 5. Message Deduplication -**Files:** `packages/discord/src/manager.ts`, `packages/discord/src/commands/help.ts`, `reset.ts`, `status.ts`, `packages/discord/src/types.ts` +**Files:** `packages/discord/src/manager.ts`, `packages/slack/src/manager.ts` -**Problem:** Default embeds were visually noisy — large titles, inconsistent colors, no branding. Tool result embeds took up excessive vertical space. +**Problem:** The CLI runtime streams output by appending to a JSONL session file. The session watcher picks up intermediate snapshots that have `stop_reason: null` and incomplete text. Neither the CLI runtime nor the SDK runtime deduplicates messages before passing them to the `onMessage` callback — chat managers receive every snapshot raw. Without filtering, these intermediates were sent to Discord as partial messages, causing duplicated or garbled output. -**Changes:** -- **Removed titles** from all embeds (progress, tool results, errors, status, summary) — cleaner look -- **Branded footer** on all embeds: `herdctl · agent-name` -- **Refined color palette:** - - Soft violet (`#8B5CF6`) — progress indicators - - Emerald (`#10B981`) — success/completion - - Cool gray (`#6B7280`) — system messages - - Sky blue (`#0EA5E9`) — command output -- **Compact tool results:** collapsed title + fields into a single description line -- **Horizontal result summary** with centered-dot separators instead of inline fields -- **Syntax highlighting** (`ansi`) for Bash tool output -- **Styled slash commands** (`/help`, `/status`, `/reset`) as embeds instead of plain text -- Made `DiscordReplyEmbed.title` optional (was required) +**Change:** Two-layer dedup in the chat manager's `onMessage` handler: +1. Skip intermediate JSONL snapshots where `stop_reason === null` (text may be incomplete) +2. Track finalized `message.id` values and skip duplicates (same turn delivered twice) ---- +Applied to both Discord and Slack connectors. -## 5. Message Deduplication +### 6. File Upload (Agent → Discord) -**Files:** `packages/discord/src/manager.ts`, `packages/slack/src/manager.ts` +**Files:** `packages/discord/src/discord-connector.ts`, `packages/discord/src/manager.ts`, `packages/core/src/runner/file-sender-mcp.ts` -**Problem:** The CLI runtime streams output by appending to a JSONL session file. The session watcher picks up intermediate snapshots that have `stop_reason: null` and incomplete text. These were being sent to Discord as partial messages, causing duplicated or garbled output. Users sometimes saw "completed the task but no response" fallback messages. +**Problem:** Agents could receive files but not send them back. An agent that generates an image, PDF, or CSV had no way to deliver it to the Discord channel. -**Change:** Skip intermediate JSONL snapshots that lack a complete response. Deliver only finalized snapshots. Deduplicate by `message.id` to prevent the same message from being sent twice. Applied to both Discord and Slack connectors. +**Change:** Added `uploadFile()` method to `DiscordConnector` using Discord.js `AttachmentBuilder`. Wired through `FileSenderContext` and `createFileSenderDef` in the manager so agents can send files back to the originating channel via an injected MCP server (`herdctl-file-sender`). Files are buffered during tool execution and attached to the next answer message, so they appear below the text rather than as standalone messages above it. Mirrors existing Slack file upload support. --- -## 6. Acknowledgement Emoji +## UX Polish + +### 7. Acknowledgement Emoji **Files:** `packages/discord/src/manager.ts`, `packages/core/src/config/schema.ts` -**Problem:** When a user sends a message in Discord, there's no immediate feedback that the bot received it. The agent might take 10-30 seconds before the first response appears, leaving the user wondering if the message was seen. +**Problem:** When a user sends a message in Discord, there's no immediate feedback that the bot received it. The agent might take 10–30 seconds before the first response appears, leaving the user wondering if the message was seen. -**Change:** Bot reacts with a configurable emoji (default: 👀) on message receipt. The reaction is removed once the response is sent. +**Change:** Bot reacts with a configurable emoji (default: eyes) on message receipt. The reaction is removed in the `finally` block when the job completes (whether it succeeds or fails), ensuring cleanup even on errors. ```yaml chat: @@ -119,48 +129,42 @@ chat: acknowledge_emoji: "👀" ``` ---- - -## 7. Voice Message Transcription +### 8. Progress Indicator -**Files:** New file `packages/discord/src/voice-transcriber.ts`, `packages/discord/src/manager.ts`, `packages/discord/src/discord-connector.ts`, `packages/core/src/config/schema.ts` +**Files:** `packages/discord/src/manager.ts` -**Problem:** Discord supports voice messages (audio recordings sent inline). Agents couldn't process them — the audio was ignored and only an empty message came through. +**Problem:** With verbose output suppressed (`assistant_messages: "answers"`), users had no visibility into what the agent was doing. Long-running jobs (30s+) appeared frozen. -**Change:** Added voice message detection and transcription: -- Detects Discord voice messages via `MessageFlags.IsVoiceMessage` -- Downloads the audio attachment -- Transcribes via OpenAI Whisper API using native fetch + FormData (no extra dependencies) -- Inserts the transcription as the message content, prefixed with `[Voice message transcription]:` +**Change:** Added an embed that updates in place as tools run. Tool names appear as they execute, throttled to 2-second intervals to avoid rate limits. The embed is deleted when the job completes. ```yaml chat: discord: - voice: - enabled: true - api_key_env: OPENAI_API_KEY - language: en # optional ISO 639-1 code + output: + progress_indicator: true # default: true ``` ---- - -## 8. File Upload (Agent → Discord) +### 9. Visual Polish -**Files:** `packages/discord/src/discord-connector.ts`, `packages/discord/src/manager.ts` - -**Problem:** Agents could receive files but not send them back. An agent that generates an image, PDF, or CSV had no way to deliver it to the Discord channel. - -**Change:** Added `uploadFile()` method to DiscordConnector using Discord.js `AttachmentBuilder`. Wired through `FileSenderContext` and `createFileSenderDef` in the manager so agents can send files back to the originating channel via an injected MCP server. Mirrors existing Slack file upload support. +**Files:** `packages/discord/src/manager.ts`, `packages/discord/src/commands/help.ts`, `reset.ts`, `status.ts`, `packages/discord/src/types.ts` ---- +**Changes:** +- **Removed titles** from all embeds (progress, tool results, errors, status, summary) — cleaner look +- **Branded footer** on all embeds: `herdctl · agent-name` +- **Refined color palette:** soft violet for progress, emerald for success, cool gray for system, sky blue for commands +- **Compact tool results:** collapsed title + fields into a single description line +- **Horizontal result summary** with centered-dot separators instead of inline fields +- **Syntax highlighting** (`ansi`) for Bash tool output in tool result embeds +- **Styled slash commands** (`/help`, `/status`, `/reset`) as embeds instead of plain text +- Made `DiscordReplyEmbed.title` optional (was required) -## 9. Typing Indicator Control +### 10. Typing Indicator Control **Files:** `packages/discord/src/manager.ts`, `packages/core/src/config/schema.ts` -**Problem:** Discord's typing indicator can cause "An unknown error occurred" for long-running jobs. The Discord client has internal timeouts, and continuous typing indicator refreshes can race against rate limits, producing visible errors in the channel. +**Problem:** Discord's typing indicator refreshes every 8 seconds via `setInterval` + `sendTyping()`. For long-running agent jobs (minutes to hours), this generates hundreds of unnecessary API calls. While errors from failed `sendTyping()` calls are caught silently, the cumulative API pressure contributes to rate limiting, especially when the bot is already handling other operations concurrently. -**Change:** Added a config option to disable the typing indicator entirely: +**Change:** Added a config option to disable the typing indicator entirely. The default remains enabled, which is fine for short interactions. Agents with long-running jobs benefit from disabling it. ```yaml chat: @@ -171,56 +175,37 @@ chat: --- -## 10. Progress Indicator - -**Files:** `packages/discord/src/manager.ts` +## Core Runtime Fixes (Discord-adjacent) -**Problem:** With verbose output suppressed, users had no visibility into what the agent was doing. Long-running jobs (30s+) appeared frozen. +These changes live in `@herdctl/core` but were discovered and required by Discord usage. -**Change:** Added a "Working..." embed that updates in place as tools run. Tool names appear as they execute, throttled to 2-second intervals to avoid rate limits. The embed is deleted when the job completes. Controlled by config: - -```yaml -chat: - discord: - output: - progress_indicator: true # default: true -``` - -Uses `replyWithRef()` to get edit/delete handles on the progress embed. - ---- - -## 11. MCP Config Format Fix +### 11. `--mcp-config` Wrapper Key Fix **File:** `packages/core/src/runner/runtime/cli-runtime.ts` **Problem:** The CLI runtime passed MCP server configuration to `claude --mcp-config` as inline JSON without the required `mcpServers` wrapper key. The CLI expects `{"mcpServers": {...}}` (same shape as `.mcp.json` files). Without the wrapper, the Claude CLI hung indefinitely during startup — no error, no timeout, just a stuck process. -This was a latent bug that never manifested because agents' MCP servers were loaded from workspace `.mcp.json` files, not via `--mcp-config`. The self-scheduling feature (which injects the `herdctl-scheduler` MCP server via config) was the first to actually use `--mcp-config`, exposing the bug. +This was a latent bug in upstream. It never manifested because agents typically defined their MCP servers in workspace `.mcp.json` files rather than in the `mcp_servers` field of `agent.yaml`. Any agent that used `mcp_servers` in its herdctl config would have hit this hang. **Change:** `JSON.stringify(mcpServers)` → `JSON.stringify({ mcpServers })` ---- - -## 12. Injected MCP Server Support for CLI Runtime (File Upload Fix) +### 12. Injected MCP Server Support for CLI Runtime **File:** `packages/core/src/runner/runtime/cli-runtime.ts` -**Problem:** The file upload feature (#8) worked for SDK and Docker runtimes but silently failed for CLI runtime agents. The `FileSenderContext` MCP server was passed via `injectedMcpServers`, but CLI runtime completely ignored that field — it only supported static MCP servers from agent config via `--mcp-config`. - -Since all agents in this deployment use CLI runtime (Max plan pricing), no agent could upload files to Discord. The stlpipeline agent would describe generated images in text but never actually upload them. +**Problem:** The file upload feature (#6) worked for SDK and Docker runtimes but silently failed for CLI runtime agents. The `FileSenderContext` MCP server was passed via `injectedMcpServers`, but CLI runtime completely ignored that field — it only supported static MCP servers from agent config. -**Root cause:** The SDK runtime can host MCP servers in-process (same Node.js process). The CLI runtime spawns `claude` as a separate subprocess — in-process handlers can't cross the process boundary. The container runner already solved this for Docker by starting HTTP bridges (JSON-RPC over HTTP) and passing them as HTTP-type MCP servers. CLI runtime had no equivalent. +**Root cause:** The SDK runtime can host MCP servers in-process (same Node.js process). The CLI runtime spawns `claude` as a separate subprocess — in-process handler closures can't cross the process boundary. The container runner already solved this for Docker by starting HTTP bridges (JSON-RPC over HTTP) and passing them as HTTP-type MCP servers. CLI runtime had no equivalent. **Change:** Reused the existing `mcp-http-bridge.ts` infrastructure. When CLI runtime receives `injectedMcpServers`: 1. Starts an HTTP bridge for each injected server (random localhost port) -2. Merges them into `--mcp-config` as `type: "http"` MCP servers pointing to `http://127.0.0.1:/mcp` +2. Merges them into `--mcp-config` as `type: "http"` servers pointing to `http://127.0.0.1:/mcp` 3. Auto-adds `mcp____*` to `--allowedTools` if the agent has an allowlist -4. Sets `CLAUDE_CODE_STREAM_CLOSE_TIMEOUT=120000` for file uploads (same as container runner) +4. Sets `CLAUDE_CODE_STREAM_CLOSE_TIMEOUT=120000` for file uploads (same value used by SDK and container runtimes) 5. Cleans up bridges in `finally` block when the CLI process exits -**Why this matters:** File upload now works across all three runtimes — SDK, Docker, and CLI. The same `InjectedMcpServerDef` pattern works everywhere. +**Result:** File upload now works across all three runtimes — SDK, Docker, and CLI. The same `InjectedMcpServerDef` pattern works everywhere. --- @@ -230,10 +215,10 @@ Since all agents in this deployment use CLI runtime (Max plan pricing), no agent chat: discord: output: - assistant_messages: "answers" # "answers" | "all" (replaces final_answer_only + concise_mode) - result_summary: true # show summary embed after job completes - typing_indicator: true # show typing indicator while processing - progress_indicator: true # show updating "Working..." embed + assistant_messages: "answers" # "answers" | "all" + result_summary: true # completion stats embed + typing_indicator: true # Discord typing indicator + progress_indicator: true # updating progress embed acknowledge_emoji: "👀" # react on message receipt attachments: diff --git a/packages/discord/src/__tests__/manager.test.ts b/packages/discord/src/__tests__/manager.test.ts index 52306001..7428210a 100644 --- a/packages/discord/src/__tests__/manager.test.ts +++ b/packages/discord/src/__tests__/manager.test.ts @@ -2217,7 +2217,7 @@ describe.skip("DiscordManager message handling", () => { const embedCall = replyMock.mock.calls[1][0] as DiscordReplyPayload; expect(embedCall).toHaveProperty("embeds"); expect(embedCall.embeds).toHaveLength(1); - const embed = embedCall.embeds[0]; + const embed = embedCall.embeds![0]; expect(embed.title).toContain("Bash"); expect(embed.description).toContain("ls -la /tmp"); expect(embed.color).toBe(0x5865f2); // blurple (not error) @@ -2384,7 +2384,7 @@ describe.skip("DiscordManager message handling", () => { const embedCall = replyMock.mock.calls[0][0] as DiscordReplyPayload; expect(embedCall).toHaveProperty("embeds"); expect(embedCall.embeds).toHaveLength(1); - const embed = embedCall.embeds[0]; + const embed = embedCall.embeds![0]; // No matching tool_use, so title falls back to "Tool" expect(embed.title).toContain("Tool"); // Should have Output field and Result field @@ -5093,7 +5093,7 @@ describe.skip("DiscordManager output configuration", () => { return typeof payload === "object" && payload !== null && "embeds" in payload; }); expect(embedCalls.length).toBe(1); - const embed = (embedCalls[0][0] as DiscordReplyPayload).embeds[0]; + const embed = (embedCalls[0][0] as DiscordReplyPayload).embeds![0]; expect(embed.title).toContain("System"); expect(embed.description).toContain("Compacting context"); }, 10000); @@ -5436,7 +5436,7 @@ describe.skip("DiscordManager output configuration", () => { return typeof payload === "object" && payload !== null && "embeds" in payload; }); expect(embedCalls.length).toBe(1); - const embed = (embedCalls[0][0] as DiscordReplyPayload).embeds[0]; + const embed = (embedCalls[0][0] as DiscordReplyPayload).embeds![0]; expect(embed.title).toContain("Task Complete"); expect(embed.fields).toBeDefined(); const fieldNames = embed.fields!.map((f: DiscordReplyEmbedField) => f.name); @@ -5611,7 +5611,7 @@ describe.skip("DiscordManager output configuration", () => { return typeof payload === "object" && payload !== null && "embeds" in payload; }); expect(embedCalls.length).toBe(1); - const embed = (embedCalls[0][0] as DiscordReplyPayload).embeds[0]; + const embed = (embedCalls[0][0] as DiscordReplyPayload).embeds![0]; expect(embed.title).toContain("Error"); expect(embed.description).toBe("Something went wrong"); }, 10000); diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index 79ff0105..ad1eb9a8 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -439,29 +439,46 @@ export class DiscordManager implements IChatManager { } } + // Buffer for files uploaded by the agent via MCP tool. + // Files are queued here and attached to the next answer message, + // so they appear below the text (not as standalone messages above it). + const pendingFiles: Array<{ buffer: Buffer; filename: string }> = []; + // Create file sender definition for this message context let injectedMcpServers: Record | undefined; const workingDir = this.resolveWorkingDirectory(agent); if (connector && workingDir) { - const agentConnector = connector; const fileSenderContext: FileSenderContext = { workingDirectory: workingDir, uploadFile: async (params) => { - return agentConnector.uploadFile({ - channelId: event.metadata.channelId, - fileBuffer: params.fileBuffer, + // Queue the file — it will be attached to the next answer message + pendingFiles.push({ + buffer: params.fileBuffer, filename: params.filename, - message: params.message, }); + const fileId = `buffered-${randomUUID()}`; + logger.debug(`Buffered file '${params.filename}' for attachment to next answer message`); + return { fileId }; }, }; const fileSenderDef = createFileSenderDef(fileSenderContext); injectedMcpServers = { [fileSenderDef.name]: fileSenderDef }; } - // Create streaming responder for incremental message delivery + // Create streaming responder for incremental message delivery. + // The reply closure drains pending files and attaches them to the message. const streamer = new StreamingResponder({ - reply: (content: string) => event.reply(content), + reply: async (content: string) => { + if (pendingFiles.length > 0) { + const files = pendingFiles.splice(0); + await event.reply({ + content, + files: files.map((f) => ({ attachment: f.buffer, name: f.filename })), + }); + } else { + await event.reply(content); + } + }, logger: logger as ChatConnectorLogger, agentName: qualifiedName, maxMessageLength: 2000, // Discord's limit @@ -854,6 +871,16 @@ export class DiscordManager implements IChatManager { // Flush any remaining buffered content await streamer.flush(); + // Send any remaining buffered files that weren't attached to an answer. + // This handles the case where the agent uploaded files but produced no text answer. + if (pendingFiles.length > 0) { + const files = pendingFiles.splice(0); + logger.debug(`Sending ${files.length} remaining buffered file(s) as standalone message`); + await event.reply({ + files: files.map((f) => ({ attachment: f.buffer, name: f.filename })), + }); + } + logger.debug( `Discord job completed: ${result.jobId} for agent '${qualifiedName}'${result.sessionId ? ` (session: ${result.sessionId})` : ""}`, ); diff --git a/packages/discord/src/types.ts b/packages/discord/src/types.ts index 8914dab4..795dd576 100644 --- a/packages/discord/src/types.ts +++ b/packages/discord/src/types.ts @@ -274,10 +274,16 @@ export interface DiscordReplyEmbed { } /** - * Payload for sending rich messages (embeds) via the reply function + * Payload for sending rich messages via the reply function. + * + * Supports embeds, plain text content, and file attachments. + * At least one of `content`, `embeds`, or `files` must be provided. + * Maps to discord.js MessageCreateOptions at runtime. */ export interface DiscordReplyPayload { - embeds: DiscordReplyEmbed[]; + content?: string; + embeds?: DiscordReplyEmbed[]; + files?: Array<{ attachment: Buffer; name: string }>; } // ============================================================================= From 64f40a9b02d32e5b58f2948d572e53568c892da7 Mon Sep 17 00:00:00 2001 From: agent Date: Tue, 3 Mar 2026 21:44:56 -0800 Subject: [PATCH 17/48] fix: address review findings across Discord improvements Critical: - Restore process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT in finally block to prevent leaking state across concurrent jobs Important: - Clean up partial HTTP bridges if a bridge fails to start midway - Use wrapper object for progressEmbedHandle to fix TypeScript narrowing - Add voice transcription prefix "[Voice message transcription]:" as documented - Cap toolNamesRun to last 50 entries to prevent unbounded memory growth - Wrap finally block async operations in try/catch to prevent error masking - Document allowedTools behavior for injected MCP tools Minor: - Add 30s timeout to attachment downloads and voice message fetch - Add 60s timeout to Whisper API transcription call - Cap embed text extraction to 4000 chars in mention handler - Use broader channel type cast (TextChannel | DMChannel) for uploadFile - Remove discord-changes.md from repo (belongs in PR description, not tree) Co-Authored-By: Claude Opus 4.6 --- discord-changes.md | 235 ------------------ .../core/src/runner/runtime/cli-runtime.ts | 37 ++- packages/discord/src/discord-connector.ts | 3 +- packages/discord/src/manager.ts | 47 ++-- packages/discord/src/mention-handler.ts | 6 +- packages/discord/src/voice-transcriber.ts | 1 + 6 files changed, 71 insertions(+), 258 deletions(-) delete mode 100644 discord-changes.md diff --git a/discord-changes.md b/discord-changes.md deleted file mode 100644 index acf7fb9d..00000000 --- a/discord-changes.md +++ /dev/null @@ -1,235 +0,0 @@ -# Discord Connector Improvements — oheckmann74/herdctl Fork - -This document covers Discord-related changes in the `oheckmann74/herdctl` fork compared to upstream. All changes are production-tested across three agents running on CLI runtime with Max plan pricing. - -Changes are grouped by area: input handling, output control, UX polish, and core runtime fixes that unblock Discord features. - ---- - -## Input: What agents can receive - -### 1. File Attachments (User → Agent) - -**Files:** `packages/discord/src/manager.ts`, `packages/discord/src/types.ts`, `packages/core/src/config/schema.ts` - -**Problem:** Users couldn't send images, PDFs, or text files to agents via Discord. Attachments were silently ignored. - -**Change:** Added full attachment processing pipeline: -- **Text files** (`.txt`, `.json`, `.csv`, etc.) are inlined directly into the prompt — up to 50,000 characters -- **Images and PDFs** are downloaded to a UUID-isolated directory so agents can access them via their Read tool -- **Automatic cleanup** after processing (configurable) -- Collision-safe: each message gets a UUID-based download directory, so concurrent messages with the same filenames don't conflict - -```yaml -chat: - discord: - attachments: - enabled: true - max_files_per_message: 10 - max_file_size_mb: 10 - allowed_types: ["image/*", "application/pdf", "text/*", "application/json"] - cleanup_after_processing: true -``` - -### 2. Voice Message Transcription - -**Files:** New file `packages/discord/src/voice-transcriber.ts`, `packages/discord/src/manager.ts`, `packages/discord/src/discord-connector.ts`, `packages/core/src/config/schema.ts` - -**Problem:** Discord voice messages (audio recordings sent inline in text channels) were silently ignored. Only an empty message came through to the agent. - -**Change:** Added voice message detection and transcription: -- Detects Discord voice messages via `MessageFlags.IsVoiceMessage` -- Downloads the audio attachment -- Transcribes via OpenAI Whisper API using native `fetch` + `FormData` (Node 18+, no extra dependencies) -- Inserts the transcription as the message content, prefixed with `[Voice message transcription]:` - -```yaml -chat: - discord: - voice: - enabled: true - api_key_env: OPENAI_API_KEY - language: en # optional ISO 639-1 code -``` - -### 3. Embed Content in Conversation Context - -**File:** `packages/discord/src/mention-handler.ts` - -**Problem:** The conversation context builder only read `message.content` — the plain text body of a Discord message. Discord embeds (structured content with titles, fields, descriptions) live in `message.embeds`, which was completely ignored. This meant embed-only messages (from herdctl hooks, link previews, other bots) were filtered out as "empty" and invisible to the agent. - -The practical impact: scheduled jobs (cron, interval) run in isolated CLI sessions with no Discord channel context. The `after_run` hook posts their output to Discord as an embed. Without this fix, replying to a hook notification was a dead end — the agent couldn't see what it had reported. - -**Change:** `processMessage()` now extracts text from embed titles, descriptions, and fields and appends it to the message content. Any embed in the channel history becomes part of the conversation context the agent sees. - ---- - -## Output: What users see - -### 4. Output Control: `assistant_messages` Enum - -**Files:** `packages/discord/src/manager.ts`, `packages/core/src/config/schema.ts` - -**Problem:** The original Discord output was extremely verbose — every assistant turn (including internal reasoning and tool-use planning) was posted to the channel. Two earlier attempts to fix this (`final_answer_only` + `concise_mode`) introduced complexity: message buffering, system prompt injection that degraded answer quality, and a "no additional output to share" fallback that confused users. - -**Change:** Replaced both boolean flags with a single enum (`z.enum(["answers", "all"])`): - -```yaml -chat: - discord: - output: - assistant_messages: "answers" # or "all" -``` - -- `"answers"` (default): Only send turns that contain NO `tool_use` blocks — pure text responses. This is the agent's actual answer. -- `"all"`: Send every turn that has text content, including reasoning during tool use. - -**Why this is better:** -- No message buffering — answer turns are sent immediately -- No system prompt injection — the agent's normal behavior is preserved -- No fallback messages — if a turn has text and no tool use, it's an answer -- Simple mental model: you either want just answers or everything - -### 5. Message Deduplication - -**Files:** `packages/discord/src/manager.ts`, `packages/slack/src/manager.ts` - -**Problem:** The CLI runtime streams output by appending to a JSONL session file. The session watcher picks up intermediate snapshots that have `stop_reason: null` and incomplete text. Neither the CLI runtime nor the SDK runtime deduplicates messages before passing them to the `onMessage` callback — chat managers receive every snapshot raw. Without filtering, these intermediates were sent to Discord as partial messages, causing duplicated or garbled output. - -**Change:** Two-layer dedup in the chat manager's `onMessage` handler: -1. Skip intermediate JSONL snapshots where `stop_reason === null` (text may be incomplete) -2. Track finalized `message.id` values and skip duplicates (same turn delivered twice) - -Applied to both Discord and Slack connectors. - -### 6. File Upload (Agent → Discord) - -**Files:** `packages/discord/src/discord-connector.ts`, `packages/discord/src/manager.ts`, `packages/core/src/runner/file-sender-mcp.ts` - -**Problem:** Agents could receive files but not send them back. An agent that generates an image, PDF, or CSV had no way to deliver it to the Discord channel. - -**Change:** Added `uploadFile()` method to `DiscordConnector` using Discord.js `AttachmentBuilder`. Wired through `FileSenderContext` and `createFileSenderDef` in the manager so agents can send files back to the originating channel via an injected MCP server (`herdctl-file-sender`). Files are buffered during tool execution and attached to the next answer message, so they appear below the text rather than as standalone messages above it. Mirrors existing Slack file upload support. - ---- - -## UX Polish - -### 7. Acknowledgement Emoji - -**Files:** `packages/discord/src/manager.ts`, `packages/core/src/config/schema.ts` - -**Problem:** When a user sends a message in Discord, there's no immediate feedback that the bot received it. The agent might take 10–30 seconds before the first response appears, leaving the user wondering if the message was seen. - -**Change:** Bot reacts with a configurable emoji (default: eyes) on message receipt. The reaction is removed in the `finally` block when the job completes (whether it succeeds or fails), ensuring cleanup even on errors. - -```yaml -chat: - discord: - output: - acknowledge_emoji: "👀" -``` - -### 8. Progress Indicator - -**Files:** `packages/discord/src/manager.ts` - -**Problem:** With verbose output suppressed (`assistant_messages: "answers"`), users had no visibility into what the agent was doing. Long-running jobs (30s+) appeared frozen. - -**Change:** Added an embed that updates in place as tools run. Tool names appear as they execute, throttled to 2-second intervals to avoid rate limits. The embed is deleted when the job completes. - -```yaml -chat: - discord: - output: - progress_indicator: true # default: true -``` - -### 9. Visual Polish - -**Files:** `packages/discord/src/manager.ts`, `packages/discord/src/commands/help.ts`, `reset.ts`, `status.ts`, `packages/discord/src/types.ts` - -**Changes:** -- **Removed titles** from all embeds (progress, tool results, errors, status, summary) — cleaner look -- **Branded footer** on all embeds: `herdctl · agent-name` -- **Refined color palette:** soft violet for progress, emerald for success, cool gray for system, sky blue for commands -- **Compact tool results:** collapsed title + fields into a single description line -- **Horizontal result summary** with centered-dot separators instead of inline fields -- **Syntax highlighting** (`ansi`) for Bash tool output in tool result embeds -- **Styled slash commands** (`/help`, `/status`, `/reset`) as embeds instead of plain text -- Made `DiscordReplyEmbed.title` optional (was required) - -### 10. Typing Indicator Control - -**Files:** `packages/discord/src/manager.ts`, `packages/core/src/config/schema.ts` - -**Problem:** Discord's typing indicator refreshes every 8 seconds via `setInterval` + `sendTyping()`. For long-running agent jobs (minutes to hours), this generates hundreds of unnecessary API calls. While errors from failed `sendTyping()` calls are caught silently, the cumulative API pressure contributes to rate limiting, especially when the bot is already handling other operations concurrently. - -**Change:** Added a config option to disable the typing indicator entirely. The default remains enabled, which is fine for short interactions. Agents with long-running jobs benefit from disabling it. - -```yaml -chat: - discord: - output: - typing_indicator: false # default: true -``` - ---- - -## Core Runtime Fixes (Discord-adjacent) - -These changes live in `@herdctl/core` but were discovered and required by Discord usage. - -### 11. `--mcp-config` Wrapper Key Fix - -**File:** `packages/core/src/runner/runtime/cli-runtime.ts` - -**Problem:** The CLI runtime passed MCP server configuration to `claude --mcp-config` as inline JSON without the required `mcpServers` wrapper key. The CLI expects `{"mcpServers": {...}}` (same shape as `.mcp.json` files). Without the wrapper, the Claude CLI hung indefinitely during startup — no error, no timeout, just a stuck process. - -This was a latent bug in upstream. It never manifested because agents typically defined their MCP servers in workspace `.mcp.json` files rather than in the `mcp_servers` field of `agent.yaml`. Any agent that used `mcp_servers` in its herdctl config would have hit this hang. - -**Change:** `JSON.stringify(mcpServers)` → `JSON.stringify({ mcpServers })` - -### 12. Injected MCP Server Support for CLI Runtime - -**File:** `packages/core/src/runner/runtime/cli-runtime.ts` - -**Problem:** The file upload feature (#6) worked for SDK and Docker runtimes but silently failed for CLI runtime agents. The `FileSenderContext` MCP server was passed via `injectedMcpServers`, but CLI runtime completely ignored that field — it only supported static MCP servers from agent config. - -**Root cause:** The SDK runtime can host MCP servers in-process (same Node.js process). The CLI runtime spawns `claude` as a separate subprocess — in-process handler closures can't cross the process boundary. The container runner already solved this for Docker by starting HTTP bridges (JSON-RPC over HTTP) and passing them as HTTP-type MCP servers. CLI runtime had no equivalent. - -**Change:** Reused the existing `mcp-http-bridge.ts` infrastructure. When CLI runtime receives `injectedMcpServers`: - -1. Starts an HTTP bridge for each injected server (random localhost port) -2. Merges them into `--mcp-config` as `type: "http"` servers pointing to `http://127.0.0.1:/mcp` -3. Auto-adds `mcp____*` to `--allowedTools` if the agent has an allowlist -4. Sets `CLAUDE_CODE_STREAM_CLOSE_TIMEOUT=120000` for file uploads (same value used by SDK and container runtimes) -5. Cleans up bridges in `finally` block when the CLI process exits - -**Result:** File upload now works across all three runtimes — SDK, Docker, and CLI. The same `InjectedMcpServerDef` pattern works everywhere. - ---- - -## Summary of Config Options Added - -```yaml -chat: - discord: - output: - assistant_messages: "answers" # "answers" | "all" - result_summary: true # completion stats embed - typing_indicator: true # Discord typing indicator - progress_indicator: true # updating progress embed - acknowledge_emoji: "👀" # react on message receipt - - attachments: - enabled: true - max_files_per_message: 10 - max_file_size_mb: 10 - allowed_types: ["image/*", "application/pdf", "text/*", "application/json"] - cleanup_after_processing: true - - voice: - enabled: true - api_key_env: OPENAI_API_KEY - language: en -``` diff --git a/packages/core/src/runner/runtime/cli-runtime.ts b/packages/core/src/runner/runtime/cli-runtime.ts index 0569baac..9fd0cc2a 100644 --- a/packages/core/src/runner/runtime/cli-runtime.ts +++ b/packages/core/src/runner/runtime/cli-runtime.ts @@ -185,13 +185,30 @@ export class CLIRuntime implements RuntimeInterface { args.push("--mcp-config", mcpConfig); } + // Track env mutation so we can restore it (see CLAUDE_CODE_STREAM_CLOSE_TIMEOUT below) + let savedStreamCloseTimeout: string | undefined | null = null; // null = not mutated + // Start HTTP bridges for injected MCP servers (e.g., file sender) // Same pattern as container-runner: expose in-process handlers via HTTP, // then pass as HTTP-type MCP servers in --mcp-config const bridges: McpHttpBridge[] = []; if (options.injectedMcpServers && Object.keys(options.injectedMcpServers).length > 0) { for (const [name, def] of Object.entries(options.injectedMcpServers)) { - const bridge = await startMcpHttpBridge(def); + let bridge: McpHttpBridge; + try { + bridge = await startMcpHttpBridge(def); + } catch (bridgeError) { + // Clean up any bridges that started successfully before this failure + for (const b of bridges) { + try { + await b.close(); + } catch { + // best-effort cleanup + } + } + bridges.length = 0; + throw bridgeError; + } bridges.push(bridge); // Build or extend the --mcp-config to include this HTTP server @@ -221,7 +238,9 @@ export class CLIRuntime implements RuntimeInterface { logger.debug(`Started MCP HTTP bridge for '${name}' on port ${bridge.port}`); } - // Auto-add injected MCP tool patterns to allowedTools + // Auto-add injected MCP tool patterns to allowedTools. + // Only needed when the agent has an explicit allowlist — without one, all tools + // (including injected MCP tools) are allowed by default. const allowedToolsIdx = args.indexOf("--allowedTools"); if (allowedToolsIdx !== -1 && allowedToolsIdx + 1 < args.length) { const existing = args[allowedToolsIdx + 1]; @@ -231,9 +250,12 @@ export class CLIRuntime implements RuntimeInterface { args[allowedToolsIdx + 1] = [existing, ...injectedPatterns].join(","); } - // File uploads via MCP tools can take longer than the default 60s timeout + // File uploads via MCP tools can take longer than the default 60s timeout. + // Save the original value so we can restore it in `finally` to avoid leaking + // state across concurrent jobs. if (options.injectedMcpServers["herdctl-file-sender"]) { if (!process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT) { + savedStreamCloseTimeout = undefined; // marker: was not set process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = "120000"; } } @@ -539,6 +561,15 @@ export class CLIRuntime implements RuntimeInterface { // Cleanup watcher?.stop(); + // Restore process.env if we mutated it + if (savedStreamCloseTimeout !== null) { + if (savedStreamCloseTimeout === undefined) { + delete process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT; + } else { + process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = savedStreamCloseTimeout; + } + } + // Close HTTP bridges for injected MCP servers for (const bridge of bridges) { try { diff --git a/packages/discord/src/discord-connector.ts b/packages/discord/src/discord-connector.ts index 8693b81a..f55f7ca0 100644 --- a/packages/discord/src/discord-connector.ts +++ b/packages/discord/src/discord-connector.ts @@ -325,7 +325,8 @@ export class DiscordConnector extends EventEmitter implements IDiscordConnector } const attachment = new AttachmentBuilder(params.fileBuffer, { name: params.filename }); - const sent = await (channel as TextChannel).send({ + // Channel type is validated above via isTextBased() + "send" check + const sent = await (channel as TextChannel | DMChannel).send({ content: params.message || undefined, files: [attachment], }); diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index ad1eb9a8..57d6bbb4 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -532,7 +532,9 @@ export class DiscordManager implements IChatManager { try { logger.debug("Downloading voice message audio..."); - const audioResponse = await fetch(event.metadata.voiceAttachmentUrl); + const audioResponse = await fetch(event.metadata.voiceAttachmentUrl, { + signal: AbortSignal.timeout(30_000), + }); if (!audioResponse.ok) { throw new Error(`Failed to download audio: ${audioResponse.status}`); } @@ -546,7 +548,7 @@ export class DiscordManager implements IChatManager { language: voiceConfig.language, }); - prompt = transcription.text; + prompt = `[Voice message transcription]: ${transcription.text}`; logger.info(`Voice message transcribed: "${prompt.substring(0, 80)}..."`); } catch (transcribeError) { const errMsg = @@ -613,7 +615,7 @@ export class DiscordManager implements IChatManager { delete: () => Promise; } const toolNamesRun: string[] = []; - let progressEmbedHandle: ProgressEmbedHandle | null = null; + const progressState: { handle: ProgressEmbedHandle | null } = { handle: null }; let lastProgressUpdate = 0; const showToolResults = outputConfig.tool_results; @@ -650,6 +652,10 @@ export class DiscordManager implements IChatManager { const emoji = TOOL_EMOJIS[block.name] ?? "\u{1F527}"; const displayName = `${emoji} ${block.name}`; toolNamesRun.push(displayName); + // Cap to last 50 entries to avoid unbounded memory growth on long jobs + if (toolNamesRun.length > 50) { + toolNamesRun.splice(0, toolNamesRun.length - 50); + } // Update progress embed (throttled to every 2s) const now = Date.now(); @@ -670,10 +676,10 @@ export class DiscordManager implements IChatManager { }; try { - if (!progressEmbedHandle) { - progressEmbedHandle = await event.replyWithRef(embedPayload); + if (!progressState.handle) { + progressState.handle = await event.replyWithRef(embedPayload); } else { - await progressEmbedHandle.edit(embedPayload); + await progressState.handle.edit(embedPayload); } } catch (progressError) { logger.warn( @@ -851,12 +857,9 @@ export class DiscordManager implements IChatManager { } // Clean up progress embed now that the job is done - // Note: progressEmbedHandle is assigned inside the onMessage async callback, - // so TypeScript's control flow analysis sees it as always null. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if ((progressEmbedHandle as unknown as ProgressEmbedHandle | null) !== null) { + if (progressState.handle) { try { - await progressEmbedHandle!.delete(); + await progressState.handle.delete(); } catch (progressError) { logger.warn(`Failed to delete progress embed: ${(progressError as Error).message}`); } @@ -977,17 +980,25 @@ export class DiscordManager implements IChatManager { } // Remove acknowledgement reaction now that processing is complete if (ackEmoji) { - await event.removeReaction(ackEmoji); + try { + await event.removeReaction(ackEmoji); + } catch (reactionError) { + logger.warn(`Failed to remove ack reaction: ${(reactionError as Error).message}`); + } } // Clean up downloaded attachment files if configured if ( attachmentDownloadedPaths.length > 0 && attachmentConfig?.cleanup_after_processing !== false ) { - await DiscordManager.cleanupAttachments( - attachmentDownloadedPaths, - logger as ChatConnectorLogger, - ); + try { + await DiscordManager.cleanupAttachments( + attachmentDownloadedPaths, + logger as ChatConnectorLogger, + ); + } catch (cleanupError) { + logger.warn(`Failed to cleanup attachments: ${(cleanupError as Error).message}`); + } } } } @@ -1275,7 +1286,7 @@ export class DiscordManager implements IChatManager { try { if (attachment.category === "text") { // Text/code: download and inline - const response = await fetch(attachment.url); + const response = await fetch(attachment.url, { signal: AbortSignal.timeout(30_000) }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } @@ -1295,7 +1306,7 @@ export class DiscordManager implements IChatManager { }); continue; } - const response = await fetch(attachment.url); + const response = await fetch(attachment.url, { signal: AbortSignal.timeout(30_000) }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } diff --git a/packages/discord/src/mention-handler.ts b/packages/discord/src/mention-handler.ts index 9cc6d3c8..39afb2bb 100644 --- a/packages/discord/src/mention-handler.ts +++ b/packages/discord/src/mention-handler.ts @@ -247,7 +247,11 @@ export function processMessage(message: Message, botUserId: string): ContextMess if (parts.length > 0) embedTexts.push(parts.join("\n")); } if (embedTexts.length > 0) { - const embedContent = embedTexts.join("\n\n"); + let embedContent = embedTexts.join("\n\n"); + // Cap embed text to avoid extremely long context from rich messages + if (embedContent.length > 4000) { + embedContent = embedContent.substring(0, 4000) + "\n[embed text truncated]"; + } content = content ? `${content}\n\n${embedContent}` : embedContent; } } diff --git a/packages/discord/src/voice-transcriber.ts b/packages/discord/src/voice-transcriber.ts index aa1b1edb..4a27442c 100644 --- a/packages/discord/src/voice-transcriber.ts +++ b/packages/discord/src/voice-transcriber.ts @@ -50,6 +50,7 @@ export async function transcribeAudio( Authorization: `Bearer ${options.apiKey}`, }, body: formData, + signal: AbortSignal.timeout(60_000), // 60s timeout for transcription API }); if (!response.ok) { From e8a6a1e3b0a5061bea18ff71b389bd618b2a36d2 Mon Sep 17 00:00:00 2001 From: agent Date: Tue, 3 Mar 2026 22:05:55 -0800 Subject: [PATCH 18/48] fix: address CodeRabbit round 2 feedback on Discord improvements - Wrap ack reaction in try/catch so failure doesn't abort message handling - Add path traversal validation on download_dir schema (reject '..' and absolute paths) - Validate Whisper API response payload before returning - Move progress embed cleanup to finally block (cleanup on error path too) - Use path.dirname() instead of manual lastIndexOf/substring - Add vi.unstubAllGlobals() safety net in attachments test afterEach Co-Authored-By: Claude Opus 4.6 --- packages/core/src/config/schema.ts | 8 +++- .../discord/src/__tests__/attachments.test.ts | 1 + packages/discord/src/manager.ts | 44 ++++++++++--------- packages/discord/src/voice-transcriber.ts | 5 ++- 4 files changed, 36 insertions(+), 22 deletions(-) diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index a5e04d18..a6ec5661 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -699,7 +699,13 @@ export const DiscordAttachmentsSchema = z.object({ /** Allowed MIME type patterns — supports wildcards like "image/*" (default: ["image/*", "application/pdf", "text/*"]) */ allowed_types: z.array(z.string()).optional().default(["image/*", "application/pdf", "text/*"]), /** Directory name for downloaded binary attachments, relative to agent working_directory (default: ".discord-attachments") */ - download_dir: z.string().optional().default(".discord-attachments"), + download_dir: z + .string() + .optional() + .default(".discord-attachments") + .refine((v) => !v.includes("..") && !v.startsWith("/"), { + message: "download_dir must be a relative path without '..' segments", + }), /** Delete downloaded files after the agent finishes processing (default: true) */ cleanup_after_processing: z.boolean().optional().default(true), }); diff --git a/packages/discord/src/__tests__/attachments.test.ts b/packages/discord/src/__tests__/attachments.test.ts index 300d34e6..a257ac4c 100644 --- a/packages/discord/src/__tests__/attachments.test.ts +++ b/packages/discord/src/__tests__/attachments.test.ts @@ -207,6 +207,7 @@ describe("processAttachments", () => { }); afterEach(async () => { + vi.unstubAllGlobals(); await rm(testDir, { recursive: true, force: true }); }); diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index 57d6bbb4..e3229d87 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -12,7 +12,7 @@ import { randomUUID } from "node:crypto"; import { mkdir, rm, writeFile } from "node:fs/promises"; -import { basename, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { type ChatConnectorLogger, @@ -492,16 +492,26 @@ export class DiscordManager implements IChatManager { // Track if we've stopped typing to avoid multiple calls let typingStopped = false; - // Add acknowledgement reaction if configured + // Add acknowledgement reaction if configured (non-fatal — don't abort message handling) const ackEmoji = outputConfig.acknowledge_emoji; if (ackEmoji) { - await event.addReaction(ackEmoji); + try { + await event.addReaction(ackEmoji); + } catch (reactionError) { + logger.warn(`Failed to add ack reaction: ${(reactionError as Error).message}`); + } } // Attachment state — declared here so the finally block can clean up let attachmentDownloadedPaths: string[] = []; const attachmentConfig = agent.chat?.discord?.attachments; + // Progress embed state — declared here so the finally block can clean up + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const progressState: { + handle: { edit: (c: any) => Promise; delete: () => Promise } | null; + } = { handle: null }; + try { // Handle voice messages: transcribe audio before triggering the agent let prompt = event.prompt; @@ -610,12 +620,7 @@ export class DiscordManager implements IChatManager { let resultText: string | undefined; // Progress indicator: track tool names for in-place-updating embed - interface ProgressEmbedHandle { - edit: (content: string | DiscordReplyPayload) => Promise; - delete: () => Promise; - } const toolNamesRun: string[] = []; - const progressState: { handle: ProgressEmbedHandle | null } = { handle: null }; let lastProgressUpdate = 0; const showToolResults = outputConfig.tool_results; @@ -856,15 +861,6 @@ export class DiscordManager implements IChatManager { typingStopped = true; } - // Clean up progress embed now that the job is done - if (progressState.handle) { - try { - await progressState.handle.delete(); - } catch (progressError) { - logger.warn(`Failed to delete progress embed: ${(progressError as Error).message}`); - } - } - // Fall back to SDK result text if no answer turns produced text if (!streamer.hasSentMessages() && resultText) { logger.debug("No answer turns produced text — using SDK result text as fallback"); @@ -978,6 +974,14 @@ export class DiscordManager implements IChatManager { if (!typingStopped) { stopTyping(); } + // Clean up progress embed (on both success and error paths) + if (progressState.handle) { + try { + await progressState.handle.delete(); + } catch (progressError) { + logger.warn(`Failed to delete progress embed: ${(progressError as Error).message}`); + } + } // Remove acknowledgement reaction now that processing is complete if (ackEmoji) { try { @@ -1347,9 +1351,9 @@ export class DiscordManager implements IChatManager { try { await rm(filePath); // Track parent directory for cleanup - const lastSlash = filePath.lastIndexOf("/"); - if (lastSlash > 0) { - parentDirs.add(filePath.substring(0, lastSlash)); + const parent = dirname(filePath); + if (parent !== filePath && parent !== ".") { + parentDirs.add(parent); } } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); diff --git a/packages/discord/src/voice-transcriber.ts b/packages/discord/src/voice-transcriber.ts index 4a27442c..0f5a651c 100644 --- a/packages/discord/src/voice-transcriber.ts +++ b/packages/discord/src/voice-transcriber.ts @@ -58,6 +58,9 @@ export async function transcribeAudio( throw new Error(`OpenAI Whisper API error (${response.status}): ${errorBody}`); } - const result = (await response.json()) as { text: string }; + const result = (await response.json()) as Record; + if (typeof result.text !== "string") { + throw new Error("OpenAI Whisper API returned an unexpected response (missing 'text' field)"); + } return { text: result.text }; } From a66b5077f8d840e56fe12d89b6176f77b705cc65 Mon Sep 17 00:00:00 2001 From: agent Date: Wed, 4 Mar 2026 08:08:43 -0800 Subject: [PATCH 19/48] feat(core): inject self-scheduling system prompt into agents When self_scheduling is enabled, append a system prompt snippet that teaches agents how to use the herdctl scheduling tools. This prevents agents from trying to edit config files directly (as happened with juliusagent) and provides clear guidelines on schedule creation, frequency limits, TTL usage, and prompt authoring. Co-Authored-By: Claude Opus 4.6 --- .../config/__tests__/self-scheduling.test.ts | 57 +++++++++++++++++++ packages/core/src/config/self-scheduling.ts | 46 +++++++++++++-- 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/packages/core/src/config/__tests__/self-scheduling.test.ts b/packages/core/src/config/__tests__/self-scheduling.test.ts index 93053943..2cc1f262 100644 --- a/packages/core/src/config/__tests__/self-scheduling.test.ts +++ b/packages/core/src/config/__tests__/self-scheduling.test.ts @@ -160,4 +160,61 @@ describe("injectSchedulerMcpServers", () => { expect(agents[0].mcp_servers!["existing-server"]).toBeDefined(); expect(agents[0].mcp_servers!["herdctl-scheduler"]).toBeDefined(); }); + + it("injects self-scheduling system prompt when enabled", () => { + const agents = [ + makeAgent({ + self_scheduling: { enabled: true, max_schedules: 5, min_interval: "15m" }, + }), + ]; + + injectSchedulerMcpServers(agents, "/tmp/.herdctl"); + + expect(agents[0].system_prompt).toBeDefined(); + expect(agents[0].system_prompt).toContain("# Self-Scheduling"); + expect(agents[0].system_prompt).toContain("herdctl_create_schedule"); + expect(agents[0].system_prompt).toContain("up to 5 dynamic schedules"); + expect(agents[0].system_prompt).toContain("Minimum interval is 15m"); + expect(agents[0].system_prompt).toContain("Never edit agent.yaml"); + }); + + it("appends scheduling prompt to existing system_prompt", () => { + const agents = [ + makeAgent({ + system_prompt: "You are a helpful assistant.", + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, + }), + ]; + + injectSchedulerMcpServers(agents, "/tmp/.herdctl"); + + expect(agents[0].system_prompt).toMatch(/^You are a helpful assistant\.\n\n# Self-Scheduling/); + }); + + it("does not inject system prompt when self_scheduling is disabled", () => { + const agents = [makeAgent()]; + + injectSchedulerMcpServers(agents, "/tmp/.herdctl"); + + expect(agents[0].system_prompt).toBeUndefined(); + }); + + it("does not inject system prompt when operator declared custom MCP server", () => { + const agents = [ + makeAgent({ + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, + mcp_servers: { + "herdctl-scheduler": { + command: "custom-scheduler", + args: ["--custom"], + }, + }, + }), + ]; + + injectSchedulerMcpServers(agents, "/tmp/.herdctl"); + + // Custom MCP server preserved, no system prompt injected + expect(agents[0].system_prompt).toBeUndefined(); + }); }); diff --git a/packages/core/src/config/self-scheduling.ts b/packages/core/src/config/self-scheduling.ts index 6124847e..8f605fea 100644 --- a/packages/core/src/config/self-scheduling.ts +++ b/packages/core/src/config/self-scheduling.ts @@ -2,8 +2,8 @@ * Self-scheduling MCP server injection * * When an agent has `self_scheduling.enabled: true`, this module injects the - * herdctl-scheduler MCP server into the agent's mcp_servers. The MCP server - * lets the agent create, update, and delete its own dynamic schedules. + * herdctl-scheduler MCP server into the agent's mcp_servers and appends a + * system prompt snippet that teaches the agent how to use self-scheduling. * * Called from FleetManager.initialize() after the stateDir is known. */ @@ -25,8 +25,38 @@ function getSchedulerMcpPath(): string { } /** - * Inject the herdctl-scheduler MCP server into agents that have - * self_scheduling.enabled. Mutates the agent configs in place. + * Build the system prompt snippet for self-scheduling guidance. + * Tailored to the agent's configured limits. + */ +function buildSchedulingPrompt( + selfScheduling: NonNullable, +): string { + const maxSchedules = selfScheduling.max_schedules ?? 10; + const minInterval = selfScheduling.min_interval ?? "5m"; + + return `# Self-Scheduling + +You have the ability to manage your own scheduled tasks using the herdctl scheduling tools. These tools let you create, list, update, and delete dynamic schedules that persist across restarts. + +## Available tools +- \`herdctl_create_schedule\` — Create a new recurring schedule (cron or interval) +- \`herdctl_list_schedules\` — List your current dynamic schedules +- \`herdctl_update_schedule\` — Update an existing schedule's timing, prompt, or enabled state +- \`herdctl_delete_schedule\` — Remove a schedule you no longer need + +## Guidelines +- **Use these tools** to manage your schedules. Never edit agent.yaml, herdctl.yaml, or any configuration files directly. +- **Cron schedules** use standard cron expressions (e.g., \`0 9 * * 1-5\` for weekday mornings). **Interval schedules** use duration strings (e.g., \`30m\`, \`6h\`, \`1d\`). +- **Be conservative with frequency.** Minimum interval is ${minInterval}. Prefer longer intervals unless the user specifically requests frequent checks. +- **You can have up to ${maxSchedules} dynamic schedules.** Use \`herdctl_list_schedules\` to check your current count before creating new ones. Clean up schedules that are no longer needed. +- **Use TTLs for temporary schedules.** If a schedule is only needed for a limited time (e.g., tracking a delivery, monitoring an event), set \`ttl_hours\` so it auto-expires. +- **Write clear, self-contained prompts.** The prompt field is what you'll receive when the schedule triggers — it should contain enough context for you to act without needing the original conversation. +- **Only create schedules when the user asks** or when it's clearly the right tool for a recurring need the user has expressed. Don't speculatively create schedules.`; +} + +/** + * Inject the herdctl-scheduler MCP server and system prompt into agents that + * have self_scheduling.enabled. Mutates the agent configs in place. * * @param agents - Resolved agent configs * @param stateDir - Absolute path to the .herdctl state directory @@ -63,5 +93,13 @@ export function injectSchedulerMcpServers(agents: ResolvedAgent[], stateDir: str }), }, }; + + // Append self-scheduling guidance to the agent's system prompt + const schedulingPrompt = buildSchedulingPrompt(selfScheduling); + if (agent.system_prompt) { + agent.system_prompt = agent.system_prompt + "\n\n" + schedulingPrompt; + } else { + agent.system_prompt = schedulingPrompt; + } } } From 2a61683c27879905d265f91bd3f4459b26226b87 Mon Sep 17 00:00:00 2001 From: agent Date: Wed, 4 Mar 2026 08:29:41 -0800 Subject: [PATCH 20/48] fix(core): always inject system prompt even with custom MCP server When an operator explicitly declares a custom herdctl-scheduler MCP server, the system prompt guidance should still be appended. Previously the `continue` statement skipped both MCP injection and prompt injection. Addresses CodeRabbit review feedback on PR #192. Co-Authored-By: Claude Opus 4.6 --- .../config/__tests__/self-scheduling.test.ts | 7 ++-- packages/core/src/config/self-scheduling.ts | 32 +++++++++---------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/core/src/config/__tests__/self-scheduling.test.ts b/packages/core/src/config/__tests__/self-scheduling.test.ts index 2cc1f262..46f2094d 100644 --- a/packages/core/src/config/__tests__/self-scheduling.test.ts +++ b/packages/core/src/config/__tests__/self-scheduling.test.ts @@ -199,7 +199,7 @@ describe("injectSchedulerMcpServers", () => { expect(agents[0].system_prompt).toBeUndefined(); }); - it("does not inject system prompt when operator declared custom MCP server", () => { + it("injects system prompt even when operator declared custom MCP server", () => { const agents = [ makeAgent({ self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, @@ -214,7 +214,8 @@ describe("injectSchedulerMcpServers", () => { injectSchedulerMcpServers(agents, "/tmp/.herdctl"); - // Custom MCP server preserved, no system prompt injected - expect(agents[0].system_prompt).toBeUndefined(); + // Custom MCP server preserved, but system prompt still injected + expect(agents[0].mcp_servers!["herdctl-scheduler"].command).toBe("custom-scheduler"); + expect(agents[0].system_prompt).toContain("# Self-Scheduling"); }); }); diff --git a/packages/core/src/config/self-scheduling.ts b/packages/core/src/config/self-scheduling.ts index 8f605fea..20f55860 100644 --- a/packages/core/src/config/self-scheduling.ts +++ b/packages/core/src/config/self-scheduling.ts @@ -78,23 +78,23 @@ export function injectSchedulerMcpServers(agents: ResolvedAgent[], stateDir: str } // Don't overwrite if operator explicitly declared the server - if ("herdctl-scheduler" in agent.mcp_servers) continue; - - agent.mcp_servers["herdctl-scheduler"] = { - command: "node", - args: [mcpPath], - env: { - HERDCTL_AGENT_NAME: agent.qualifiedName, - HERDCTL_STATE_DIR: stateDir, - HERDCTL_MAX_SCHEDULES: String(selfScheduling.max_schedules ?? 10), - HERDCTL_MIN_INTERVAL: selfScheduling.min_interval ?? "5m", - ...(staticScheduleNames.length > 0 && { - HERDCTL_STATIC_SCHEDULES: staticScheduleNames.join(","), - }), - }, - }; + if (!("herdctl-scheduler" in agent.mcp_servers)) { + agent.mcp_servers["herdctl-scheduler"] = { + command: "node", + args: [mcpPath], + env: { + HERDCTL_AGENT_NAME: agent.qualifiedName, + HERDCTL_STATE_DIR: stateDir, + HERDCTL_MAX_SCHEDULES: String(selfScheduling.max_schedules ?? 10), + HERDCTL_MIN_INTERVAL: selfScheduling.min_interval ?? "5m", + ...(staticScheduleNames.length > 0 && { + HERDCTL_STATIC_SCHEDULES: staticScheduleNames.join(","), + }), + }, + }; + } - // Append self-scheduling guidance to the agent's system prompt + // Always append self-scheduling guidance to the agent's system prompt const schedulingPrompt = buildSchedulingPrompt(selfScheduling); if (agent.system_prompt) { agent.system_prompt = agent.system_prompt + "\n\n" + schedulingPrompt; From c739d7ec175a5005e587614586abd89ca7e5dc30 Mon Sep 17 00:00:00 2001 From: agent Date: Wed, 4 Mar 2026 15:28:36 -0800 Subject: [PATCH 21/48] bd: backup 2026-03-04 23:28 --- .beads/backup/backup_state.json | 13 +++++++++++++ .beads/backup/comments.jsonl | 0 .beads/backup/config.jsonl | 11 +++++++++++ .beads/backup/dependencies.jsonl | 0 .beads/backup/events.jsonl | 0 .beads/backup/issues.jsonl | 0 .beads/backup/labels.jsonl | 0 7 files changed, 24 insertions(+) create mode 100644 .beads/backup/backup_state.json create mode 100644 .beads/backup/comments.jsonl create mode 100644 .beads/backup/config.jsonl create mode 100644 .beads/backup/dependencies.jsonl create mode 100644 .beads/backup/events.jsonl create mode 100644 .beads/backup/issues.jsonl create mode 100644 .beads/backup/labels.jsonl diff --git a/.beads/backup/backup_state.json b/.beads/backup/backup_state.json new file mode 100644 index 00000000..177cf89e --- /dev/null +++ b/.beads/backup/backup_state.json @@ -0,0 +1,13 @@ +{ + "last_dolt_commit": "mjj13v60ua6nqvn3bvvo56v8krmtgaqe", + "last_event_id": 0, + "timestamp": "2026-03-04T23:28:36.137989Z", + "counts": { + "issues": 0, + "events": 0, + "comments": 0, + "dependencies": 0, + "labels": 0, + "config": 11 + } +} \ No newline at end of file diff --git a/.beads/backup/comments.jsonl b/.beads/backup/comments.jsonl new file mode 100644 index 00000000..e69de29b diff --git a/.beads/backup/config.jsonl b/.beads/backup/config.jsonl new file mode 100644 index 00000000..112a0b6f --- /dev/null +++ b/.beads/backup/config.jsonl @@ -0,0 +1,11 @@ +{"key":"auto_compact_enabled","value":"false"} +{"key":"compact_batch_size","value":"50"} +{"key":"compact_parallel_workers","value":"5"} +{"key":"compact_tier1_days","value":"30"} +{"key":"compact_tier1_dep_levels","value":"2"} +{"key":"compact_tier2_commits","value":"100"} +{"key":"compact_tier2_days","value":"90"} +{"key":"compact_tier2_dep_levels","value":"5"} +{"key":"compaction_enabled","value":"false"} +{"key":"issue_prefix","value":"herdctl"} +{"key":"schema_version","value":"6"} diff --git a/.beads/backup/dependencies.jsonl b/.beads/backup/dependencies.jsonl new file mode 100644 index 00000000..e69de29b diff --git a/.beads/backup/events.jsonl b/.beads/backup/events.jsonl new file mode 100644 index 00000000..e69de29b diff --git a/.beads/backup/issues.jsonl b/.beads/backup/issues.jsonl new file mode 100644 index 00000000..e69de29b diff --git a/.beads/backup/labels.jsonl b/.beads/backup/labels.jsonl new file mode 100644 index 00000000..e69de29b From 0127223a61c2d11a25c5df4df4ffca2073b81c3b Mon Sep 17 00:00:00 2001 From: agent Date: Wed, 4 Mar 2026 15:50:42 -0800 Subject: [PATCH 22/48] bd: backup 2026-03-04 23:50 --- .beads/backup/backup_state.json | 10 +++++----- .beads/backup/dependencies.jsonl | 16 ++++++++++++++++ .beads/backup/events.jsonl | 10 ++++++++++ .beads/backup/issues.jsonl | 10 ++++++++++ 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/.beads/backup/backup_state.json b/.beads/backup/backup_state.json index 177cf89e..b5c2cfbc 100644 --- a/.beads/backup/backup_state.json +++ b/.beads/backup/backup_state.json @@ -1,12 +1,12 @@ { - "last_dolt_commit": "mjj13v60ua6nqvn3bvvo56v8krmtgaqe", + "last_dolt_commit": "mcgoglttb785sns4t2u2d5avsvj06ql0", "last_event_id": 0, - "timestamp": "2026-03-04T23:28:36.137989Z", + "timestamp": "2026-03-04T23:50:42.764912Z", "counts": { - "issues": 0, - "events": 0, + "issues": 10, + "events": 10, "comments": 0, - "dependencies": 0, + "dependencies": 16, "labels": 0, "config": 11 } diff --git a/.beads/backup/dependencies.jsonl b/.beads/backup/dependencies.jsonl index e69de29b..06943246 100644 --- a/.beads/backup/dependencies.jsonl +++ b/.beads/backup/dependencies.jsonl @@ -0,0 +1,16 @@ +{"created_at":"2026-03-04T15:29:11Z","created_by":"agent","depends_on_id":"herdctl-gi7","issue_id":"herdctl-60a","type":"blocks"} +{"created_at":"2026-03-04T15:29:10Z","created_by":"agent","depends_on_id":"herdctl-klh","issue_id":"herdctl-60a","type":"blocks"} +{"created_at":"2026-03-04T15:29:12Z","created_by":"agent","depends_on_id":"herdctl-r3t","issue_id":"herdctl-60a","type":"blocks"} +{"created_at":"2026-03-04T15:29:16Z","created_by":"agent","depends_on_id":"herdctl-gi7","issue_id":"herdctl-com","type":"blocks"} +{"created_at":"2026-03-04T15:29:16Z","created_by":"agent","depends_on_id":"herdctl-klh","issue_id":"herdctl-com","type":"blocks"} +{"created_at":"2026-03-04T15:29:17Z","created_by":"agent","depends_on_id":"herdctl-r3t","issue_id":"herdctl-com","type":"blocks"} +{"created_at":"2026-03-04T15:29:15Z","created_by":"agent","depends_on_id":"herdctl-uwa","issue_id":"herdctl-com","type":"blocks"} +{"created_at":"2026-03-04T15:29:08Z","created_by":"agent","depends_on_id":"herdctl-klh","issue_id":"herdctl-gi7","type":"blocks"} +{"created_at":"2026-03-04T15:29:08Z","created_by":"agent","depends_on_id":"herdctl-r3t","issue_id":"herdctl-gi7","type":"blocks"} +{"created_at":"2026-03-04T15:29:07Z","created_by":"agent","depends_on_id":"herdctl-uwa","issue_id":"herdctl-gi7","type":"blocks"} +{"created_at":"2026-03-04T15:29:06Z","created_by":"agent","depends_on_id":"herdctl-uwa","issue_id":"herdctl-klh","type":"blocks"} +{"created_at":"2026-03-04T15:29:13Z","created_by":"agent","depends_on_id":"herdctl-3ee","issue_id":"herdctl-qto","type":"blocks"} +{"created_at":"2026-03-04T15:29:13Z","created_by":"agent","depends_on_id":"herdctl-k0i","issue_id":"herdctl-qto","type":"blocks"} +{"created_at":"2026-03-04T15:29:14Z","created_by":"agent","depends_on_id":"herdctl-r3t","issue_id":"herdctl-qto","type":"blocks"} +{"created_at":"2026-03-04T15:29:10Z","created_by":"agent","depends_on_id":"herdctl-uwa","issue_id":"herdctl-r3t","type":"blocks"} +{"created_at":"2026-03-04T15:29:09Z","created_by":"agent","depends_on_id":"herdctl-uwa","issue_id":"herdctl-zjd","type":"blocks"} diff --git a/.beads/backup/events.jsonl b/.beads/backup/events.jsonl index e69de29b..52d73c6c 100644 --- a/.beads/backup/events.jsonl +++ b/.beads/backup/events.jsonl @@ -0,0 +1,10 @@ +{"actor":"agent","comment":null,"created_at":"2026-03-04T15:28:59Z","event_type":"created","id":1,"issue_id":"herdctl-uwa","new_value":"","old_value":""} +{"actor":"agent","comment":null,"created_at":"2026-03-04T15:28:59Z","event_type":"created","id":2,"issue_id":"herdctl-klh","new_value":"","old_value":""} +{"actor":"agent","comment":null,"created_at":"2026-03-04T15:29:00Z","event_type":"created","id":3,"issue_id":"herdctl-gi7","new_value":"","old_value":""} +{"actor":"agent","comment":null,"created_at":"2026-03-04T15:29:01Z","event_type":"created","id":4,"issue_id":"herdctl-3ee","new_value":"","old_value":""} +{"actor":"agent","comment":null,"created_at":"2026-03-04T15:29:02Z","event_type":"created","id":5,"issue_id":"herdctl-zjd","new_value":"","old_value":""} +{"actor":"agent","comment":null,"created_at":"2026-03-04T15:29:02Z","event_type":"created","id":6,"issue_id":"herdctl-r3t","new_value":"","old_value":""} +{"actor":"agent","comment":null,"created_at":"2026-03-04T15:29:03Z","event_type":"created","id":7,"issue_id":"herdctl-k0i","new_value":"","old_value":""} +{"actor":"agent","comment":null,"created_at":"2026-03-04T15:29:04Z","event_type":"created","id":8,"issue_id":"herdctl-60a","new_value":"","old_value":""} +{"actor":"agent","comment":null,"created_at":"2026-03-04T15:29:05Z","event_type":"created","id":9,"issue_id":"herdctl-qto","new_value":"","old_value":""} +{"actor":"agent","comment":null,"created_at":"2026-03-04T15:29:05Z","event_type":"created","id":10,"issue_id":"herdctl-com","new_value":"","old_value":""} diff --git a/.beads/backup/issues.jsonl b/.beads/backup/issues.jsonl index e69de29b..6db080b1 100644 --- a/.beads/backup/issues.jsonl +++ b/.beads/backup/issues.jsonl @@ -0,0 +1,10 @@ +{"acceptance_criteria":"1) Commands are registered and executable in supported channels/DMs. 2) Correct behavior for active/inactive run states. 3) Clear ephemeral success/error responses.","actor":"","agent_state":"","assignee":null,"await_id":"","await_type":"","close_reason":"","closed_at":null,"closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"c3e4b66f5be19dccae9dc7ea6be53320d112acc6cfed21068fbdde2298853d6e","created_at":"2026-03-04T23:29:01Z","created_by":"agent","crystallizes":0,"defer_until":null,"description":"Extend command surface with execution/session controls for better operational usability in Discord.","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"herdctl-3ee","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"oliver.heckmann@gmail.com","payload":"","pinned":0,"priority":2,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"open","target":"","timeout_ns":0,"title":"Discord: Add slash control commands (/stop, /retry, /new, /session)","updated_at":"2026-03-04T23:29:01Z","waiters":"","wisp_type":"","work_type":""} +{"acceptance_criteria":"1) Shared embed builder utilities added. 2) Color/label/footer semantics are consistent. 3) Snapshot tests cover key templates.","actor":"","agent_state":"","assignee":null,"await_id":"","await_type":"","close_reason":"","closed_at":null,"closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"7f266404dd35390bfd1852f4cc793e5e25e63d027262909de4e32565d33a672f","created_at":"2026-03-04T23:29:04Z","created_by":"agent","crystallizes":0,"defer_until":null,"description":"Centralize embed builders and visual semantics (progress/answer/trace/success/error) for consistent look and readability.","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"herdctl-60a","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"oliver.heckmann@gmail.com","payload":"","pinned":0,"priority":2,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"open","target":"","timeout_ns":0,"title":"Discord: Embed/theme design system refactor","updated_at":"2026-03-04T23:29:04Z","waiters":"","wisp_type":"","work_type":""} +{"acceptance_criteria":"1) Test fixtures include representative tool-heavy, error, and status flows. 2) Rendering parity checks exist for both runtimes. 3) Future format drift is caught by tests.","actor":"","agent_state":"","assignee":null,"await_id":"","await_type":"","close_reason":"","closed_at":null,"closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"6cbb6a0de9b59bfa1b51d4ce43e3fb191eb9421d4165061407173b2b5842a120","created_at":"2026-03-04T23:29:06Z","created_by":"agent","crystallizes":0,"defer_until":null,"description":"Add fixture-driven regression tests that assert equivalent user-visible behavior across SDK and CLI runtime message streams.","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"herdctl-com","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"oliver.heckmann@gmail.com","payload":"","pinned":0,"priority":2,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"open","target":"","timeout_ns":0,"title":"Discord: End-to-end regression suite for SDK + CLI runtime streams","updated_at":"2026-03-04T23:29:06Z","waiters":"","wisp_type":"","work_type":""} +{"acceptance_criteria":"1) Message count reduced for complex runs. 2) Final answer remains obvious and readable. 3) Large outputs are attached instead of flooding chat.","actor":"","agent_state":"","assignee":null,"await_id":"","await_type":"","close_reason":"","closed_at":null,"closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"90e044bb40551aa6156677be32ad33489429146a530f0151e8cdb254cae07bb4","created_at":"2026-03-04T23:29:01Z","created_by":"agent","crystallizes":0,"defer_until":null,"description":"Replace per-event embed burst with a persistent run card plus grouped tool trace updates; attach oversized tool output as files with short previews.","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"herdctl-gi7","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"oliver.heckmann@gmail.com","payload":"","pinned":0,"priority":2,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"open","target":"","timeout_ns":0,"title":"Discord: Run card and grouped trace UX","updated_at":"2026-03-04T23:29:01Z","waiters":"","wisp_type":"","work_type":""} +{"acceptance_criteria":"1) Global path remains default. 2) Guild-scoped path registers correctly for selected guilds. 3) Docs describe tradeoffs and usage.","actor":"","agent_state":"","assignee":null,"await_id":"","await_type":"","close_reason":"","closed_at":null,"closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"0430a137a7092776b18bde96241ba13dc5cbedeea5a154f77bd3bac0973279b0","created_at":"2026-03-04T23:29:04Z","created_by":"agent","crystallizes":0,"defer_until":null,"description":"Add optional guild-scoped command registration for faster development propagation while preserving global default for production.","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"herdctl-k0i","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"oliver.heckmann@gmail.com","payload":"","pinned":0,"priority":2,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"open","target":"","timeout_ns":0,"title":"Discord: Configurable slash command registration mode (global vs guild)","updated_at":"2026-03-04T23:29:04Z","waiters":"","wisp_type":"","work_type":""} +{"acceptance_criteria":"1) Live deltas appear in Discord when available. 2) No duplicate final answer content. 3) Fallback path works when only assistant snapshots are present.","actor":"","agent_state":"","assignee":null,"await_id":"","await_type":"","close_reason":"","closed_at":null,"closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"f718c850b20471775c6bf569f852b31fae04ec3f73bb5691d1ed056afb005c58","created_at":"2026-03-04T23:29:00Z","created_by":"agent","crystallizes":0,"defer_until":null,"description":"Upgrade response delivery to support live delta streaming (stream_event/content_block_delta) into an editable answer message, with fallback to finalized assistant snapshots.","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"herdctl-klh","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"oliver.heckmann@gmail.com","payload":"","pinned":0,"priority":2,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"open","target":"","timeout_ns":0,"title":"Discord: Incremental answer streaming via stream_event","updated_at":"2026-03-04T23:29:00Z","waiters":"","wisp_type":"","work_type":""} +{"acceptance_criteria":"1) Docs match schema defaults and runtime behavior. 2) Slash command invocation/troubleshooting is explicit. 3) Docs build/lint passes.","actor":"","agent_state":"","assignee":null,"await_id":"","await_type":"","close_reason":"","closed_at":null,"closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"2bb093b690e8d06f95f46ef383cc4091ded2c367ab1f89c8f6caf7cb7c433f79","created_at":"2026-03-04T23:29:05Z","created_by":"agent","crystallizes":0,"defer_until":null,"description":"Fix docs mismatches (including result_summary defaults), document slash invocation UX, and explain SDK vs CLI runtime message differences.","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"herdctl-qto","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"oliver.heckmann@gmail.com","payload":"","pinned":0,"priority":2,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"open","target":"","timeout_ns":0,"title":"Discord docs: Slash command UX + defaults/runtime parity","updated_at":"2026-03-04T23:29:05Z","waiters":"","wisp_type":"","work_type":""} +{"acceptance_criteria":"1) tool_progress updates run/progress state without spam. 2) auth_status shows only actionable notices. 3) Behavior is tested across SDK and CLI streams.","actor":"","agent_state":"","assignee":null,"await_id":"","await_type":"","close_reason":"","closed_at":null,"closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"9235b5cb474d3f3b7fec7eb12f77e0eb7d2bb78c9e8af9ef0191aa87cbf2beb9","created_at":"2026-03-04T23:29:03Z","created_by":"agent","crystallizes":0,"defer_until":null,"description":"Handle additional SDK message classes explicitly in Discord UX so users receive useful state updates without noise.","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"herdctl-r3t","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"oliver.heckmann@gmail.com","payload":"","pinned":0,"priority":2,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"open","target":"","timeout_ns":0,"title":"Discord: Surface tool_progress and auth_status messages","updated_at":"2026-03-04T23:29:03Z","waiters":"","wisp_type":"","work_type":""} +{"acceptance_criteria":"1) Canonical event model implemented and used by Discord manager. 2) Tests cover SDK-native and CLI-native streams. 3) Existing happy-path output remains functionally equivalent.","actor":"","agent_state":"","assignee":null,"await_id":"","await_type":"","close_reason":"","closed_at":null,"closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"0a6d9d024453bb3753bb0e0d01f06bde419cbca0e93055d26f7fc5d7aa856caf","created_at":"2026-03-04T23:28:59Z","created_by":"agent","crystallizes":0,"defer_until":null,"description":"Add a normalization layer in @herdctl/discord that maps raw SDKMessage streams from both SDKRuntime and CLIRuntime into canonical chat events before rendering.","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"herdctl-uwa","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"oliver.heckmann@gmail.com","payload":"","pinned":0,"priority":2,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"open","target":"","timeout_ns":0,"title":"Discord: Runtime-agnostic message normalizer","updated_at":"2026-03-04T23:28:59Z","waiters":"","wisp_type":"","work_type":""} +{"acceptance_criteria":"1) Fresh-session prompts include formatted recent context. 2) Resume path does not duplicate history. 3) Tests cover empty and reply-chain-heavy contexts.","actor":"","agent_state":"","assignee":null,"await_id":"","await_type":"","close_reason":"","closed_at":null,"closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"f0e99aab40d55766ae72a9c5d203f327dd6d08a2739e5236f95c76c915333bca","created_at":"2026-03-04T23:29:02Z","created_by":"agent","crystallizes":0,"defer_until":null,"description":"Use built conversation context when no resume session is present; avoid re-injecting context on resume path.","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"herdctl-zjd","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"oliver.heckmann@gmail.com","payload":"","pinned":0,"priority":2,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"open","target":"","timeout_ns":0,"title":"Discord: Inject context_messages on fresh session path","updated_at":"2026-03-04T23:29:02Z","waiters":"","wisp_type":"","work_type":""} From 2f1f018751249f7830c3d2159e8303fd9e5e29cb Mon Sep 17 00:00:00 2001 From: agent Date: Wed, 4 Mar 2026 15:58:08 -0800 Subject: [PATCH 23/48] Enhance Discord chat UX, slash controls, and runtime parity --- .gitignore | 6 +- AGENTS.md | 113 +++ .../src/content/docs/integrations/discord.mdx | 29 +- .../core/src/config/__tests__/schema.test.ts | 27 + packages/core/src/config/schema.ts | 20 + .../core/src/fleet-manager/job-control.ts | 1 + packages/core/src/fleet-manager/types.ts | 8 + packages/discord/README.md | 8 +- .../__snapshots__/embeds.test.ts.snap | 96 +++ packages/discord/src/__tests__/embeds.test.ts | 58 ++ .../discord/src/__tests__/manager.test.ts | 10 +- .../src/__tests__/message-normalizer.test.ts | 92 +++ .../src/__tests__/runtime-parity.test.ts | 168 ++++ .../__tests__/command-manager.test.ts | 30 +- .../__tests__/extended-commands.test.ts | 80 ++ .../discord/src/commands/command-manager.ts | 26 +- packages/discord/src/commands/help.ts | 4 + packages/discord/src/commands/index.ts | 6 + packages/discord/src/commands/new.ts | 26 + packages/discord/src/commands/retry.ts | 29 + packages/discord/src/commands/session.ts | 55 ++ packages/discord/src/commands/stop.ts | 29 + packages/discord/src/commands/types.ts | 27 + packages/discord/src/discord-connector.ts | 10 + packages/discord/src/embeds.ts | 163 ++++ packages/discord/src/index.ts | 13 +- packages/discord/src/manager.ts | 725 ++++++++++-------- packages/discord/src/message-normalizer.ts | 177 +++++ packages/discord/src/types.ts | 10 + 29 files changed, 1710 insertions(+), 336 deletions(-) create mode 100644 packages/discord/src/__tests__/__snapshots__/embeds.test.ts.snap create mode 100644 packages/discord/src/__tests__/embeds.test.ts create mode 100644 packages/discord/src/__tests__/message-normalizer.test.ts create mode 100644 packages/discord/src/__tests__/runtime-parity.test.ts create mode 100644 packages/discord/src/commands/__tests__/extended-commands.test.ts create mode 100644 packages/discord/src/commands/new.ts create mode 100644 packages/discord/src/commands/retry.ts create mode 100644 packages/discord/src/commands/session.ts create mode 100644 packages/discord/src/commands/stop.ts create mode 100644 packages/discord/src/embeds.ts create mode 100644 packages/discord/src/message-normalizer.ts diff --git a/.gitignore b/.gitignore index edb9d7eb..4c3ddc31 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,8 @@ coverage/ agent-distribution.md docs/MANUAL-QA-TEST-PLAN.md.planning/ -.planning/phases \ No newline at end of file +.planning/phases + +# Dolt database files (added by bd init) +.dolt/ +*.db diff --git a/AGENTS.md b/AGENTS.md index df7a4af9..1f22a290 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,3 +38,116 @@ bd sync # Sync with git - NEVER say "ready to push when you are" - YOU must push - If push fails, resolve and retry until it succeeds + + +## Issue Tracking with bd (beads) + +**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods. + +### Why bd? + +- Dependency-aware: Track blockers and relationships between issues +- Git-friendly: Dolt-powered version control with native sync +- Agent-optimized: JSON output, ready work detection, discovered-from links +- Prevents duplicate tracking systems and confusion + +### Quick Start + +**Check for ready work:** + +```bash +bd ready --json +``` + +**Create new issues:** + +```bash +bd create "Issue title" --description="Detailed context" -t bug|feature|task -p 0-4 --json +bd create "Issue title" --description="What this issue is about" -p 1 --deps discovered-from:bd-123 --json +``` + +**Claim and update:** + +```bash +bd update --claim --json +bd update bd-42 --priority 1 --json +``` + +**Complete work:** + +```bash +bd close bd-42 --reason "Completed" --json +``` + +### Issue Types + +- `bug` - Something broken +- `feature` - New functionality +- `task` - Work item (tests, docs, refactoring) +- `epic` - Large feature with subtasks +- `chore` - Maintenance (dependencies, tooling) + +### Priorities + +- `0` - Critical (security, data loss, broken builds) +- `1` - High (major features, important bugs) +- `2` - Medium (default, nice-to-have) +- `3` - Low (polish, optimization) +- `4` - Backlog (future ideas) + +### Workflow for AI Agents + +1. **Check ready work**: `bd ready` shows unblocked issues +2. **Claim your task atomically**: `bd update --claim` +3. **Work on it**: Implement, test, document +4. **Discover new work?** Create linked issue: + - `bd create "Found bug" --description="Details about what was found" -p 1 --deps discovered-from:` +5. **Complete**: `bd close --reason "Done"` + +### Auto-Sync + +bd automatically syncs via Dolt: + +- Each write auto-commits to Dolt history +- Use `bd dolt push`/`bd dolt pull` for remote sync +- No manual export/import needed! + +### Important Rules + +- ✅ Use bd for ALL task tracking +- ✅ Always use `--json` flag for programmatic use +- ✅ Link discovered work with `discovered-from` dependencies +- ✅ Check `bd ready` before asking "what should I work on?" +- ❌ Do NOT create markdown TODO lists +- ❌ Do NOT use external issue trackers +- ❌ Do NOT duplicate tracking systems + +For more details, see README.md and docs/QUICKSTART.md. + +## Landing the Plane (Session Completion) + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + bd sync + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds + + diff --git a/docs/src/content/docs/integrations/discord.mdx b/docs/src/content/docs/integrations/discord.mdx index b2310627..ef503548 100644 --- a/docs/src/content/docs/integrations/discord.mdx +++ b/docs/src/content/docs/integrations/discord.mdx @@ -259,12 +259,19 @@ chat: # Log level: minimal, standard, verbose (default: standard) log_level: standard + # Optional: slash command registration mode + # global = available everywhere (slower to propagate) + # guild = faster updates for one guild (recommended for local dev) + command_registration: + scope: global # global | guild + # guild_id: "123456789012345678" # required when scope: guild + # Output configuration - control what SDK messages appear in Discord output: tool_results: true # Show tool result embeds (default: true) tool_result_max_length: 900 # Max chars in tool output (default: 900, max: 1000) system_status: true # Show system status embeds (default: true) - result_summary: false # Show completion summary embed (default: false) + result_summary: true # Show completion summary embed (default: true) errors: true # Show error embeds (default: true) # Bot presence/activity (optional) @@ -308,6 +315,7 @@ chat: | `bot_token_env` | string | — | **Required.** Environment variable name containing the bot token | | `session_expiry_hours` | number | `24` | Hours before a conversation session expires | | `log_level` | string | `standard` | Logging verbosity: `minimal`, `standard`, `verbose` | +| `command_registration` | object | `{ scope: global }` | Slash command registration mode (`global` or `guild`) | | `presence` | object | — | Bot presence/activity configuration | | `guilds` | array | — | List of Discord servers to operate in | | `dm` | object | — | Global DM configuration | @@ -319,6 +327,13 @@ chat: | `activity_type` | string | Activity type: `playing`, `watching`, `listening`, `competing` | | `activity_message` | string | Activity text shown in the bot's status | +#### Command Registration Settings + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `scope` | string | `global` | `global` registers app commands globally; `guild` registers to one guild for faster propagation | +| `guild_id` | string | — | Required when `scope: guild`; target guild for registration | + #### Guild Settings | Field | Type | Description | @@ -354,7 +369,7 @@ Control which SDK messages are surfaced in Discord. When your agent uses tools ( | `output.tool_results` | boolean | `true` | Show tool result embeds when the agent uses tools | | `output.tool_result_max_length` | number | `900` | Maximum characters shown in tool output (max: 1000) | | `output.system_status` | boolean | `true` | Show system status embeds (e.g., "Compacting context...") | -| `output.result_summary` | boolean | `false` | Show a summary embed when the agent finishes (duration, cost, tokens) | +| `output.result_summary` | boolean | `true` | Show a summary embed when the agent finishes (duration, cost, tokens) | | `output.errors` | boolean | `true` | Show error embeds when the SDK reports errors | All output types appear as compact Discord embeds with color coding: @@ -370,7 +385,7 @@ All output types appear as compact Discord embeds with color coding: Tool result embeds include the tool name with an emoji, the input summary (command, file path, or search pattern), execution duration, output length, and a truncated preview of the output in a code block. **Minimal output (text responses only):** @@ -563,7 +578,15 @@ herdctl automatically registers slash commands for every Discord-enabled agent: |---------|-------------| | `/help` | Show available commands and usage | | `/status` | Show bot connection status and session info | +| `/session` | Show current session and run state for this channel | | `/reset` | Clear conversation context and start fresh | +| `/new` | Alias for starting a fresh conversation | +| `/stop` | Stop the active run in this channel | +| `/retry` | Retry the last prompt in this channel | + +#### How slash commands are entered + +Discord slash commands are not regular chat messages. Type `/` in the message box, then pick the command under your bot's app name in Discord's command picker. Discord sends this as an interaction event (not a normal message), which herdctl handles via the command manager. Try them in any channel where the bot is active: diff --git a/packages/core/src/config/__tests__/schema.test.ts b/packages/core/src/config/__tests__/schema.test.ts index 7427df6c..d1e7b52e 100644 --- a/packages/core/src/config/__tests__/schema.test.ts +++ b/packages/core/src/config/__tests__/schema.test.ts @@ -1748,6 +1748,33 @@ describe("AgentChatDiscordSchema", () => { }); expect(result.success).toBe(false); }); + + it("accepts guild-scoped command registration when guild_id is provided", () => { + const result = AgentChatDiscordSchema.safeParse({ + bot_token_env: "TOKEN", + guilds: [{ id: "123", channels: [{ id: "456" }] }], + command_registration: { + scope: "guild", + guild_id: "123", + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.command_registration?.scope).toBe("guild"); + expect(result.data.command_registration?.guild_id).toBe("123"); + } + }); + + it("rejects guild-scoped command registration without guild_id", () => { + const result = AgentChatDiscordSchema.safeParse({ + bot_token_env: "TOKEN", + guilds: [{ id: "123", channels: [{ id: "456" }] }], + command_registration: { + scope: "guild", + }, + }); + expect(result.success).toBe(false); + }); }); describe("AgentChatSchema", () => { diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 0e2c8f38..ed8a1645 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -639,6 +639,24 @@ export const DiscordOutputSchema = ChatOutputSchema.extend({ progress_indicator: z.boolean().optional().default(true), }); +/** + * Discord slash command registration settings + * + * Controls whether commands are registered globally (default) or per-guild. + * Guild registration propagates faster and is useful for local development. + */ +export const DiscordCommandRegistrationSchema = z + .object({ + /** Command registration scope (default: global) */ + scope: z.enum(["global", "guild"]).optional().default("global"), + /** Target guild ID when using scope: guild */ + guild_id: z.string().optional(), + }) + .refine((v) => v.scope !== "guild" || Boolean(v.guild_id), { + message: "guild_id is required when scope is 'guild'", + path: ["guild_id"], + }); + /** * Discord voice message transcription configuration * @@ -759,6 +777,8 @@ export const AgentChatDiscordSchema = z.object({ voice: DiscordVoiceSchema.optional(), /** File attachment handling configuration */ attachments: DiscordAttachmentsSchema.optional(), + /** Slash command registration mode */ + command_registration: DiscordCommandRegistrationSchema.optional(), }); // ============================================================================= diff --git a/packages/core/src/fleet-manager/job-control.ts b/packages/core/src/fleet-manager/job-control.ts index 56173d42..f9c2e274 100644 --- a/packages/core/src/fleet-manager/job-control.ts +++ b/packages/core/src/fleet-manager/job-control.ts @@ -163,6 +163,7 @@ export class JobControl { schedule: scheduleName, outputToFile: schedule?.outputToFile ?? false, onMessage: options?.onMessage, + onJobCreated: options?.onJobCreated, resume: sessionId, injectedMcpServers: options?.injectedMcpServers, systemPromptAppend: options?.systemPromptAppend, diff --git a/packages/core/src/fleet-manager/types.ts b/packages/core/src/fleet-manager/types.ts index 26ad2ae4..ba66eb71 100644 --- a/packages/core/src/fleet-manager/types.ts +++ b/packages/core/src/fleet-manager/types.ts @@ -582,6 +582,14 @@ export interface TriggerOptions { */ onMessage?: (message: import("../runner/types.js").SDKMessage) => void | Promise; + /** + * Callback invoked as soon as a job ID is created. + * + * Useful for chat connectors that need immediate job control (for example, + * enabling /stop while output is still streaming). + */ + onJobCreated?: (jobId: string) => void | Promise; + /** * MCP servers to inject into the agent's runtime session * diff --git a/packages/discord/README.md b/packages/discord/README.md index 5f4fbd69..2777f824 100644 --- a/packages/discord/README.md +++ b/packages/discord/README.md @@ -140,7 +140,7 @@ This allows you to run multiple agents with different Discord identities, each w - **DM Support** - Users can chat privately with agents - **Channel Support** - Agents can participate in server channels - **Per-Agent Bots** - Each agent can have its own Discord bot identity -- **Slash Commands** - Built-in `/status`, `/reset`, and `/help` commands +- **Slash Commands** - Built-in `/help`, `/status`, `/session`, `/reset`, `/new`, `/stop`, `/retry` - **Typing Indicators** - Visual feedback while agent is processing - **Message Splitting** - Long responses are automatically split to fit Discord's limits @@ -150,7 +150,13 @@ This allows you to run multiple agents with different Discord identities, each w |---------|-------------| | `/help` | Show available commands and usage | | `/status` | Show agent status and current session info | +| `/session` | Show session and run state for the current channel | | `/reset` | Clear conversation context (start fresh) | +| `/new` | Start a fresh conversation (alias for reset behavior) | +| `/stop` | Stop the active run in this channel | +| `/retry` | Retry the last prompt in this channel | + +To invoke slash commands, type `/` in Discord and pick the command under your bot app in Discord's command picker. Slash commands are routed as interaction events, not regular text messages. ## Bot Setup diff --git a/packages/discord/src/__tests__/__snapshots__/embeds.test.ts.snap b/packages/discord/src/__tests__/__snapshots__/embeds.test.ts.snap new file mode 100644 index 00000000..ec489f47 --- /dev/null +++ b/packages/discord/src/__tests__/__snapshots__/embeds.test.ts.snap @@ -0,0 +1,96 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`embeds > builds result summary embeds 1`] = ` +{ + "color": 2278750, + "description": "**Task complete** in 2s · 3 turns · $0.0123 · 1.5k tokens", + "footer": { + "text": "herdctl · test-agent", + }, + "timestamp": "2026-03-04T12:00:00.000Z", +} +`; + +exports[`embeds > builds run card embeds with trace lines 1`] = ` +{ + "color": 9133302, + "description": "Running · 🔧 Bash", + "fields": [ + { + "inline": false, + "name": "Trace", + "value": "🔧 Bash · ls -la +✓ Bash · completed", + }, + ], + "footer": { + "text": "herdctl · test-agent", + }, + "timestamp": "2026-03-04T12:00:00.000Z", +} +`; + +exports[`embeds > builds status and error embeds 1`] = ` +{ + "color": 7041664, + "description": "Compacting context…", + "footer": { + "text": "herdctl · test-agent", + }, +} +`; + +exports[`embeds > builds status and error embeds 2`] = ` +{ + "color": 15680580, + "description": "**Error:** Boom", + "footer": { + "text": "herdctl · test-agent", + }, + "timestamp": "2026-03-04T12:00:00.000Z", +} +`; + +exports[`embeds > builds tool result embed with truncated output 1`] = ` +{ + "color": 5793266, + "description": "💻 **Bash** \`> echo hi\` — 2s", + "fields": [ + { + "inline": false, + "name": "Output", + "value": "\`\`\`ansi +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line + +… 999 chars total +\`\`\`", + }, + ], + "footer": { + "text": "herdctl · test-agent", + }, +} +`; diff --git a/packages/discord/src/__tests__/embeds.test.ts b/packages/discord/src/__tests__/embeds.test.ts new file mode 100644 index 00000000..1c01ee5d --- /dev/null +++ b/packages/discord/src/__tests__/embeds.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildErrorEmbed, + buildResultSummaryEmbed, + buildRunCardEmbed, + buildStatusEmbed, + buildToolResultEmbed, +} from "../embeds.js"; + +describe("embeds", () => { + it("builds run card embeds with trace lines", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-04T12:00:00.000Z")); + const embed = buildRunCardEmbed({ + agentName: "herdctl.test-agent", + status: "running", + message: "Running · 🔧 Bash", + traceLines: ["🔧 Bash · ls -la", "✓ Bash · completed"], + }); + expect(embed).toMatchSnapshot(); + vi.useRealTimers(); + }); + + it("builds result summary embeds", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-04T12:00:00.000Z")); + const embed = buildResultSummaryEmbed({ + agentName: "herdctl.test-agent", + isError: false, + durationMs: 2140, + numTurns: 3, + totalCostUsd: 0.0123, + usage: { input_tokens: 1200, output_tokens: 300 }, + }); + expect(embed).toMatchSnapshot(); + vi.useRealTimers(); + }); + + it("builds status and error embeds", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-04T12:00:00.000Z")); + expect( + buildStatusEmbed("Compacting context…", "system", "herdctl.test-agent"), + ).toMatchSnapshot(); + expect(buildErrorEmbed("Boom", "herdctl.test-agent")).toMatchSnapshot(); + vi.useRealTimers(); + }); + + it("builds tool result embed with truncated output", () => { + const embed = buildToolResultEmbed({ + toolUse: { name: "Bash", input: { command: "echo hi" }, startTime: Date.now() - 2000 }, + toolResult: { output: "line\n".repeat(200), isError: false }, + agentName: "herdctl.test-agent", + maxOutputChars: 120, + }); + expect(embed).toMatchSnapshot(); + }); +}); diff --git a/packages/discord/src/__tests__/manager.test.ts b/packages/discord/src/__tests__/manager.test.ts index 7428210a..b72a2685 100644 --- a/packages/discord/src/__tests__/manager.test.ts +++ b/packages/discord/src/__tests__/manager.test.ts @@ -1169,7 +1169,7 @@ describe("DiscordManager handleMessage pipeline", () => { // ---- Tool result embeds ---- - it("sends tool result embeds when showToolResults is true", async () => { + it("sends final answer without per-tool embed burst when tool results are enabled", async () => { const { manager, connector } = buildManagerWithTrigger(async (...args: unknown[]) => { const options = args[2] as { onMessage?: (m: unknown) => Promise } | undefined; if (options?.onMessage) { @@ -1218,17 +1218,13 @@ describe("DiscordManager handleMessage pipeline", () => { connector.emit("message", event); await new Promise((resolve) => setTimeout(resolve, 200)); - // Should have sent: tool result embed + final text + // Should have sent final text without a per-tool embed burst. const embedCalls = reply.mock.calls.filter( (call: unknown[]) => typeof call[0] === "object" && (call[0] as { embeds?: unknown[] }).embeds, ); const textCalls = reply.mock.calls.filter((call: unknown[]) => typeof call[0] === "string"); - - expect(embedCalls.length).toBeGreaterThanOrEqual(1); - const toolEmbed = embedCalls[0][0] as { embeds: DiscordReplyEmbed[] }; - expect(toolEmbed.embeds[0].description).toContain("Bash"); - + expect(embedCalls).toHaveLength(0); expect(textCalls).toHaveLength(1); expect(textCalls[0][0]).toBe("Found 2 files."); }); diff --git a/packages/discord/src/__tests__/message-normalizer.test.ts b/packages/discord/src/__tests__/message-normalizer.test.ts new file mode 100644 index 00000000..f9d76e4d --- /dev/null +++ b/packages/discord/src/__tests__/message-normalizer.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { normalizeDiscordMessage } from "../message-normalizer.js"; + +describe("normalizeDiscordMessage", () => { + it("extracts assistant final content and tool uses", () => { + const events = normalizeDiscordMessage({ + type: "assistant", + message: { + id: "msg-1", + stop_reason: "end_turn", + content: [ + { type: "text", text: "Done." }, + { type: "tool_use", id: "tool-1", name: "Bash", input: { command: "ls -la" } }, + ], + }, + }); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + kind: "assistant_final", + content: "Done.", + messageId: "msg-1", + }); + if (events[0].kind === "assistant_final") { + expect(events[0].toolUses).toHaveLength(1); + expect(events[0].toolUses[0].name).toBe("Bash"); + } + }); + + it("extracts stream_event text delta", () => { + const events = normalizeDiscordMessage({ + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "hello " }, + }, + }); + + expect(events).toEqual([{ kind: "assistant_delta", delta: "hello " }]); + }); + + it("extracts tool results from top-level tool_use_result", () => { + const events = normalizeDiscordMessage({ + type: "user", + tool_use_result: "output text", + }); + + expect(events).toHaveLength(1); + expect(events[0].kind).toBe("tool_results"); + if (events[0].kind === "tool_results") { + expect(events[0].results).toHaveLength(1); + expect(events[0].results[0]).toMatchObject({ output: "output text", isError: false }); + } + }); + + it("normalizes tool_progress and auth_status", () => { + const toolProgress = normalizeDiscordMessage({ + type: "tool_progress", + tool_name: "Bash", + }); + expect(toolProgress).toEqual([{ kind: "tool_progress", content: "Tool Bash in progress" }]); + + const auth = normalizeDiscordMessage({ + type: "auth_status", + output: ["Refreshing token", "Retrying..."], + }); + expect(auth).toEqual([ + { kind: "auth_status", content: "Refreshing token\nRetrying...", isError: false }, + ]); + }); + + it("produces equivalent result events for SDK and CLI-style result payloads", () => { + const sdkResult = normalizeDiscordMessage({ + type: "result", + result: "All done", + is_error: false, + duration_ms: 1200, + num_turns: 2, + usage: { input_tokens: 100, output_tokens: 50 }, + }); + const cliSyntheticResult = normalizeDiscordMessage({ + type: "result", + result: "All done", + is_error: false, + duration_ms: 1200, + num_turns: 2, + usage: { input_tokens: 100, output_tokens: 50 }, + }); + + expect(sdkResult).toEqual(cliSyntheticResult); + }); +}); diff --git a/packages/discord/src/__tests__/runtime-parity.test.ts b/packages/discord/src/__tests__/runtime-parity.test.ts new file mode 100644 index 00000000..67c9e007 --- /dev/null +++ b/packages/discord/src/__tests__/runtime-parity.test.ts @@ -0,0 +1,168 @@ +import type { SDKMessage } from "@herdctl/core"; +import { describe, expect, it } from "vitest"; +import { normalizeDiscordMessage } from "../message-normalizer.js"; + +function renderTimeline(messages: SDKMessage[]): string[] { + const lines: string[] = []; + for (const message of messages) { + for (const event of normalizeDiscordMessage(message)) { + switch (event.kind) { + case "assistant_delta": + lines.push(`delta:${event.delta}`); + break; + case "assistant_final": + if (event.content) { + lines.push(`answer:${event.content}`); + } + if (event.toolUses.length > 0) { + lines.push(`tools:${event.toolUses.map((t) => t.name).join(",")}`); + } + break; + case "tool_results": + for (const result of event.results) { + lines.push( + `tool_result:${result.isError ? "error" : "ok"}:${result.output.slice(0, 40)}`, + ); + } + break; + case "system_status": + lines.push(`system:${event.status}`); + break; + case "tool_progress": + lines.push(`progress:${event.content}`); + break; + case "auth_status": + lines.push(`auth:${event.isError ? "error" : "ok"}:${event.content}`); + break; + case "result": + lines.push(`result:${event.isError ? "error" : "ok"}:${event.resultText ?? ""}`); + break; + case "error": + lines.push(`error:${event.message}`); + break; + } + } + } + return lines; +} + +type RuntimeFixture = { + name: string; + sdk: SDKMessage[]; + cli: SDKMessage[]; +}; + +const fixtures: RuntimeFixture[] = [ + { + name: "tool-heavy flow", + sdk: [ + { + type: "assistant", + message: { + id: "a1", + stop_reason: null, + content: [{ type: "tool_use", id: "t1", name: "Bash", input: { command: "ls -la" } }], + }, + } as SDKMessage, + { + type: "user", + message: { + content: [{ type: "tool_result", tool_use_id: "t1", content: "file-a\nfile-b" }], + }, + } as SDKMessage, + { + type: "assistant", + message: { + id: "a1", + stop_reason: "end_turn", + content: [{ type: "text", text: "Done with listing." }], + }, + } as SDKMessage, + { type: "result", result: "Done with listing.", is_error: false } as SDKMessage, + ], + cli: [ + { + type: "assistant", + message: { + id: "a1", + stop_reason: null, + content: [{ type: "tool_use", id: "t1", name: "Bash", input: { command: "ls -la" } }], + }, + } as SDKMessage, + { type: "user", tool_use_result: "file-a\nfile-b" } as SDKMessage, + { + type: "assistant", + message: { + id: "a1", + stop_reason: "end_turn", + content: [{ type: "text", text: "Done with listing." }], + }, + } as SDKMessage, + { type: "result", result: "Done with listing.", is_error: false } as SDKMessage, + ], + }, + { + name: "streaming answer flow", + sdk: [ + { + type: "stream_event", + event: { type: "content_block_delta", delta: { type: "text_delta", text: "Hello " } }, + } as SDKMessage, + { + type: "stream_event", + event: { type: "content_block_delta", delta: { type: "text_delta", text: "world" } }, + } as SDKMessage, + { + type: "assistant", + message: { + id: "a2", + stop_reason: "end_turn", + content: [{ type: "text", text: "Hello world" }], + }, + } as SDKMessage, + { type: "result", result: "Hello world", is_error: false } as SDKMessage, + ], + cli: [ + { + type: "stream_event", + event: { type: "content_block_delta", delta: { type: "text_delta", text: "Hello " } }, + } as SDKMessage, + { + type: "stream_event", + event: { type: "content_block_delta", delta: { type: "text_delta", text: "world" } }, + } as SDKMessage, + { + type: "assistant", + message: { + id: "a2", + stop_reason: "end_turn", + content: [{ type: "text", text: "Hello world" }], + }, + } as SDKMessage, + { type: "result", result: "Hello world", is_error: false } as SDKMessage, + ], + }, + { + name: "error/status flow", + sdk: [ + { type: "tool_progress", tool_name: "Read" } as SDKMessage, + { type: "auth_status", output: ["Authenticating"] } as SDKMessage, + { type: "auth_status", error: "Token expired" } as SDKMessage, + { type: "error", content: "Execution failed" } as SDKMessage, + { type: "result", result: "Execution failed", is_error: true } as SDKMessage, + ], + cli: [ + { type: "tool_progress", tool_name: "Read" } as SDKMessage, + { type: "auth_status", output: ["Authenticating"] } as SDKMessage, + { type: "auth_status", error: "Token expired" } as SDKMessage, + { type: "error", message: "Execution failed" } as SDKMessage, + { type: "result", result: "Execution failed", is_error: true } as SDKMessage, + ], + }, +]; + +describe("runtime stream parity", () => { + it.each(fixtures)("produces equivalent visible timeline for $name", (fixture) => { + expect(renderTimeline(fixture.sdk)).toEqual(renderTimeline(fixture.cli)); + }); +}); diff --git a/packages/discord/src/commands/__tests__/command-manager.test.ts b/packages/discord/src/commands/__tests__/command-manager.test.ts index 3c50b5b6..e426a81d 100644 --- a/packages/discord/src/commands/__tests__/command-manager.test.ts +++ b/packages/discord/src/commands/__tests__/command-manager.test.ts @@ -25,6 +25,8 @@ vi.mock("discord.js", async () => { REST: MockREST, Routes: { applicationCommands: (clientId: string) => `/applications/${clientId}/commands`, + applicationGuildCommands: (clientId: string, guildId: string) => + `/applications/${clientId}/guilds/${guildId}/commands`, }, }; }); @@ -157,6 +159,10 @@ describe("CommandManager", () => { expect(commands.has("help")).toBe(true); expect(commands.has("reset")).toBe(true); expect(commands.has("status")).toBe(true); + expect(commands.has("new")).toBe(true); + expect(commands.has("session")).toBe(true); + expect(commands.has("stop")).toBe(true); + expect(commands.has("retry")).toBe(true); }); }); @@ -182,6 +188,28 @@ describe("CommandManager", () => { ); }); + it("registers commands at guild scope when configured", async () => { + const manager = new CommandManager({ + agentName: "test-agent", + client, + botToken: "test-token", + sessionManager, + getConnectorState: () => connectorState, + logger, + commandRegistration: { + scope: "guild", + guildId: "guild-123", + }, + }); + + await manager.registerCommands(); + + expect(mockRestPut).toHaveBeenCalledWith( + expect.stringContaining("/guilds/guild-123/commands"), + expect.any(Object), + ); + }); + it("logs successful registration", async () => { const manager = new CommandManager({ agentName: "test-agent", @@ -391,7 +419,7 @@ describe("CommandManager", () => { const commands = manager.getCommands(); expect(commands).toBeInstanceOf(Map); - expect(commands.size).toBe(3); + expect(commands.size).toBe(7); }); }); }); diff --git a/packages/discord/src/commands/__tests__/extended-commands.test.ts b/packages/discord/src/commands/__tests__/extended-commands.test.ts new file mode 100644 index 00000000..f6f276c5 --- /dev/null +++ b/packages/discord/src/commands/__tests__/extended-commands.test.ts @@ -0,0 +1,80 @@ +import type { IChatSessionManager } from "@herdctl/chat"; +import type { ChatInputCommandInteraction, Client } from "discord.js"; +import { describe, expect, it, vi } from "vitest"; +import type { DiscordConnectorState } from "../../types.js"; +import { newCommand } from "../new.js"; +import { retryCommand } from "../retry.js"; +import { sessionCommand } from "../session.js"; +import { stopCommand } from "../stop.js"; +import type { CommandContext } from "../types.js"; + +function makeContext(): CommandContext { + const interaction = { + channelId: "channel-1", + reply: vi.fn().mockResolvedValue(undefined), + } as unknown as ChatInputCommandInteraction; + + const sessionManager = { + getSession: vi.fn().mockResolvedValue({ + sessionId: "session-1234567890abcdefghijkl", + lastMessageAt: new Date().toISOString(), + }), + clearSession: vi.fn().mockResolvedValue(true), + } as unknown as IChatSessionManager; + + return { + interaction, + client: {} as Client, + agentName: "test-agent", + sessionManager, + connectorState: { + status: "connected", + connectedAt: new Date().toISOString(), + disconnectedAt: null, + reconnectAttempts: 0, + lastError: null, + botUser: null, + rateLimits: { + totalCount: 0, + lastRateLimitAt: null, + isRateLimited: false, + currentResetTime: 0, + }, + messageStats: { received: 0, sent: 0, ignored: 0 }, + } satisfies DiscordConnectorState, + commandActions: { + stopRun: vi.fn().mockResolvedValue({ success: true, message: "stopped" }), + retryRun: vi.fn().mockResolvedValue({ success: true, message: "retried" }), + getSessionInfo: vi.fn().mockResolvedValue({ + activeJobId: "job-123", + lastPrompt: "hello", + }), + }, + }; +} + +describe("extended commands", () => { + it("executes /new", async () => { + const ctx = makeContext(); + await newCommand.execute(ctx); + expect(ctx.interaction.reply).toHaveBeenCalledOnce(); + }); + + it("executes /session", async () => { + const ctx = makeContext(); + await sessionCommand.execute(ctx); + expect(ctx.interaction.reply).toHaveBeenCalledOnce(); + }); + + it("executes /stop", async () => { + const ctx = makeContext(); + await stopCommand.execute(ctx); + expect(ctx.interaction.reply).toHaveBeenCalledOnce(); + }); + + it("executes /retry", async () => { + const ctx = makeContext(); + await retryCommand.execute(ctx); + expect(ctx.interaction.reply).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/discord/src/commands/command-manager.ts b/packages/discord/src/commands/command-manager.ts index 3e7af244..e3cccbd8 100644 --- a/packages/discord/src/commands/command-manager.ts +++ b/packages/discord/src/commands/command-manager.ts @@ -17,9 +17,14 @@ import { import { ErrorHandler, withRetry } from "../error-handler.js"; import type { DiscordConnectorState } from "../types.js"; import { helpCommand } from "./help.js"; +import { newCommand } from "./new.js"; import { resetCommand } from "./reset.js"; +import { retryCommand } from "./retry.js"; +import { sessionCommand } from "./session.js"; import { statusCommand } from "./status.js"; +import { stopCommand } from "./stop.js"; import type { + CommandActions, CommandContext, CommandManagerLogger, CommandManagerOptions, @@ -43,7 +48,15 @@ function createDefaultLogger(agentName: string): CommandManagerLogger { * Get all built-in commands */ function getBuiltInCommands(): SlashCommand[] { - return [helpCommand, resetCommand, statusCommand]; + return [ + helpCommand, + statusCommand, + sessionCommand, + resetCommand, + newCommand, + stopCommand, + retryCommand, + ]; } // ============================================================================= @@ -86,6 +99,8 @@ export class CommandManager implements ICommandManager { private readonly logger: CommandManagerLogger; private readonly commands: Map; private readonly errorHandler: ErrorHandler; + private readonly commandActions?: CommandActions; + private readonly commandRegistration: { scope: "global" | "guild"; guildId?: string }; constructor(options: CommandManagerOptions) { this.agentName = options.agentName; @@ -94,6 +109,8 @@ export class CommandManager implements ICommandManager { this.sessionManager = options.sessionManager; this.getConnectorState = options.getConnectorState; this.logger = options.logger ?? createDefaultLogger(options.agentName); + this.commandActions = options.commandActions; + this.commandRegistration = options.commandRegistration ?? { scope: "global" as const }; // Initialize error handler this.errorHandler = new ErrorHandler({ @@ -137,7 +154,11 @@ export class CommandManager implements ICommandManager { // Use retry logic for command registration (handles rate limits, network issues) const result = await withRetry( async () => { - await rest.put(Routes.applicationCommands(clientId), { + const route = + this.commandRegistration.scope === "guild" && this.commandRegistration.guildId + ? Routes.applicationGuildCommands(clientId, this.commandRegistration.guildId) + : Routes.applicationCommands(clientId); + await rest.put(route, { body: commandData, }); }, @@ -192,6 +213,7 @@ export class CommandManager implements ICommandManager { agentName: this.agentName, sessionManager: this.sessionManager, connectorState: this.getConnectorState(), + commandActions: this.commandActions, }; try { diff --git a/packages/discord/src/commands/help.ts b/packages/discord/src/commands/help.ts index 4b48a628..45403e2a 100644 --- a/packages/discord/src/commands/help.ts +++ b/packages/discord/src/commands/help.ts @@ -20,6 +20,10 @@ export const helpCommand: SlashCommand = { "**/help** \u2014 Show this help message", "**/status** \u2014 Show agent status and session info", "**/reset** \u2014 Clear conversation context", + "**/new** \u2014 Start a fresh conversation", + "**/session** \u2014 Show current session and run state", + "**/stop** \u2014 Stop the active run in this channel", + "**/retry** \u2014 Retry the last prompt in this channel", "", "**Usage**", "Mention the bot or message in a configured channel. DMs are supported based on configuration.", diff --git a/packages/discord/src/commands/index.ts b/packages/discord/src/commands/index.ts index 5257454a..58322dc6 100644 --- a/packages/discord/src/commands/index.ts +++ b/packages/discord/src/commands/index.ts @@ -9,10 +9,16 @@ export { CommandManager } from "./command-manager.js"; // Built-in Commands export { helpCommand } from "./help.js"; +export { newCommand } from "./new.js"; export { resetCommand } from "./reset.js"; +export { retryCommand } from "./retry.js"; +export { sessionCommand } from "./session.js"; export { statusCommand } from "./status.js"; +export { stopCommand } from "./stop.js"; // Types export type { + CommandActionResult, + CommandActions, CommandContext, CommandManagerLogger, CommandManagerOptions, diff --git a/packages/discord/src/commands/new.ts b/packages/discord/src/commands/new.ts new file mode 100644 index 00000000..76788ab2 --- /dev/null +++ b/packages/discord/src/commands/new.ts @@ -0,0 +1,26 @@ +import type { CommandContext, SlashCommand } from "./types.js"; + +export const newCommand: SlashCommand = { + name: "new", + description: "Start a fresh conversation (clear current session)", + + async execute(context: CommandContext): Promise { + const { interaction, sessionManager, agentName } = context; + const channelId = interaction.channelId; + + const wasCleared = await sessionManager.clearSession(channelId); + + await interaction.reply({ + embeds: [ + { + description: wasCleared + ? "Started a new conversation. Previous session context was cleared." + : "Started a new conversation. No previous session was active.", + color: 0x22c55e, + footer: { text: `herdctl · ${agentName}` }, + }, + ], + ephemeral: true, + }); + }, +}; diff --git a/packages/discord/src/commands/retry.ts b/packages/discord/src/commands/retry.ts new file mode 100644 index 00000000..f9ae8460 --- /dev/null +++ b/packages/discord/src/commands/retry.ts @@ -0,0 +1,29 @@ +import type { CommandContext, SlashCommand } from "./types.js"; + +export const retryCommand: SlashCommand = { + name: "retry", + description: "Retry the last prompt in this channel", + + async execute(context: CommandContext): Promise { + const { interaction, commandActions, agentName } = context; + const channelId = interaction.channelId; + + const result = commandActions?.retryRun + ? await commandActions.retryRun(channelId) + : { + success: false, + message: "Retry is not available in this deployment.", + }; + + await interaction.reply({ + embeds: [ + { + description: result.message, + color: result.success ? 0x22c55e : 0xef4444, + footer: { text: `herdctl · ${agentName}` }, + }, + ], + ephemeral: true, + }); + }, +}; diff --git a/packages/discord/src/commands/session.ts b/packages/discord/src/commands/session.ts new file mode 100644 index 00000000..916c9a04 --- /dev/null +++ b/packages/discord/src/commands/session.ts @@ -0,0 +1,55 @@ +import { formatDuration } from "@herdctl/chat"; +import type { CommandContext, SlashCommand } from "./types.js"; + +export const sessionCommand: SlashCommand = { + name: "session", + description: "Show current session and run state for this channel", + + async execute(context: CommandContext): Promise { + const { interaction, sessionManager, commandActions, agentName } = context; + const channelId = interaction.channelId; + + const session = await sessionManager.getSession(channelId); + const managedInfo = commandActions?.getSessionInfo + ? await commandActions.getSessionInfo(channelId) + : undefined; + + const lines: string[] = []; + + if (session) { + lines.push("**Session**"); + lines.push( + `\`${session.sessionId.substring(0, 20)}…\` · Active ${formatDuration(session.lastMessageAt)} ago`, + ); + } else { + lines.push("**Session**"); + lines.push("No active session."); + } + + lines.push(""); + lines.push("**Run State**"); + if (managedInfo?.activeJobId) { + lines.push(`Running job: \`${managedInfo.activeJobId}\``); + } else { + lines.push("No active run in this channel."); + } + if (managedInfo?.lastPrompt) { + const preview = + managedInfo.lastPrompt.length > 100 + ? `${managedInfo.lastPrompt.substring(0, 100)}…` + : managedInfo.lastPrompt; + lines.push(`Last prompt: \`${preview}\``); + } + + await interaction.reply({ + embeds: [ + { + description: lines.join("\n"), + color: 0x3b82f6, + footer: { text: `herdctl · ${agentName}` }, + }, + ], + ephemeral: true, + }); + }, +}; diff --git a/packages/discord/src/commands/stop.ts b/packages/discord/src/commands/stop.ts new file mode 100644 index 00000000..44524e6a --- /dev/null +++ b/packages/discord/src/commands/stop.ts @@ -0,0 +1,29 @@ +import type { CommandContext, SlashCommand } from "./types.js"; + +export const stopCommand: SlashCommand = { + name: "stop", + description: "Stop the active run in this channel", + + async execute(context: CommandContext): Promise { + const { interaction, commandActions, agentName } = context; + const channelId = interaction.channelId; + + const result = commandActions?.stopRun + ? await commandActions.stopRun(channelId) + : { + success: false, + message: "Stop is not available in this deployment.", + }; + + await interaction.reply({ + embeds: [ + { + description: result.message, + color: result.success ? 0x22c55e : 0xef4444, + footer: { text: `herdctl · ${agentName}` }, + }, + ], + ephemeral: true, + }); + }, +}; diff --git a/packages/discord/src/commands/types.ts b/packages/discord/src/commands/types.ts index 0c8a7f17..3a529c2a 100644 --- a/packages/discord/src/commands/types.ts +++ b/packages/discord/src/commands/types.ts @@ -9,6 +9,21 @@ import type { IChatSessionManager } from "@herdctl/chat"; import type { ChatInputCommandInteraction, Client } from "discord.js"; import type { DiscordConnectorState } from "../types.js"; +export interface CommandActionResult { + success: boolean; + message: string; + jobId?: string; +} + +export interface CommandActions { + stopRun?: (channelId: string) => Promise; + retryRun?: (channelId: string) => Promise; + getSessionInfo?: (channelId: string) => Promise<{ + activeJobId?: string; + lastPrompt?: string; + }>; +} + // ============================================================================= // Command Context // ============================================================================= @@ -31,6 +46,9 @@ export interface CommandContext { /** Current connector state */ connectorState: DiscordConnectorState; + + /** Optional manager-backed command actions */ + commandActions?: CommandActions; } // ============================================================================= @@ -90,6 +108,15 @@ export interface CommandManagerOptions { /** Optional logger */ logger?: CommandManagerLogger; + + /** Optional manager-backed command actions */ + commandActions?: CommandActions; + + /** Registration mode for slash commands */ + commandRegistration?: { + scope: "global" | "guild"; + guildId?: string; + }; } // ============================================================================= diff --git a/packages/discord/src/discord-connector.ts b/packages/discord/src/discord-connector.ts index f55f7ca0..4b772f99 100644 --- a/packages/discord/src/discord-connector.ts +++ b/packages/discord/src/discord-connector.ts @@ -27,6 +27,7 @@ import { } from "discord.js"; import { checkDMUserFilter, resolveChannelConfig } from "./auto-mode-handler.js"; import { CommandManager, type ICommandManager } from "./commands/index.js"; +import type { CommandActions } from "./commands/types.js"; import { ErrorHandler } from "./error-handler.js"; import { AlreadyConnectedError, DiscordConnectionError, InvalidTokenError } from "./errors.js"; import { createLoggerFromConfig } from "./logger.js"; @@ -80,6 +81,11 @@ export class DiscordConnector extends EventEmitter implements IDiscordConnector private readonly _botToken: string; private readonly _logger: DiscordConnectorLogger; private readonly _sessionManager: IChatSessionManager; + private readonly _commandActions?: CommandActions; + private readonly _commandRegistration?: { + scope: "global" | "guild"; + guildId?: string; + }; private readonly _errorHandler: ErrorHandler; private _client: Client | null = null; private _commandManager: ICommandManager | null = null; @@ -108,6 +114,8 @@ export class DiscordConnector extends EventEmitter implements IDiscordConnector this._discordConfig = options.discordConfig; this._botToken = options.botToken; this._sessionManager = options.sessionManager; + this._commandActions = options.commandActions; + this._commandRegistration = options.commandRegistration; // Create logger from config if not provided this._logger = @@ -514,6 +522,8 @@ export class DiscordConnector extends EventEmitter implements IDiscordConnector sessionManager: this._sessionManager, getConnectorState: () => this.getState(), logger: this._logger, + commandActions: this._commandActions, + commandRegistration: this._commandRegistration, }); await this._commandManager.registerCommands(); diff --git a/packages/discord/src/embeds.ts b/packages/discord/src/embeds.ts new file mode 100644 index 00000000..3966a002 --- /dev/null +++ b/packages/discord/src/embeds.ts @@ -0,0 +1,163 @@ +import { formatCompactNumber } from "@herdctl/chat"; +import { getToolInputSummary, TOOL_EMOJIS } from "@herdctl/core"; +import type { DiscordReplyEmbed, DiscordReplyEmbedField } from "./types.js"; + +export const DISCORD_EMBED_COLORS = { + brand: 0x5865f2, + working: 0x8b5cf6, + success: 0x22c55e, + error: 0xef4444, + system: 0x6b7280, + info: 0x3b82f6, +} as const; + +export function buildFooter(agentName: string): { text: string } { + const shortName = agentName.includes(".") ? agentName.split(".").pop()! : agentName; + return { text: `herdctl · ${shortName}` }; +} + +export function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; +} + +export function buildRunCardEmbed(params: { + agentName: string; + status: "running" | "success" | "error"; + message: string; + traceLines?: string[]; +}): DiscordReplyEmbed { + const color = + params.status === "running" + ? DISCORD_EMBED_COLORS.working + : params.status === "success" + ? DISCORD_EMBED_COLORS.success + : DISCORD_EMBED_COLORS.error; + const fields = + params.traceLines && params.traceLines.length > 0 + ? [ + { + name: "Trace", + value: params.traceLines.slice(-8).join("\n").slice(-1024), + inline: false, + }, + ] + : undefined; + + return { + description: params.message, + color, + fields, + footer: buildFooter(params.agentName), + timestamp: new Date().toISOString(), + }; +} + +export function buildStatusEmbed( + description: string, + type: "system" | "info" | "error", + agentName?: string, +): DiscordReplyEmbed { + const color = + type === "system" + ? DISCORD_EMBED_COLORS.system + : type === "info" + ? DISCORD_EMBED_COLORS.info + : DISCORD_EMBED_COLORS.error; + return { + description, + color, + footer: agentName ? buildFooter(agentName) : undefined, + }; +} + +export function buildResultSummaryEmbed(params: { + agentName: string; + isError: boolean; + durationMs?: number; + numTurns?: number; + totalCostUsd?: number; + usage?: { input_tokens?: number; output_tokens?: number }; +}): DiscordReplyEmbed { + const summaryParts: string[] = []; + summaryParts.push(params.isError ? "**Task failed**" : "**Task complete**"); + if (params.durationMs !== undefined) { + summaryParts[0] += ` in ${formatDuration(params.durationMs)}`; + } + if (params.numTurns !== undefined) { + summaryParts.push(`${params.numTurns} turn${params.numTurns !== 1 ? "s" : ""}`); + } + if (params.totalCostUsd !== undefined) { + summaryParts.push(`$${params.totalCostUsd.toFixed(4)}`); + } + if (params.usage) { + const total = (params.usage.input_tokens ?? 0) + (params.usage.output_tokens ?? 0); + summaryParts.push(`${formatCompactNumber(total)} tokens`); + } + return { + description: summaryParts.join(" · "), + color: params.isError ? DISCORD_EMBED_COLORS.error : DISCORD_EMBED_COLORS.success, + footer: buildFooter(params.agentName), + timestamp: new Date().toISOString(), + }; +} + +export function buildErrorEmbed(message: string, agentName: string): DiscordReplyEmbed { + return { + description: `**Error:** ${message.length > 4000 ? `${message.substring(0, 4000)}…` : message}`, + color: DISCORD_EMBED_COLORS.error, + footer: buildFooter(agentName), + timestamp: new Date().toISOString(), + }; +} + +export function buildToolResultEmbed(params: { + toolUse: { name: string; input?: unknown; startTime: number } | null; + toolResult: { output: string; isError: boolean }; + agentName: string; + maxOutputChars: number; +}): DiscordReplyEmbed { + const toolName = params.toolUse?.name ?? "Tool"; + const emoji = TOOL_EMOJIS[toolName] ?? "🔧"; + const parts: string[] = [`${emoji} **${toolName}**`]; + const inputSummary = params.toolUse + ? getToolInputSummary(params.toolUse.name, params.toolUse.input) + : undefined; + if (inputSummary) { + const prefix = toolName === "Bash" || toolName === "bash" ? "> " : ""; + const truncated = + inputSummary.length > 120 ? `${inputSummary.substring(0, 120)}…` : inputSummary; + parts.push(`\`${prefix}${truncated}\``); + } + if (params.toolUse) { + parts.push(`— ${formatDuration(Date.now() - params.toolUse.startTime)}`); + } + + const fields: DiscordReplyEmbedField[] = []; + const trimmedOutput = params.toolResult.output.trim(); + if (trimmedOutput.length > 0) { + let outputText = trimmedOutput; + if (outputText.length > params.maxOutputChars) { + outputText = + outputText.substring(0, params.maxOutputChars) + + `\n… ${trimmedOutput.length.toLocaleString()} chars total`; + } + const lang = toolName === "Bash" || toolName === "bash" ? "ansi" : ""; + fields.push({ + name: params.toolResult.isError ? "Error" : "Output", + value: `\`\`\`${lang}\n${outputText}\n\`\`\``, + inline: false, + }); + } + + return { + description: parts.join(" "), + color: params.toolResult.isError ? DISCORD_EMBED_COLORS.error : DISCORD_EMBED_COLORS.brand, + fields: fields.length > 0 ? fields : undefined, + footer: buildFooter(params.agentName), + }; +} diff --git a/packages/discord/src/index.ts b/packages/discord/src/index.ts index e4ba3e52..8b1ce917 100644 --- a/packages/discord/src/index.ts +++ b/packages/discord/src/index.ts @@ -32,7 +32,16 @@ export type { SlashCommand, } from "./commands/index.js"; // Commands -export { CommandManager, helpCommand, resetCommand, statusCommand } from "./commands/index.js"; +export { + CommandManager, + helpCommand, + newCommand, + resetCommand, + retryCommand, + sessionCommand, + statusCommand, + stopCommand, +} from "./commands/index.js"; // Main connector class export { DiscordConnector } from "./discord-connector.js"; export type { ErrorHandlerOptions } from "./error-handler.js"; @@ -81,6 +90,8 @@ export { stripBotRoleMentions, stripMentions, } from "./mention-handler.js"; +export type { DiscordNormalizedMessageEvent } from "./message-normalizer.js"; +export { normalizeDiscordMessage } from "./message-normalizer.js"; // Types export type { DiscordConnectionStatus, diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index e3229d87..1279c5dd 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -17,8 +17,6 @@ import { basename, dirname, join } from "node:path"; import { type ChatConnectorLogger, ChatSessionManager, - extractMessageContent, - formatCompactNumber, StreamingResponder, splitMessage, } from "@herdctl/chat"; @@ -32,19 +30,25 @@ import type { } from "@herdctl/core"; import { createFileSenderDef, - extractToolResults, - extractToolUseBlocks, type FileSenderContext, getToolInputSummary, + type SDKMessage, TOOL_EMOJIS, } from "@herdctl/core"; import { DiscordConnector } from "./discord-connector.js"; +import { + buildErrorEmbed, + buildResultSummaryEmbed, + buildRunCardEmbed, + buildStatusEmbed, + buildToolResultEmbed, +} from "./embeds.js"; +import { formatContextForPrompt } from "./mention-handler.js"; +import { normalizeDiscordMessage } from "./message-normalizer.js"; import type { DiscordAttachmentInfo, DiscordConnectorEventMap, - DiscordReplyEmbed, - DiscordReplyEmbedField, DiscordReplyPayload, } from "./types.js"; import { transcribeAudio } from "./voice-transcriber.js"; @@ -78,6 +82,8 @@ type DiscordErrorEvent = DiscordConnectorEventMap["error"]; */ export class DiscordManager implements IChatManager { private connectors: Map = new Map(); + private activeJobsByChannel: Map = new Map(); + private lastPromptByChannel: Map = new Map(); private initialized: boolean = false; constructor(private ctx: FleetManagerContext) {} @@ -169,6 +175,18 @@ export class DiscordManager implements IChatManager { sessionManager, stateDir, logger: createAgentLogger(`[discord:${agent.qualifiedName}]`), + commandActions: { + stopRun: (channelId: string) => this.stopChannelRun(agent.qualifiedName, channelId), + retryRun: (channelId: string) => this.retryChannelRun(agent.qualifiedName, channelId), + getSessionInfo: async (channelId: string) => + this.getChannelRunInfo(agent.qualifiedName, channelId), + }, + commandRegistration: discordConfig.command_registration + ? { + scope: discordConfig.command_registration.scope, + guildId: discordConfig.command_registration.guild_id, + } + : { scope: "global" }, }); this.connectors.set(agent.qualifiedName, connector); @@ -384,6 +402,10 @@ export class DiscordManager implements IChatManager { logger.info( `Discord message for agent '${qualifiedName}': ${event.prompt.substring(0, 50)}...`, ); + this.lastPromptByChannel.set( + this.getChannelKey(qualifiedName, event.metadata.channelId), + event.prompt, + ); // Get the agent configuration (lookup by qualifiedName) const config = this.ctx.getConfig(); @@ -511,10 +533,30 @@ export class DiscordManager implements IChatManager { const progressState: { handle: { edit: (c: any) => Promise; delete: () => Promise } | null; } = { handle: null }; + const traceLines: string[] = []; + + const pushTraceLine = (line: string) => { + traceLines.push(line); + if (traceLines.length > 24) { + traceLines.splice(0, traceLines.length - 24); + } + }; try { // Handle voice messages: transcribe audio before triggering the agent let prompt = event.prompt; + if (!existingSessionId && event.context.messages.length > 0) { + const priorContext = formatContextForPrompt(event.context); + if (priorContext) { + prompt = [ + "Recent conversation context from this Discord channel:", + priorContext, + "", + `Current user message: ${prompt}`, + ].join("\n"); + } + } + const voiceConfig = agent.chat?.discord?.voice; if (event.metadata.isVoiceMessage) { if (!voiceConfig?.enabled) { @@ -618,12 +660,52 @@ export class DiscordManager implements IChatManager { // Capture the result text from the SDK's "result" message as a fallback // When all assistant messages are tool-only (no text), this is the last resort let resultText: string | undefined; + let sentAnswer = false; + let streamedDeltaSinceFinal = false; // Progress indicator: track tool names for in-place-updating embed const toolNamesRun: string[] = []; let lastProgressUpdate = 0; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let liveAnswerHandle: { edit: (c: any) => Promise } | null = null; + let liveAnswerText = ""; + let latestStatusText = "Preparing run…"; + + const refreshRunCard = async (status: "running" | "success" | "error") => { + if (!showProgressIndicator) { + return; + } + const now = Date.now(); + if (status === "running" && now - lastProgressUpdate < 1500) { + return; + } + lastProgressUpdate = now; + const header = + toolNamesRun.length > 0 ? `Running · ${toolNamesRun.join(" → ")}` : "Running"; + const message = status === "running" ? `${header}\n${latestStatusText}` : latestStatusText; + const embedPayload = { + embeds: [ + buildRunCardEmbed({ + agentName: qualifiedName, + status, + message: message.length > 4000 ? `…${message.slice(-3997)}` : message, + traceLines, + }), + ], + }; + try { + if (!progressState.handle) { + progressState.handle = await event.replyWithRef(embedPayload); + } else { + await progressState.handle.edit(embedPayload); + } + } catch (progressError) { + logger.warn(`Failed to update run card: ${(progressError as Error).message}`); + } + }; const showToolResults = outputConfig.tool_results; + const enableDeltaStreaming = assistantMessages === "all"; // Execute job via FleetManager.trigger() through the context // Pass resume option for conversation continuity @@ -633,223 +715,215 @@ export class DiscordManager implements IChatManager { prompt, resume: existingSessionId, injectedMcpServers, + onJobCreated: async (jobId) => { + this.activeJobsByChannel.set( + this.getChannelKey(qualifiedName, event.metadata.channelId), + jobId, + ); + }, onMessage: async (message) => { - // Extract text content from assistant messages and stream to Discord - if (message.type === "assistant") { - // Cast to the SDKMessage shape expected by extractMessageContent - // The chat package's SDKMessage type expects a specific structure - const sdkMessage = message as unknown as Parameters[0]; - - // Always track tool_use blocks (even from duplicate messages) - // so tool results can be paired correctly - const toolUseBlocks = extractToolUseBlocks(sdkMessage); - for (const block of toolUseBlocks) { - if (block.id) { - pendingToolUses.set(block.id, { - name: block.name, - input: block.input, - startTime: Date.now(), - }); + for (const normalized of normalizeDiscordMessage(message as SDKMessage)) { + if (normalized.kind === "assistant_delta") { + if (!enableDeltaStreaming) { + continue; + } + + streamedDeltaSinceFinal = true; + liveAnswerText += normalized.delta; + if (!liveAnswerText.trim()) { + continue; + } + + const payload = { content: liveAnswerText }; + try { + if (!liveAnswerHandle) { + liveAnswerHandle = await event.replyWithRef(payload); + } else { + await liveAnswerHandle.edit(payload); + } + sentAnswer = true; + } catch (deltaError) { + logger.warn(`Failed delta streaming update: ${(deltaError as Error).message}`); } + continue; + } - // Track tool names for progress indicator - if (block.name && showProgressIndicator) { - const emoji = TOOL_EMOJIS[block.name] ?? "\u{1F527}"; - const displayName = `${emoji} ${block.name}`; - toolNamesRun.push(displayName); - // Cap to last 50 entries to avoid unbounded memory growth on long jobs - if (toolNamesRun.length > 50) { - toolNamesRun.splice(0, toolNamesRun.length - 50); + if (normalized.kind === "assistant_final") { + for (const block of normalized.toolUses) { + if (block.id) { + pendingToolUses.set(block.id, { + name: block.name, + input: block.input, + startTime: Date.now(), + }); } - // Update progress embed (throttled to every 2s) - const now = Date.now(); - if (now - lastProgressUpdate >= 2000) { - lastProgressUpdate = now; - const description = toolNamesRun.join(" \u2192 "); - const embedPayload = { - embeds: [ - { - description: - description.length > 4000 - ? `\u2026${description.slice(-3997)}` - : description, - color: DiscordManager.EMBED_COLOR_WORKING, - footer: DiscordManager.buildFooter(qualifiedName), - }, - ], - }; - - try { - if (!progressState.handle) { - progressState.handle = await event.replyWithRef(embedPayload); - } else { - await progressState.handle.edit(embedPayload); - } - } catch (progressError) { - logger.warn( - `Failed to update progress embed: ${(progressError as Error).message}`, - ); + if (block.name && showProgressIndicator) { + const emoji = TOOL_EMOJIS[block.name] ?? "\u{1F527}"; + const displayName = `${emoji} ${block.name}`; + toolNamesRun.push(displayName); + if (toolNamesRun.length > 50) { + toolNamesRun.splice(0, toolNamesRun.length - 50); } + const inputSummary = getToolInputSummary(block.name, block.input); + pushTraceLine( + `${emoji} ${block.name}${inputSummary ? ` · ${inputSummary.slice(0, 60)}` : ""}`, + ); + latestStatusText = `Executing ${block.name}`; + await refreshRunCard("running"); } } - } - // Deduplicate assistant messages by message.id. - // Claude Code emits multiple JSONL lines per turn with the same id: - // intermediate snapshots (stop_reason: null) may lack text content, - // while the final (stop_reason: "end_turn") has the complete response. - // Skip intermediates, deliver and deduplicate finals. - const messageId = (message as { message?: { id?: string } }).message?.id; - const stopReason = (message as { message?: { stop_reason?: unknown } }).message - ?.stop_reason; - if (messageId && stopReason === null) { - return; // Skip intermediate snapshot — text may be incomplete - } - if (messageId) { - if (deliveredAssistantIds.has(messageId)) { - return; + if (normalized.messageId && normalized.stopReason === null) { + continue; + } + if (normalized.messageId) { + if (deliveredAssistantIds.has(normalized.messageId)) { + continue; + } + deliveredAssistantIds.add(normalized.messageId); + } + + const content = normalized.content; + if (!content) { + streamedDeltaSinceFinal = false; + continue; } - deliveredAssistantIds.add(messageId); - } - const content = extractMessageContent(sdkMessage); - if (content) { if (assistantMessages === "answers") { - // Only send turns with no tool_use blocks (answer turns) - if (toolUseBlocks.length === 0) { + if (normalized.toolUses.length === 0) { + await streamer.addMessageAndSend(content); + sentAnswer = true; + } + } else if (streamedDeltaSinceFinal && enableDeltaStreaming) { + // Sync final content into the live delta message to avoid duplicates. + liveAnswerText = content; + try { + if (!liveAnswerHandle) { + liveAnswerHandle = await event.replyWithRef({ content }); + } else { + await liveAnswerHandle.edit({ content }); + } + sentAnswer = true; + } catch (syncError) { + logger.warn( + `Failed to sync final streamed answer: ${(syncError as Error).message}`, + ); await streamer.addMessageAndSend(content); + sentAnswer = true; } } else { - // "all" mode: send every turn with text await streamer.addMessageAndSend(content); + sentAnswer = true; } + streamedDeltaSinceFinal = false; + continue; } - } - // Build and send embeds for tool results - if (message.type === "user" && showToolResults) { - // Cast to the shape expected by extractToolResults - const userMessage = message as { - type: string; - message?: { content?: unknown }; - tool_use_result?: unknown; - }; - const toolResults = extractToolResults(userMessage); - for (const toolResult of toolResults) { - // Look up the matching tool_use for name, input, and timing - const toolUse = toolResult.toolUseId - ? pendingToolUses.get(toolResult.toolUseId) - : undefined; - if (toolResult.toolUseId) { - pendingToolUses.delete(toolResult.toolUseId); + if (normalized.kind === "tool_results" && showToolResults) { + for (const toolResult of normalized.results) { + const toolUse = toolResult.toolUseId + ? pendingToolUses.get(toolResult.toolUseId) + : undefined; + if (toolResult.toolUseId) { + pendingToolUses.delete(toolResult.toolUseId); + } + const toolName = toolUse?.name ?? "Tool"; + const output = toolResult.output.trim(); + const preview = output.length > 0 ? output.replace(/\s+/g, " ").slice(0, 90) : ""; + pushTraceLine( + `${toolResult.isError ? "✖" : "✓"} ${toolName}${preview ? ` · ${preview}` : ""}`, + ); + latestStatusText = `${toolResult.isError ? "Error from" : "Completed"} ${toolName}`; + await refreshRunCard("running"); + + // Oversized output is attached as a file instead of flooding chat. + const maxOutputChars = outputConfig.tool_result_max_length ?? 900; + if (output.length > maxOutputChars) { + await streamer.flush(); + const filename = `${toolName.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "tool"}-output.txt`; + const previewEmbed = buildToolResultEmbed({ + toolUse: toolUse ?? null, + toolResult: { + output: output.slice(0, Math.min(300, maxOutputChars)), + isError: toolResult.isError, + }, + agentName: qualifiedName, + maxOutputChars: Math.min(300, maxOutputChars), + }); + await event.reply({ + embeds: [previewEmbed], + files: [{ attachment: Buffer.from(output, "utf8"), name: filename }], + }); + embedsSent++; + } } - - const embed = this.buildToolEmbed( - toolUse ?? null, - toolResult, - outputConfig.tool_result_max_length, - qualifiedName, - ); - - // Flush any buffered text before sending embed to preserve ordering - await streamer.flush(); - await event.reply({ embeds: [embed] }); - embedsSent++; + continue; } - } - // Show system status messages (e.g., "compacting context...") - if (message.type === "system" && outputConfig.system_status) { - const sysMessage = message as { subtype?: string; status?: string | null }; - if (sysMessage.subtype === "status" && sysMessage.status) { - const statusText = - sysMessage.status === "compacting" ? "Compacting context\u2026" : sysMessage.status; - await streamer.flush(); - await event.reply({ - embeds: [ - { - description: statusText, - color: DiscordManager.EMBED_COLOR_SYSTEM, - }, - ], - }); - embedsSent++; + if (normalized.kind === "system_status" && outputConfig.system_status) { + latestStatusText = + normalized.status === "compacting" ? "Compacting context…" : normalized.status; + pushTraceLine(`ℹ ${latestStatusText}`); + await refreshRunCard("running"); + continue; } - } - // Capture result text from the SDK "result" message as a fallback answer. - // This covers cases where all assistant messages were tool-only (no text blocks). - if (message.type === "result") { - const resultMsg = message as { result?: string }; - if (typeof resultMsg.result === "string" && resultMsg.result.trim()) { - resultText = resultMsg.result; + if (normalized.kind === "tool_progress" && outputConfig.system_status) { + latestStatusText = normalized.content; + pushTraceLine(`ℹ ${normalized.content}`); + await refreshRunCard("running"); + continue; } - } - // Show result summary embed (cost, tokens, turns) - if (message.type === "result" && outputConfig.result_summary) { - const resultMessage = message as { - is_error?: boolean; - duration_ms?: number; - total_cost_usd?: number; - num_turns?: number; - usage?: { input_tokens?: number; output_tokens?: number }; - }; - const isError = resultMessage.is_error === true; - - // Build compact summary: "**Task complete** in 45s · 3 turns · $0.0045 · 15.7k tokens" - const summaryParts: string[] = []; - summaryParts.push(isError ? "**Task failed**" : "**Task complete**"); - if (resultMessage.duration_ms !== undefined) { - summaryParts[0] += ` in ${DiscordManager.formatDuration(resultMessage.duration_ms)}`; - } - if (resultMessage.num_turns !== undefined) { - summaryParts.push( - `${resultMessage.num_turns} turn${resultMessage.num_turns !== 1 ? "s" : ""}`, - ); - } - if (resultMessage.total_cost_usd !== undefined) { - summaryParts.push(`$${resultMessage.total_cost_usd.toFixed(4)}`); - } - if (resultMessage.usage) { - const total = - (resultMessage.usage.input_tokens ?? 0) + (resultMessage.usage.output_tokens ?? 0); - summaryParts.push(`${formatCompactNumber(total)} tokens`); + if (normalized.kind === "auth_status" && outputConfig.system_status) { + latestStatusText = normalized.content; + pushTraceLine(`${normalized.isError ? "✖" : "ℹ"} ${normalized.content}`); + if (normalized.isError) { + await streamer.flush(); + await event.reply({ + embeds: [buildStatusEmbed(normalized.content, "error", qualifiedName)], + }); + embedsSent++; + } else { + await refreshRunCard("running"); + } + continue; } - await streamer.flush(); - await event.reply({ - embeds: [ - { - description: summaryParts.join(" \u00b7 "), - color: isError - ? DiscordManager.EMBED_COLOR_ERROR - : DiscordManager.EMBED_COLOR_SUCCESS, - footer: DiscordManager.buildFooter(qualifiedName), - timestamp: new Date().toISOString(), - }, - ], - }); - embedsSent++; - } + if (normalized.kind === "result") { + if (normalized.resultText) { + resultText = normalized.resultText; + } + latestStatusText = normalized.isError ? "Task failed" : "Task complete"; + await refreshRunCard(normalized.isError ? "error" : "success"); + + if (outputConfig.result_summary) { + await streamer.flush(); + await event.reply({ + embeds: [ + buildResultSummaryEmbed({ + agentName: qualifiedName, + isError: normalized.isError, + durationMs: normalized.durationMs, + numTurns: normalized.numTurns, + totalCostUsd: normalized.totalCostUsd, + usage: normalized.usage, + }), + ], + }); + embedsSent++; + } + continue; + } - // Show SDK error messages - if (message.type === "error" && outputConfig.errors) { - const errorText = - typeof message.content === "string" ? message.content : "An unknown error occurred"; - await streamer.flush(); - await event.reply({ - embeds: [ - { - description: `**Error:** ${errorText.length > 4000 ? errorText.substring(0, 4000) + "\u2026" : errorText}`, - color: DiscordManager.EMBED_COLOR_ERROR, - footer: DiscordManager.buildFooter(qualifiedName), - timestamp: new Date().toISOString(), - }, - ], - }); - embedsSent++; + if (normalized.kind === "error" && outputConfig.errors) { + await streamer.flush(); + await event.reply({ + embeds: [buildErrorEmbed(normalized.message, qualifiedName)], + }); + embedsSent++; + } } }, }); @@ -862,9 +936,10 @@ export class DiscordManager implements IChatManager { } // Fall back to SDK result text if no answer turns produced text - if (!streamer.hasSentMessages() && resultText) { + if (!sentAnswer && !streamer.hasSentMessages() && resultText) { logger.debug("No answer turns produced text — using SDK result text as fallback"); await streamer.addMessageAndSend(resultText); + sentAnswer = true; } // Flush any remaining buffered content @@ -884,18 +959,35 @@ export class DiscordManager implements IChatManager { `Discord job completed: ${result.jobId} for agent '${qualifiedName}'${result.sessionId ? ` (session: ${result.sessionId})` : ""}`, ); + if (progressState.handle) { + try { + await progressState.handle.edit({ + embeds: [ + buildRunCardEmbed({ + agentName: qualifiedName, + status: result.success ? "success" : "error", + message: result.success ? "Task complete" : "Task failed", + traceLines, + }), + ], + }); + } catch (progressError) { + logger.warn(`Failed to finalize run card: ${(progressError as Error).message}`); + } + } + // If no text messages were sent, send an appropriate fallback. // When embedsSent > 0 but no text was delivered, the user saw tool/result embeds // but may have missed the final answer. Show a brief completion indicator. - if (!streamer.hasSentMessages() && embedsSent === 0) { + if (!sentAnswer && !streamer.hasSentMessages() && embedsSent === 0) { if (result.success) { await event.reply({ embeds: [ - { - description: "Task completed \u2014 no additional output to share.", - color: DiscordManager.EMBED_COLOR_SUCCESS, - footer: DiscordManager.buildFooter(qualifiedName), - }, + buildStatusEmbed( + "Task completed — no additional output to share.", + "info", + qualifiedName, + ), ], }); } else { @@ -904,12 +996,7 @@ export class DiscordManager implements IChatManager { result.errorDetails?.message ?? result.error?.message ?? "An unknown error occurred"; await event.reply({ embeds: [ - { - description: `**Error:** ${errorMessage}\n\nThe task could not be completed.`, - color: DiscordManager.EMBED_COLOR_ERROR, - footer: DiscordManager.buildFooter(qualifiedName), - timestamp: new Date().toISOString(), - }, + buildErrorEmbed(`${errorMessage}\n\nThe task could not be completed.`, qualifiedName), ], }); } @@ -953,6 +1040,23 @@ export class DiscordManager implements IChatManager { const err = error instanceof Error ? error : new Error(String(error)); logger.error(`Discord message handling failed for agent '${qualifiedName}': ${err.message}`); + if (progressState.handle) { + try { + await progressState.handle.edit({ + embeds: [ + buildRunCardEmbed({ + agentName: qualifiedName, + status: "error", + message: `Task failed · ${err.message}`, + traceLines, + }), + ], + }); + } catch (progressError) { + logger.warn(`Failed to finalize failed run card: ${(progressError as Error).message}`); + } + } + // Send user-friendly error message using the formatted error method try { await event.reply(this.formatErrorMessage(err, qualifiedName)); @@ -969,19 +1073,12 @@ export class DiscordManager implements IChatManager { timestamp: new Date().toISOString(), }); } finally { + this.activeJobsByChannel.delete(this.getChannelKey(qualifiedName, event.metadata.channelId)); // Safety net: stop typing indicator if not already stopped // (Should already be stopped after sending messages, but this ensures cleanup on errors) if (!typingStopped) { stopTyping(); } - // Clean up progress embed (on both success and error paths) - if (progressState.handle) { - try { - await progressState.handle.delete(); - } catch (progressError) { - logger.warn(`Failed to delete progress embed: ${(progressError as Error).message}`); - } - } // Remove acknowledgement reaction now that processing is complete if (ackEmoji) { try { @@ -1007,105 +1104,6 @@ export class DiscordManager implements IChatManager { } } - // ============================================================================= - // Tool Embed Support - // ============================================================================= - - /** Maximum characters for tool output in Discord embed fields */ - private static readonly TOOL_OUTPUT_MAX_CHARS = 900; - - /** Embed colors */ - private static readonly EMBED_COLOR_BRAND = 0x5865f2; // Discord blurple (tool results) - private static readonly EMBED_COLOR_WORKING = 0x8b5cf6; // Soft violet (progress) - private static readonly EMBED_COLOR_SUCCESS = 0x22c55e; // Emerald (completion) - private static readonly EMBED_COLOR_ERROR = 0xef4444; // Red (errors) - private static readonly EMBED_COLOR_SYSTEM = 0x6b7280; // Cool gray (system status) - private static readonly EMBED_COLOR_INFO = 0x3b82f6; // Sky blue (slash commands) - - /** - * Build a consistent footer for Discord embeds - */ - private static buildFooter(agentName: string): { text: string } { - const shortName = agentName.includes(".") ? agentName.split(".").pop()! : agentName; - return { text: `herdctl \u00b7 ${shortName}` }; - } - - /** - * Format duration in milliseconds to a human-readable string - */ - private static formatDuration(ms: number): string { - if (ms < 1000) return `${ms}ms`; - const seconds = Math.floor(ms / 1000); - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; - } - - /** - * Build a Discord embed for a tool call result - * - * Combines the tool_use info (name, input) with the tool_result - * (output, error status) into a compact Discord embed with a - * single-line description and optional output field. - * - * @param toolUse - The tool_use block info (name, input, startTime) - * @param toolResult - The tool result (output, isError) - * @param maxOutputChars - Maximum characters for output (defaults to TOOL_OUTPUT_MAX_CHARS) - * @param agentName - Agent name for the embed footer - */ - private buildToolEmbed( - toolUse: { name: string; input?: unknown; startTime: number } | null, - toolResult: { output: string; isError: boolean }, - maxOutputChars?: number, - agentName?: string, - ): DiscordReplyEmbed { - const toolName = toolUse?.name ?? "Tool"; - const emoji = TOOL_EMOJIS[toolName] ?? "\u{1F527}"; // wrench fallback - - // Build compact description: "💻 **Bash** `> ls -la` — 2s" - const parts: string[] = [`${emoji} **${toolName}**`]; - const inputSummary = toolUse ? getToolInputSummary(toolUse.name, toolUse.input) : undefined; - if (inputSummary) { - const prefix = toolName === "Bash" || toolName === "bash" ? "> " : ""; - const truncated = - inputSummary.length > 120 ? inputSummary.substring(0, 120) + "\u2026" : inputSummary; - parts.push(`\`${prefix}${truncated}\``); - } - if (toolUse) { - const durationMs = Date.now() - toolUse.startTime; - parts.push(`\u2014 ${DiscordManager.formatDuration(durationMs)}`); - } - - // Build output field if non-empty - const fields: DiscordReplyEmbedField[] = []; - const trimmedOutput = toolResult.output.trim(); - if (trimmedOutput.length > 0) { - const maxChars = maxOutputChars ?? DiscordManager.TOOL_OUTPUT_MAX_CHARS; - let outputText = trimmedOutput; - if (outputText.length > maxChars) { - outputText = - outputText.substring(0, maxChars) + - `\n\u2026 ${trimmedOutput.length.toLocaleString()} chars total`; - } - const lang = toolName === "Bash" || toolName === "bash" ? "ansi" : ""; - fields.push({ - name: toolResult.isError ? "Error" : "Output", - value: `\`\`\`${lang}\n${outputText}\n\`\`\``, - inline: false, - }); - } - - return { - description: parts.join(" "), - color: toolResult.isError - ? DiscordManager.EMBED_COLOR_ERROR - : DiscordManager.EMBED_COLOR_BRAND, - fields: fields.length > 0 ? fields : undefined, - footer: agentName ? DiscordManager.buildFooter(agentName) : undefined, - }; - } - /** * Handle errors from Discord connectors * @@ -1129,6 +1127,95 @@ export class DiscordManager implements IChatManager { }); } + private getChannelKey(qualifiedName: string, channelId: string): string { + return `${qualifiedName}:${channelId}`; + } + + private async stopChannelRun( + qualifiedName: string, + channelId: string, + ): Promise<{ success: boolean; message: string; jobId?: string }> { + const key = this.getChannelKey(qualifiedName, channelId); + const jobId = this.activeJobsByChannel.get(key); + if (!jobId) { + return { + success: false, + message: "No active run found for this channel.", + }; + } + + try { + const fleetManager = this.ctx.getEmitter() as unknown as import("@herdctl/core").FleetManager; + await fleetManager.cancelJob(jobId); + this.activeJobsByChannel.delete(key); + return { + success: true, + message: `Stop requested for job \`${jobId}\`.`, + jobId, + }; + } catch (error) { + return { + success: false, + message: `Failed to stop active run: ${(error as Error).message}`, + jobId, + }; + } + } + + private async retryChannelRun( + qualifiedName: string, + channelId: string, + ): Promise<{ success: boolean; message: string; jobId?: string }> { + const key = this.getChannelKey(qualifiedName, channelId); + const activeJobId = this.activeJobsByChannel.get(key); + if (activeJobId) { + return { + success: false, + message: `A run is already active in this channel (\`${activeJobId}\`).`, + jobId: activeJobId, + }; + } + + const lastPrompt = this.lastPromptByChannel.get(key); + if (!lastPrompt) { + return { + success: false, + message: "No previous prompt found to retry in this channel.", + }; + } + + // Fire-and-forget retry. Slash-command flows don't provide the same + // message reply hooks as the message pipeline, so we retry execution + // without channel streaming and return an immediate status response. + void this.ctx + .trigger(qualifiedName, undefined, { + triggerType: "discord", + prompt: lastPrompt, + onJobCreated: async (jobId) => { + this.activeJobsByChannel.set(key, jobId); + }, + }) + .finally(() => { + this.activeJobsByChannel.delete(key); + }); + + return { + success: true, + message: "Retry started in the background for this channel's last prompt.", + }; + } + + private async getChannelRunInfo( + qualifiedName: string, + channelId: string, + ): Promise<{ activeJobId?: string; lastPrompt?: string }> { + const key = this.getChannelKey(qualifiedName, channelId); + return { + activeJobId: this.activeJobsByChannel.get(key), + lastPrompt: this.lastPromptByChannel.get(key), + }; + } + // =========================================================================== // Response Formatting and Splitting // =========================================================================== @@ -1150,12 +1237,10 @@ export class DiscordManager implements IChatManager { if (agentName) { return { embeds: [ - { - description: `**Error:** ${error.message}\n\nTry again or use \`/reset\` to start a new session.`, - color: DiscordManager.EMBED_COLOR_ERROR, - footer: DiscordManager.buildFooter(agentName), - timestamp: new Date().toISOString(), - }, + buildErrorEmbed( + `${error.message}\n\nTry again or use \`/reset\` to start a new session.`, + agentName, + ), ], }; } diff --git a/packages/discord/src/message-normalizer.ts b/packages/discord/src/message-normalizer.ts new file mode 100644 index 00000000..d3a96a2d --- /dev/null +++ b/packages/discord/src/message-normalizer.ts @@ -0,0 +1,177 @@ +import { extractMessageContent } from "@herdctl/chat"; +import { + extractToolResults, + extractToolUseBlocks, + type SDKMessage, + type ToolResult, + type ToolUseBlock, +} from "@herdctl/core"; + +export type DiscordNormalizedMessageEvent = + | { + kind: "assistant_final"; + content?: string; + messageId?: string; + stopReason?: unknown; + toolUses: ToolUseBlock[]; + } + | { + kind: "assistant_delta"; + delta: string; + } + | { + kind: "tool_results"; + results: ToolResult[]; + } + | { + kind: "system_status"; + status: string; + } + | { + kind: "tool_progress"; + content: string; + } + | { + kind: "auth_status"; + content: string; + isError: boolean; + } + | { + kind: "result"; + resultText?: string; + isError: boolean; + durationMs?: number; + totalCostUsd?: number; + numTurns?: number; + usage?: { input_tokens?: number; output_tokens?: number }; + } + | { + kind: "error"; + message: string; + }; + +function extractStreamDelta(message: SDKMessage): string | undefined { + const event = message.event as + | { + type?: string; + delta?: { type?: string; text?: string }; + content_block?: { type?: string; text?: string }; + } + | undefined; + + if (!event) return undefined; + + if (event.type === "content_block_delta" && event.delta?.type === "text_delta") { + return event.delta.text; + } + if (event.type === "content_block_start" && event.content_block?.type === "text") { + return event.content_block.text; + } + return undefined; +} + +export function normalizeDiscordMessage(message: SDKMessage): DiscordNormalizedMessageEvent[] { + if (!message || typeof message !== "object") { + return []; + } + + if (message.type === "assistant") { + const sdkMessage = message as unknown as Parameters[0]; + return [ + { + kind: "assistant_final", + content: extractMessageContent(sdkMessage) ?? undefined, + messageId: (message as { message?: { id?: string } }).message?.id, + stopReason: (message as { message?: { stop_reason?: unknown } }).message?.stop_reason, + toolUses: extractToolUseBlocks( + message as { + type: string; + message?: { content?: unknown }; + }, + ), + }, + ]; + } + + if (message.type === "stream_event") { + const delta = extractStreamDelta(message); + return delta ? [{ kind: "assistant_delta", delta }] : []; + } + + if (message.type === "user") { + const results = extractToolResults( + message as { + type: string; + message?: { content?: unknown }; + tool_use_result?: unknown; + }, + ); + return results.length > 0 ? [{ kind: "tool_results", results }] : []; + } + + if (message.type === "system") { + const sys = message as { subtype?: string; status?: string | null; content?: string }; + if (sys.subtype === "status" && typeof sys.status === "string" && sys.status.length > 0) { + return [{ kind: "system_status", status: sys.status }]; + } + return []; + } + + if (message.type === "tool_progress") { + const toolName = + typeof (message as { tool_name?: unknown }).tool_name === "string" + ? (message as { tool_name: string }).tool_name + : "Tool"; + return [{ kind: "tool_progress", content: `Tool ${toolName} in progress` }]; + } + + if (message.type === "auth_status") { + const auth = message as { output?: string[]; error?: string }; + if (typeof auth.error === "string" && auth.error.length > 0) { + return [ + { kind: "auth_status", content: `Authentication error: ${auth.error}`, isError: true }, + ]; + } + if (Array.isArray(auth.output) && auth.output.length > 0) { + return [{ kind: "auth_status", content: auth.output.join("\n"), isError: false }]; + } + return []; + } + + if (message.type === "result") { + const resultMessage = message as { + result?: string; + is_error?: boolean; + duration_ms?: number; + total_cost_usd?: number; + num_turns?: number; + usage?: { input_tokens?: number; output_tokens?: number }; + }; + return [ + { + kind: "result", + resultText: + typeof resultMessage.result === "string" && resultMessage.result.trim().length > 0 + ? resultMessage.result + : undefined, + isError: resultMessage.is_error === true, + durationMs: resultMessage.duration_ms, + totalCostUsd: resultMessage.total_cost_usd, + numTurns: resultMessage.num_turns, + usage: resultMessage.usage, + }, + ]; + } + + if (message.type === "error") { + const errorText = + typeof (message as { content?: unknown }).content === "string" + ? ((message as { content: string }).content ?? "An unknown error occurred") + : typeof (message as { message?: unknown }).message === "string" + ? ((message as { message: string }).message ?? "An unknown error occurred") + : "An unknown error occurred"; + return [{ kind: "error", message: errorText }]; + } + + return []; +} diff --git a/packages/discord/src/types.ts b/packages/discord/src/types.ts index 795dd576..ea0df186 100644 --- a/packages/discord/src/types.ts +++ b/packages/discord/src/types.ts @@ -7,6 +7,7 @@ import type { IChatSessionManager } from "@herdctl/chat"; import type { AgentChatDiscord, AgentConfig, FleetManager } from "@herdctl/core"; +import type { CommandActions } from "./commands/types.js"; import type { ConversationContext } from "./mention-handler.js"; // ============================================================================= @@ -80,6 +81,15 @@ export interface DiscordConnectorOptions { * Used for session persistence */ stateDir?: string; + + /** Optional manager-backed actions for slash commands */ + commandActions?: CommandActions; + + /** Optional slash command registration mode */ + commandRegistration?: { + scope: "global" | "guild"; + guildId?: string; + }; } // ============================================================================= From a758bbe85d99eac00d56dbd38509061168d51d48 Mon Sep 17 00:00:00 2001 From: agent Date: Wed, 4 Mar 2026 16:12:39 -0800 Subject: [PATCH 24/48] Fix /retry to reuse Discord pipeline and catch failures --- .../discord/src/__tests__/manager.test.ts | 89 ++++++++++++++ packages/discord/src/manager.ts | 116 ++++++++++++++++-- 2 files changed, 195 insertions(+), 10 deletions(-) diff --git a/packages/discord/src/__tests__/manager.test.ts b/packages/discord/src/__tests__/manager.test.ts index b72a2685..72a65957 100644 --- a/packages/discord/src/__tests__/manager.test.ts +++ b/packages/discord/src/__tests__/manager.test.ts @@ -243,6 +243,95 @@ describe("DiscordManager", () => { }); }); + describe("retry channel run controls", () => { + it("routes retry through the normal handleMessage pipeline", async () => { + const ctx = createMockContext(null); + const manager = new DiscordManager(ctx); + const managerAny = manager as unknown as { + lastPromptByChannel: Map; + connectors: Map; + retryChannelRun: (qualifiedName: string, channelId: string) => Promise<{ success: boolean }>; + handleMessage: (qualifiedName: string, event: DiscordMessageEvent) => Promise; + }; + + const mockSend = vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }); + const mockChannel = { + isTextBased: () => true, + isDMBased: () => false, + guildId: "guild-1", + send: mockSend, + }; + managerAny.connectors = new Map([ + [ + "agent-1", + { + client: { + isReady: () => true, + channels: { fetch: vi.fn().mockResolvedValue(mockChannel) }, + }, + }, + ], + ]); + managerAny.lastPromptByChannel.set("agent-1:channel-1", "retry prompt"); + const handleMessageSpy = vi + .spyOn(managerAny, "handleMessage") + .mockResolvedValue(undefined as never); + + const result = await managerAny.retryChannelRun("agent-1", "channel-1"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(result.success).toBe(true); + expect(handleMessageSpy).toHaveBeenCalledTimes(1); + const [, event] = handleMessageSpy.mock.calls[0]; + expect(event.prompt).toBe("retry prompt"); + expect(event.metadata.channelId).toBe("channel-1"); + }); + + it("catches background retry failures and posts an error message", async () => { + const ctx = createMockContext(null); + const manager = new DiscordManager(ctx); + const managerAny = manager as unknown as { + lastPromptByChannel: Map; + connectors: Map; + retryChannelRun: (qualifiedName: string, channelId: string) => Promise<{ success: boolean }>; + handleMessage: (qualifiedName: string, event: DiscordMessageEvent) => Promise; + }; + + const mockSend = vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }); + const mockChannel = { + isTextBased: () => true, + isDMBased: () => false, + guildId: "guild-1", + send: mockSend, + }; + managerAny.connectors = new Map([ + [ + "agent-1", + { + client: { + isReady: () => true, + channels: { fetch: vi.fn().mockResolvedValue(mockChannel) }, + }, + }, + ], + ]); + managerAny.lastPromptByChannel.set("agent-1:channel-1", "retry prompt"); + vi.spyOn(managerAny, "handleMessage").mockRejectedValue(new Error("retry boom")); + + const result = await managerAny.retryChannelRun("agent-1", "channel-1"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(result.success).toBe(true); + expect(mockSend).toHaveBeenCalled(); + }); + }); + describe("getConnector", () => { it("returns undefined for non-existent agent", () => { const ctx = createMockContext(null); diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index 1279c5dd..d04f5273 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -1166,6 +1166,7 @@ export class DiscordManager implements IChatManager { qualifiedName: string, channelId: string, ): Promise<{ success: boolean; message: string; jobId?: string }> { + const logger = this.ctx.getLogger(); const key = this.getChannelKey(qualifiedName, channelId); const activeJobId = this.activeJobsByChannel.get(key); if (activeJobId) { @@ -1184,16 +1185,111 @@ export class DiscordManager implements IChatManager { }; } - // Fire-and-forget retry. Slash-command flows don't provide the same - // message reply hooks as the message pipeline, so we retry execution - // without channel streaming and return an immediate status response. - void this.ctx - .trigger(qualifiedName, undefined, { - triggerType: "discord", + const connector = this.connectors.get(qualifiedName); + if (!connector?.client?.isReady()) { + return { + success: false, + message: "Retry is unavailable because the Discord connector is not connected.", + }; + } + + const channel = await connector.client.channels.fetch(channelId); + if ( + !channel || + !("isTextBased" in channel) || + typeof channel.isTextBased !== "function" || + !channel.isTextBased() || + !("send" in channel) || + typeof channel.send !== "function" + ) { + return { + success: false, + message: "Retry failed because this channel is not text-capable.", + }; + } + + type RetryMessageRef = { + edit: (content: string | DiscordReplyPayload) => Promise; + delete: () => Promise; + }; + type RetryChannel = { + send: (content: string | DiscordReplyPayload) => Promise; + sendTyping?: () => Promise; + }; + const textChannel = channel as unknown as RetryChannel; + + const retryEvent = { + agentName: qualifiedName, + prompt: lastPrompt, + context: { + messages: [], prompt: lastPrompt, - onJobCreated: async (jobId) => { - this.activeJobsByChannel.set(key, jobId); - }, + wasMentioned: false, + }, + metadata: { + guildId: + "isDMBased" in channel && typeof channel.isDMBased === "function" && channel.isDMBased() + ? null + : "guildId" in channel && typeof channel.guildId === "string" + ? channel.guildId + : null, + channelId, + messageId: `retry-${randomUUID()}`, + userId: "retry-command", + username: "retry-command", + wasMentioned: false, + mode: "auto" as const, + }, + reply: async (content: string | DiscordReplyPayload): Promise => { + await textChannel.send(content); + }, + replyWithRef: async ( + content: string | DiscordReplyPayload, + ): Promise<{ edit: (c: string | DiscordReplyPayload) => Promise; delete: () => Promise }> => { + const sent = await textChannel.send(content); + return { + edit: async (newContent: string | DiscordReplyPayload) => { + await sent.edit(newContent); + }, + delete: async () => { + await sent.delete(); + }, + }; + }, + startTyping: (): (() => void) => { + let typingInterval: ReturnType | null = null; + if (textChannel.sendTyping) { + void textChannel.sendTyping().catch((err: unknown) => { + logger.debug(`Retry typing indicator failed: ${(err as Error).message}`); + }); + typingInterval = setInterval(() => { + void textChannel.sendTyping?.().catch((err: unknown) => { + logger.debug(`Retry typing refresh failed: ${(err as Error).message}`); + }); + }, 8000); + } + return () => { + if (typingInterval) { + clearInterval(typingInterval); + typingInterval = null; + } + }; + }, + addReaction: async () => {}, + removeReaction: async () => {}, + } as DiscordMessageEvent; + + // Execute retry through the same Discord message pipeline so users get + // the normal streamed answer/tool/status outputs in the channel. + void this.handleMessage(qualifiedName, retryEvent) + .catch(async (error: unknown) => { + const err = error instanceof Error ? error : new Error(String(error)); + logger.error(`Background retry failed for '${qualifiedName}': ${err.message}`); + try { + await textChannel.send(this.formatErrorMessage(err, qualifiedName)); + } catch (replyError) { + logger.error(`Failed to send retry failure message: ${(replyError as Error).message}`); + } }) .finally(() => { this.activeJobsByChannel.delete(key); @@ -1201,7 +1297,7 @@ export class DiscordManager implements IChatManager { return { success: true, - message: "Retry started in the background for this channel's last prompt.", + message: "Retry started. I will post the retried run output in this channel.", }; } From 3b0b644a64112ef7c76bc8503a40cf615fb403c5 Mon Sep 17 00:00:00 2001 From: agent Date: Wed, 4 Mar 2026 17:46:32 -0800 Subject: [PATCH 25/48] Deduplicate CLI assistant snapshots in synthetic result usage --- .../runtime/__tests__/cli-runtime.test.ts | 116 ++++++++++++++++++ .../core/src/runner/runtime/cli-runtime.ts | 27 ++++ 2 files changed, 143 insertions(+) create mode 100644 packages/core/src/runner/runtime/__tests__/cli-runtime.test.ts diff --git a/packages/core/src/runner/runtime/__tests__/cli-runtime.test.ts b/packages/core/src/runner/runtime/__tests__/cli-runtime.test.ts new file mode 100644 index 00000000..cb744f6a --- /dev/null +++ b/packages/core/src/runner/runtime/__tests__/cli-runtime.test.ts @@ -0,0 +1,116 @@ +import { EventEmitter } from "node:events"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SDKMessage } from "../../types.js"; + +const watchMessages: SDKMessage[] = []; +const flushMessages: SDKMessage[] = []; + +vi.mock("../cli-session-path.js", () => ({ + getCliSessionDir: vi.fn(() => "/tmp/sessions"), + getCliSessionFile: vi.fn(() => "/tmp/sessions/session-1.jsonl"), + waitForNewSessionFile: vi.fn(async () => "/tmp/sessions/session-1.jsonl"), +})); + +vi.mock("../cli-session-watcher.js", () => ({ + CLISessionWatcher: class { + constructor(_path: string) {} + + async initialize(): Promise {} + + async *watch(): AsyncIterable { + for (const message of watchMessages) { + yield message; + } + } + + async flushRemainingMessages(): Promise { + return [...flushMessages]; + } + + stop(): void {} + }, +})); + +import { CLIRuntime } from "../cli-runtime.js"; + +function makeSubprocess(exitCode = 0): Promise<{ exitCode: number }> & { + pid: number; + stdout: EventEmitter; + stderr: EventEmitter; + kill: () => void; +} { + const promise = Promise.resolve({ exitCode }) as Promise<{ exitCode: number }> & { + pid: number; + stdout: EventEmitter; + stderr: EventEmitter; + kill: () => void; + }; + promise.pid = 1234; + promise.stdout = new EventEmitter(); + promise.stderr = new EventEmitter(); + promise.kill = vi.fn(); + return promise; +} + +describe("CLIRuntime synthetic result aggregation", () => { + beforeEach(() => { + watchMessages.length = 0; + flushMessages.length = 0; + }); + + it("deduplicates assistant snapshots when aggregating turns and usage", async () => { + watchMessages.push( + { + type: "assistant", + message: { + id: "msg-1", + stop_reason: null, + usage: { input_tokens: 100, output_tokens: 25 }, + content: [{ type: "text", text: "partial" }], + }, + } as SDKMessage, + { + type: "assistant", + message: { + id: "msg-1", + stop_reason: "end_turn", + usage: { input_tokens: 100, output_tokens: 25 }, + content: [{ type: "text", text: "final one" }], + }, + } as SDKMessage, + { + type: "assistant", + message: { + id: "msg-2", + stop_reason: "end_turn", + usage: { input_tokens: 10, output_tokens: 5 }, + content: [{ type: "text", text: "final two" }], + }, + } as SDKMessage, + ); + + const runtime = new CLIRuntime({ + processSpawner: (() => makeSubprocess() as never) as never, + }); + + const messages: SDKMessage[] = []; + for await (const message of runtime.execute({ + prompt: "Hello", + agent: { name: "test-agent", configPath: "/tmp/agent.yaml" } as never, + })) { + messages.push(message); + } + + const result = messages.find((m) => m.type === "result") as + | (SDKMessage & { + type: "result"; + num_turns?: number; + usage?: { input_tokens?: number; output_tokens?: number }; + }) + | undefined; + expect(result).toBeDefined(); + expect(result?.num_turns).toBe(2); + expect(result?.usage?.input_tokens).toBe(110); + expect(result?.usage?.output_tokens).toBe(30); + }); +}); diff --git a/packages/core/src/runner/runtime/cli-runtime.ts b/packages/core/src/runner/runtime/cli-runtime.ts index 9fd0cc2a..0733fada 100644 --- a/packages/core/src/runner/runtime/cli-runtime.ts +++ b/packages/core/src/runner/runtime/cli-runtime.ts @@ -285,9 +285,36 @@ export class CLIRuntime implements RuntimeInterface { let totalOutputTokens = 0; let numTurns = 0; let lastAssistantText = ""; + const seenAssistantMessageIds = new Set(); // Helper to accumulate usage from each assistant message const trackAssistantUsage = (message: SDKMessage) => { + if (message.type !== "assistant") { + return; + } + + const assistantMeta = message as { + message?: { + id?: string; + stop_reason?: unknown; + }; + }; + const stopReason = assistantMeta.message?.stop_reason; + + // Ignore intermediate assistant snapshots. + if (stopReason === null) { + return; + } + + // Claude CLI can emit duplicate finalized snapshots for the same message id. + const messageId = assistantMeta.message?.id; + if (typeof messageId === "string" && messageId.length > 0) { + if (seenAssistantMessageIds.has(messageId)) { + return; + } + seenAssistantMessageIds.add(messageId); + } + numTurns++; const msg = message as { message?: { From 4505d0ad15332d0535ba465b8dcd097848c783fe Mon Sep 17 00:00:00 2001 From: agent Date: Wed, 4 Mar 2026 18:05:35 -0800 Subject: [PATCH 26/48] Cleanup: deduplicate formatDuration, extract synthetic event factory - Replace local formatDuration in embeds.ts with formatDurationMs from @herdctl/chat (identical implementation) - Extract SyntheticTextChannel type and createSyntheticMessageEvent() factory from the 80-line inline retry event construction - Document why message-normalizer.ts uses casts (SDKMessage is intentionally loose to support both SDK and CLI runtimes) Co-Authored-By: Claude Opus 4.6 --- packages/discord/src/embeds.ts | 12 +- packages/discord/src/manager.ts | 122 +++++++++++++-------- packages/discord/src/message-normalizer.ts | 9 ++ 3 files changed, 89 insertions(+), 54 deletions(-) diff --git a/packages/discord/src/embeds.ts b/packages/discord/src/embeds.ts index 3966a002..440c9b52 100644 --- a/packages/discord/src/embeds.ts +++ b/packages/discord/src/embeds.ts @@ -1,4 +1,4 @@ -import { formatCompactNumber } from "@herdctl/chat"; +import { formatCompactNumber, formatDurationMs } from "@herdctl/chat"; import { getToolInputSummary, TOOL_EMOJIS } from "@herdctl/core"; import type { DiscordReplyEmbed, DiscordReplyEmbedField } from "./types.js"; @@ -16,14 +16,8 @@ export function buildFooter(agentName: string): { text: string } { return { text: `herdctl · ${shortName}` }; } -export function formatDuration(ms: number): string { - if (ms < 1000) return `${ms}ms`; - const seconds = Math.floor(ms / 1000); - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; -} +// Re-export so existing callers (tests, snapshots) don't break. +export const formatDuration = formatDurationMs; export function buildRunCardEmbed(params: { agentName: string; diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index d04f5273..a3efce14 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -66,6 +66,19 @@ import { transcribeAudio } from "./voice-transcriber.js"; */ type DiscordMessageEvent = DiscordConnectorEventMap["message"]; +/** + * Minimal text-channel shape used when constructing synthetic message events + * (e.g. for /retry). Avoids coupling to the full discord.js Channel type. + */ +type SyntheticMessageRef = { + edit: (content: string | DiscordReplyPayload) => Promise; + delete: () => Promise; +}; +type SyntheticTextChannel = { + send: (content: string | DiscordReplyPayload) => Promise; + sendTyping?: () => Promise; +}; + /** * Error event payload from DiscordConnector */ @@ -1208,35 +1221,79 @@ export class DiscordManager implements IChatManager { }; } - type RetryMessageRef = { - edit: (content: string | DiscordReplyPayload) => Promise; - delete: () => Promise; - }; - type RetryChannel = { - send: (content: string | DiscordReplyPayload) => Promise; - sendTyping?: () => Promise; + const textChannel = this.toTextChannel(channel); + const retryEvent = this.createSyntheticMessageEvent({ + qualifiedName, + prompt: lastPrompt, + channelId, + channel, + textChannel, + logger, + }); + + // Execute retry through the same Discord message pipeline so users get + // the normal streamed answer/tool/status outputs in the channel. + void this.handleMessage(qualifiedName, retryEvent) + .catch(async (error: unknown) => { + const err = error instanceof Error ? error : new Error(String(error)); + logger.error(`Background retry failed for '${qualifiedName}': ${err.message}`); + try { + await textChannel.send(this.formatErrorMessage(err, qualifiedName)); + } catch (replyError) { + logger.error(`Failed to send retry failure message: ${(replyError as Error).message}`); + } + }) + .finally(() => { + this.activeJobsByChannel.delete(key); + }); + + return { + success: true, + message: "Retry started. I will post the retried run output in this channel.", }; - const textChannel = channel as unknown as RetryChannel; + } + + // =========================================================================== + // Synthetic Event Helpers + // =========================================================================== - const retryEvent = { + private toTextChannel(channel: { send?: unknown }): SyntheticTextChannel { + return channel as unknown as SyntheticTextChannel; + } + + /** + * Build a DiscordMessageEvent from scratch for programmatic triggers + * (e.g. /retry). This keeps the boilerplate in one place so any new + * fields on DiscordMessageEvent only need updating here. + */ + private createSyntheticMessageEvent(params: { + qualifiedName: string; + prompt: string; + channelId: string; + channel: { isDMBased?: () => boolean; guildId?: string }; + textChannel: SyntheticTextChannel; + logger: { debug: (msg: string) => void }; + }): DiscordMessageEvent { + const { qualifiedName, prompt, channelId, channel, textChannel, logger } = params; + return { agentName: qualifiedName, - prompt: lastPrompt, + prompt, context: { messages: [], - prompt: lastPrompt, + prompt, wasMentioned: false, }, metadata: { guildId: - "isDMBased" in channel && typeof channel.isDMBased === "function" && channel.isDMBased() + typeof channel.isDMBased === "function" && channel.isDMBased() ? null - : "guildId" in channel && typeof channel.guildId === "string" + : typeof channel.guildId === "string" ? channel.guildId : null, channelId, - messageId: `retry-${randomUUID()}`, - userId: "retry-command", - username: "retry-command", + messageId: `synthetic-${randomUUID()}`, + userId: "system", + username: "system", wasMentioned: false, mode: "auto" as const, }, @@ -1248,23 +1305,19 @@ export class DiscordManager implements IChatManager { ): Promise<{ edit: (c: string | DiscordReplyPayload) => Promise; delete: () => Promise }> => { const sent = await textChannel.send(content); return { - edit: async (newContent: string | DiscordReplyPayload) => { - await sent.edit(newContent); - }, - delete: async () => { - await sent.delete(); - }, + edit: async (c: string | DiscordReplyPayload) => { await sent.edit(c); }, + delete: async () => { await sent.delete(); }, }; }, startTyping: (): (() => void) => { let typingInterval: ReturnType | null = null; if (textChannel.sendTyping) { void textChannel.sendTyping().catch((err: unknown) => { - logger.debug(`Retry typing indicator failed: ${(err as Error).message}`); + logger.debug(`Synthetic typing indicator failed: ${(err as Error).message}`); }); typingInterval = setInterval(() => { void textChannel.sendTyping?.().catch((err: unknown) => { - logger.debug(`Retry typing refresh failed: ${(err as Error).message}`); + logger.debug(`Synthetic typing refresh failed: ${(err as Error).message}`); }); }, 8000); } @@ -1278,27 +1331,6 @@ export class DiscordManager implements IChatManager { addReaction: async () => {}, removeReaction: async () => {}, } as DiscordMessageEvent; - - // Execute retry through the same Discord message pipeline so users get - // the normal streamed answer/tool/status outputs in the channel. - void this.handleMessage(qualifiedName, retryEvent) - .catch(async (error: unknown) => { - const err = error instanceof Error ? error : new Error(String(error)); - logger.error(`Background retry failed for '${qualifiedName}': ${err.message}`); - try { - await textChannel.send(this.formatErrorMessage(err, qualifiedName)); - } catch (replyError) { - logger.error(`Failed to send retry failure message: ${(replyError as Error).message}`); - } - }) - .finally(() => { - this.activeJobsByChannel.delete(key); - }); - - return { - success: true, - message: "Retry started. I will post the retried run output in this channel.", - }; } private async getChannelRunInfo( diff --git a/packages/discord/src/message-normalizer.ts b/packages/discord/src/message-normalizer.ts index d3a96a2d..794e2a47 100644 --- a/packages/discord/src/message-normalizer.ts +++ b/packages/discord/src/message-normalizer.ts @@ -1,3 +1,12 @@ +/** + * Normalizes the loose SDKMessage union into typed Discord display events. + * + * SDKMessage uses `[key: string]: unknown` (an intentional design choice to + * stay compatible with both the SDK and CLI runtimes without a compile-time + * dependency). The casts in this file are a consequence of that — they narrow + * the runtime shape for each message type. If SDKMessage is ever refined into + * a proper discriminated union, these casts can be removed. + */ import { extractMessageContent } from "@herdctl/chat"; import { extractToolResults, From 21895e2e4bb1f41e83513eabe692ee6edab2e3ea Mon Sep 17 00:00:00 2001 From: agent Date: Wed, 4 Mar 2026 20:20:45 -0800 Subject: [PATCH 27/48] feat(core): add host: true MCP server support for Docker agents When an agent runs in Docker and an MCP server has `host: true` in its config, the server is spawned on the host instead of inside the container. Tools are discovered via MCP protocol and bridged into the container through the existing HTTP bridge infrastructure. This enables MCP servers that need host access (e.g., Bear notes which requires macOS `open` command and Full Disk Access) to work with containerized agents. - Add `host` field to McpServerSchema - New mcp-host-bridge module: spawns host-side MCP process via StdioClientTransport, discovers tools, wraps as InjectedMcpServerDef - partitionMcpServers utility splits host vs container servers - ContainerRunner.execute() partitions before dispatch so both CLI and SDK runtime paths benefit Co-Authored-By: Claude Opus 4.6 --- packages/core/src/config/schema.ts | 2 + .../runtime/__tests__/mcp-host-bridge.test.ts | 147 ++++++++++++++++++ .../src/runner/runtime/container-runner.ts | 55 ++++++- .../src/runner/runtime/mcp-host-bridge.ts | 136 ++++++++++++++++ 4 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/runner/runtime/__tests__/mcp-host-bridge.test.ts create mode 100644 packages/core/src/runner/runtime/mcp-host-bridge.ts diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index ed8a1645..9d55f854 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -501,6 +501,8 @@ export const McpServerSchema = z.object({ args: z.array(z.string()).optional(), env: z.record(z.string(), z.string()).optional(), url: z.string().optional(), + /** When true and Docker is enabled, run this MCP server on the host and bridge into the container */ + host: z.boolean().optional(), }); // ============================================================================= diff --git a/packages/core/src/runner/runtime/__tests__/mcp-host-bridge.test.ts b/packages/core/src/runner/runtime/__tests__/mcp-host-bridge.test.ts new file mode 100644 index 00000000..ed774d18 --- /dev/null +++ b/packages/core/src/runner/runtime/__tests__/mcp-host-bridge.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from "vitest"; +import type { McpServer } from "../../../config/index.js"; + +// Mock the MCP SDK before importing the module under test +const mockConnect = vi.fn().mockResolvedValue(undefined); +const mockClose = vi.fn().mockResolvedValue(undefined); +const mockListTools = vi.fn().mockResolvedValue({ + tools: [ + { + name: "search_notes", + description: "Search Bear notes", + inputSchema: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + }, + { + name: "create_note", + description: "Create a Bear note", + inputSchema: { + type: "object", + properties: { title: { type: "string" }, text: { type: "string" } }, + }, + }, + ], +}); +const mockCallTool = vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "Note found: Hello World" }], + isError: false, +}); + +vi.mock("@modelcontextprotocol/sdk/client/index.js", () => { + return { + Client: class MockClient { + connect = mockConnect; + close = mockClose; + listTools = mockListTools; + callTool = mockCallTool; + }, + }; +}); + +vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => { + return { + StdioClientTransport: class MockTransport {}, + }; +}); + +import { createMcpHostBridge, partitionMcpServers } from "../mcp-host-bridge.js"; + +describe("partitionMcpServers", () => { + it("separates host and container servers", () => { + const servers: Record = { + bear: { command: "node", args: ["bear.js"], host: true }, + gmail: { command: "npx", args: ["gmail-mcp"] }, + contacts: { command: "python3", args: ["-m", "contacts"], host: true }, + }; + + const [host, container] = partitionMcpServers(servers); + + expect(Object.keys(host)).toEqual(["bear", "contacts"]); + expect(Object.keys(container)).toEqual(["gmail"]); + expect(host.bear.host).toBe(true); + expect(container.gmail.host).toBeUndefined(); + }); + + it("returns empty records for undefined input", () => { + const [host, container] = partitionMcpServers(undefined); + expect(Object.keys(host)).toHaveLength(0); + expect(Object.keys(container)).toHaveLength(0); + }); + + it("puts all servers in container when none have host: true", () => { + const servers: Record = { + a: { command: "a" }, + b: { command: "b" }, + }; + const [host, container] = partitionMcpServers(servers); + expect(Object.keys(host)).toHaveLength(0); + expect(Object.keys(container)).toHaveLength(2); + }); + + it("puts all servers in host when all have host: true", () => { + const servers: Record = { + a: { command: "a", host: true }, + b: { command: "b", host: true }, + }; + const [host, container] = partitionMcpServers(servers); + expect(Object.keys(host)).toHaveLength(2); + expect(Object.keys(container)).toHaveLength(0); + }); +}); + +describe("createMcpHostBridge", () => { + it("throws if no command is specified", async () => { + await expect(createMcpHostBridge("test", { host: true })).rejects.toThrow( + "no command specified", + ); + }); + + it("connects to the MCP server and discovers tools", async () => { + const config: McpServer = { + command: "node", + args: ["/srv/mcp-servers/bear/index.js"], + env: { BEAR_DB_PATH: "/data/bear.sqlite" }, + host: true, + }; + + const handle = await createMcpHostBridge("bear", config); + + expect(mockConnect).toHaveBeenCalled(); + expect(mockListTools).toHaveBeenCalled(); + expect(handle.serverDef.name).toBe("bear"); + expect(handle.serverDef.tools).toHaveLength(2); + expect(handle.serverDef.tools[0].name).toBe("search_notes"); + expect(handle.serverDef.tools[1].name).toBe("create_note"); + }); + + it("tool handler delegates to client.callTool", async () => { + const handle = await createMcpHostBridge("bear", { + command: "node", + args: ["bear.js"], + host: true, + }); + + const result = await handle.serverDef.tools[0].handler({ query: "hello" }); + + expect(mockCallTool).toHaveBeenCalledWith({ + name: "search_notes", + arguments: { query: "hello" }, + }); + expect(result.content[0].text).toBe("Note found: Hello World"); + expect(result.isError).toBe(false); + }); + + it("close() calls client.close()", async () => { + const handle = await createMcpHostBridge("bear", { + command: "node", + args: ["bear.js"], + host: true, + }); + + await handle.close(); + expect(mockClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/runner/runtime/container-runner.ts b/packages/core/src/runner/runtime/container-runner.ts index a71728ba..fd0f23ea 100644 --- a/packages/core/src/runner/runtime/container-runner.ts +++ b/packages/core/src/runner/runtime/container-runner.ts @@ -30,6 +30,11 @@ import { buildContainerEnv, buildContainerMounts, ContainerManager } from "./con import type { DockerConfig } from "./docker-config.js"; import type { RuntimeExecuteOptions, RuntimeInterface } from "./interface.js"; import { type McpHttpBridge, startMcpHttpBridge } from "./mcp-http-bridge.js"; +import { + createMcpHostBridge, + type McpHostBridgeHandle, + partitionMcpServers, +} from "./mcp-host-bridge.js"; import { SDKRuntime } from "./sdk-runtime.js"; const logger = createLogger("ContainerRunner"); @@ -83,6 +88,43 @@ export class ContainerRunner implements RuntimeInterface { // Get or create container const container = await this.manager.getOrCreateContainer(agent.name, this.config, mounts, env); + // Partition host: true MCP servers — these run on the host and get bridged + const hostBridges: McpHostBridgeHandle[] = []; + let effectiveOptions = options; + + if (agent.mcp_servers) { + const [hostServers, containerServers] = partitionMcpServers(agent.mcp_servers); + + if (Object.keys(hostServers).length > 0) { + // Start host-side MCP bridges + for (const [name, config] of Object.entries(hostServers)) { + const handle = await createMcpHostBridge(name, config); + hostBridges.push(handle); + } + + // Build effective options with host servers moved to injectedMcpServers + const injected = { ...(options.injectedMcpServers ?? {}) }; + for (const handle of hostBridges) { + injected[handle.serverDef.name] = handle.serverDef; + } + + effectiveOptions = { + ...options, + agent: { + ...agent, + mcp_servers: + Object.keys(containerServers).length > 0 ? containerServers : undefined, + }, + injectedMcpServers: injected, + }; + + logger.info( + `Partitioned MCP servers: ${Object.keys(hostServers).length} host, ` + + `${Object.keys(containerServers).length} container`, + ); + } + } + try { // Get container ID for docker exec const containerInfo = await container.inspect(); @@ -90,11 +132,11 @@ export class ContainerRunner implements RuntimeInterface { // Handle CLI runtime with session file watching if (this.wrapped instanceof CLIRuntime) { - yield* this.executeCLIRuntime(containerId, dockerSessionsDir, options); + yield* this.executeCLIRuntime(containerId, dockerSessionsDir, effectiveOptions); } // Handle SDK runtime with wrapper script else if (this.wrapped instanceof SDKRuntime) { - yield* this.executeSDKRuntime(container, options); + yield* this.executeSDKRuntime(container, effectiveOptions); } // Unknown runtime type else { @@ -112,6 +154,15 @@ export class ContainerRunner implements RuntimeInterface { // Container cleanup happens in finally block } finally { + // Close host MCP bridges (kill subprocess + MCP client) + for (const handle of hostBridges) { + try { + await handle.close(); + } catch (err) { + logger.error(`Failed to close host MCP bridge: ${err}`); + } + } + // For ephemeral containers, stop immediately after execution // This triggers AutoRemove so the container is cleaned up automatically if (this.config.ephemeral) { diff --git a/packages/core/src/runner/runtime/mcp-host-bridge.ts b/packages/core/src/runner/runtime/mcp-host-bridge.ts new file mode 100644 index 00000000..e1e90f6c --- /dev/null +++ b/packages/core/src/runner/runtime/mcp-host-bridge.ts @@ -0,0 +1,136 @@ +/** + * MCP Host Bridge + * + * Spawns an MCP server process on the host and wraps it as an InjectedMcpServerDef. + * Used by ContainerRunner to run `host: true` MCP servers outside Docker while + * making their tools available to the containerized agent via HTTP bridge. + * + * Uses the MCP SDK Client with StdioClientTransport to communicate with the + * spawned process. Discovers tools via tools/list and creates handler functions + * that delegate to tools/call. + * + * @module mcp-host-bridge + */ + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import type { McpServer } from "../../config/index.js"; +import { createLogger } from "../../utils/logger.js"; +import type { InjectedMcpServerDef, InjectedMcpToolDef, McpToolCallResult } from "../types.js"; + +const logger = createLogger("McpHostBridge"); + +// ============================================================================= +// Types +// ============================================================================= + +export interface McpHostBridgeHandle { + /** The InjectedMcpServerDef with tool handlers that delegate to the host process */ + serverDef: InjectedMcpServerDef; + /** Close the MCP client and kill the subprocess */ + close: () => Promise; +} + +// ============================================================================= +// Host Bridge +// ============================================================================= + +/** + * Spawn a host-side MCP server process and wrap it as an InjectedMcpServerDef. + * + * The spawned process communicates via stdio using the MCP protocol. + * Tools are discovered via tools/list and each tool's handler delegates + * to tools/call on the subprocess. + * + * @param name - Server name (used for logging and as the InjectedMcpServerDef name) + * @param config - MCP server config with command, args, env + * @returns Handle with the server definition and a close function + */ +export async function createMcpHostBridge( + name: string, + config: McpServer, +): Promise { + if (!config.command) { + throw new Error(`MCP server '${name}' has host: true but no command specified`); + } + + const transport = new StdioClientTransport({ + command: config.command, + args: config.args, + env: config.env ? { ...process.env, ...config.env } as Record : undefined, + }); + + const client = new Client( + { name: `herdctl-host-${name}`, version: "1.0.0" }, + { capabilities: {} }, + ); + await client.connect(transport); + + // Discover tools from the MCP server + const toolsResult = await client.listTools(); + const tools: InjectedMcpToolDef[] = (toolsResult.tools ?? []).map((tool) => ({ + name: tool.name, + description: tool.description ?? "", + inputSchema: (tool.inputSchema ?? {}) as Record, + handler: async (args: Record): Promise => { + const result = await client.callTool({ name: tool.name, arguments: args }); + const content = Array.isArray(result.content) + ? (result.content as Array<{ type: string; text: string }>) + : [{ type: "text", text: JSON.stringify(result) }]; + return { + content, + isError: result.isError === true, + }; + }, + })); + + logger.info(`Host MCP bridge '${name}' connected with ${tools.length} tool(s)`); + + return { + serverDef: { + name, + version: "1.0.0", + tools, + }, + close: async () => { + try { + await client.close(); + } catch (err) { + logger.error(`Failed to close MCP host bridge '${name}': ${err}`); + } + }, + }; +} + +// ============================================================================= +// Partition Utility +// ============================================================================= + +/** + * Partition MCP servers into host-side and container-side groups. + * + * Servers with `host: true` are separated out so they can be spawned on + * the host and bridged into the container. Only meaningful when Docker + * is enabled — when Docker is off, all servers run in-process regardless. + * + * @param mcpServers - All configured MCP servers for the agent + * @returns Tuple of [hostServers, containerServers] + */ +export function partitionMcpServers( + mcpServers: Record | undefined, +): [Record, Record] { + const hostServers: Record = {}; + const containerServers: Record = {}; + + if (!mcpServers) return [hostServers, containerServers]; + + for (const [name, server] of Object.entries(mcpServers)) { + if (server.host) { + hostServers[name] = server; + } else { + containerServers[name] = server; + } + } + + return [hostServers, containerServers]; +} From 7553fe96b1f47b322382b20a2bfab7a0296661bb Mon Sep 17 00:00:00 2001 From: agent Date: Wed, 4 Mar 2026 20:34:08 -0800 Subject: [PATCH 28/48] fix(core): route MCP HTTP bridges via host.docker.internal for Docker agents CLIRuntime MCP HTTP bridges used 127.0.0.1 which is unreachable from inside Docker containers. Add mcpBridgeHost option (defaults to 127.0.0.1) and set it to host.docker.internal in ContainerRunner's CLI path. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/runner/runtime/cli-runtime.ts | 12 +++++++++++- packages/core/src/runner/runtime/container-runner.ts | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/core/src/runner/runtime/cli-runtime.ts b/packages/core/src/runner/runtime/cli-runtime.ts index 0733fada..5e073f5c 100644 --- a/packages/core/src/runner/runtime/cli-runtime.ts +++ b/packages/core/src/runner/runtime/cli-runtime.ts @@ -64,6 +64,14 @@ interface CLIRuntimeOptions { * container session files are visible (e.g., .herdctl/docker-sessions). */ sessionDirOverride?: string; + + /** + * Hostname for MCP HTTP bridge URLs + * + * Defaults to '127.0.0.1'. For Docker execution, set to 'host.docker.internal' + * so the container can reach host-side bridges. + */ + mcpBridgeHost?: string; } /** @@ -99,6 +107,7 @@ interface CLIRuntimeOptions { export class CLIRuntime implements RuntimeInterface { private processSpawner: ProcessSpawner; private sessionDirOverride?: string; + private mcpBridgeHost: string; constructor(options?: CLIRuntimeOptions) { // Default to local execa spawning with prompt via stdin @@ -112,6 +121,7 @@ export class CLIRuntime implements RuntimeInterface { })); this.sessionDirOverride = options?.sessionDirOverride; + this.mcpBridgeHost = options?.mcpBridgeHost ?? "127.0.0.1"; } /** * Execute an agent using the Claude CLI @@ -225,7 +235,7 @@ export class CLIRuntime implements RuntimeInterface { mcpConfig.mcpServers[name] = { type: "http", - url: `http://127.0.0.1:${bridge.port}/mcp`, + url: `http://${this.mcpBridgeHost}:${bridge.port}/mcp`, }; const configJson = JSON.stringify(mcpConfig); diff --git a/packages/core/src/runner/runtime/container-runner.ts b/packages/core/src/runner/runtime/container-runner.ts index fd0f23ea..f8e2b92e 100644 --- a/packages/core/src/runner/runtime/container-runner.ts +++ b/packages/core/src/runner/runtime/container-runner.ts @@ -197,6 +197,7 @@ export class ContainerRunner implements RuntimeInterface { ): AsyncIterable { // Create CLI runtime with Docker-specific spawner const cliRuntime = new CLIRuntime({ + mcpBridgeHost: "host.docker.internal", processSpawner: (args, _cwd, prompt, signal) => { // Build docker exec command with prompt piped to stdin // Uses printf to avoid issues with newlines and special chars in prompt From d40f215d0a045dd1cb85a271b2fe182d62a9eeec Mon Sep 17 00:00:00 2001 From: agent Date: Wed, 4 Mar 2026 21:10:04 -0800 Subject: [PATCH 29/48] feat(discord): add default_channel_mode for guild-wide mention response Add optional default_channel_mode field to DiscordGuildSchema. When set, the bot responds in any channel in that guild (not just explicitly listed ones). Enables agents like Vector to respond to @mentions server-wide while keeping auto mode in their home channel. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/config/schema.ts | 2 ++ packages/discord/src/auto-mode-handler.ts | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 9d55f854..f5046c15 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -583,6 +583,8 @@ export const DiscordChannelSchema = z.object({ export const DiscordGuildSchema = z.object({ id: z.string(), channels: z.array(DiscordChannelSchema).optional(), + /** Default mode for channels not explicitly listed. When set, the bot responds in any channel. */ + default_channel_mode: z.enum(["mention", "auto"]).optional(), dm: ChatDMSchema.optional(), }); diff --git a/packages/discord/src/auto-mode-handler.ts b/packages/discord/src/auto-mode-handler.ts index abcaa87b..3dd83fce 100644 --- a/packages/discord/src/auto-mode-handler.ts +++ b/packages/discord/src/auto-mode-handler.ts @@ -178,6 +178,15 @@ export function resolveChannelConfig( const channelConfig = guildConfig.channels?.find((c) => c.id === channelId); if (!channelConfig) { + // Fall back to guild-level default mode (e.g., respond to @mentions in any channel) + if (guildConfig.default_channel_mode) { + return { + mode: guildConfig.default_channel_mode, + contextMessages: DEFAULT_CHANNEL_CONTEXT_MESSAGES, + isDM: false, + guildId, + }; + } return null; } From 5d6cf72cbd05e458aff67e063faa17d68a611818 Mon Sep 17 00:00:00 2001 From: agent Date: Wed, 4 Mar 2026 21:42:38 -0800 Subject: [PATCH 30/48] Add skills, usage, and utility Discord slash commands --- .../src/content/docs/integrations/discord.mdx | 9 + packages/discord/README.md | 9 +- .../discord/src/__tests__/manager.test.ts | 10 +- .../__tests__/command-manager.test.ts | 43 ++- .../__tests__/extended-commands.test.ts | 43 +++ packages/discord/src/commands/cancel.ts | 25 ++ .../discord/src/commands/command-manager.ts | 49 ++- packages/discord/src/commands/config.ts | 36 +++ packages/discord/src/commands/help.ts | 7 + packages/discord/src/commands/index.ts | 9 +- packages/discord/src/commands/ping.ts | 24 ++ packages/discord/src/commands/skill.ts | 57 ++++ packages/discord/src/commands/skills.ts | 35 +++ packages/discord/src/commands/tools.ts | 33 ++ packages/discord/src/commands/types.ts | 45 ++- packages/discord/src/commands/usage.ts | 42 +++ packages/discord/src/discord-connector.ts | 21 +- packages/discord/src/index.ts | 7 + packages/discord/src/manager.ts | 294 ++++++++++++++---- 19 files changed, 713 insertions(+), 85 deletions(-) create mode 100644 packages/discord/src/commands/cancel.ts create mode 100644 packages/discord/src/commands/config.ts create mode 100644 packages/discord/src/commands/ping.ts create mode 100644 packages/discord/src/commands/skill.ts create mode 100644 packages/discord/src/commands/skills.ts create mode 100644 packages/discord/src/commands/tools.ts create mode 100644 packages/discord/src/commands/usage.ts diff --git a/docs/src/content/docs/integrations/discord.mdx b/docs/src/content/docs/integrations/discord.mdx index ef503548..83f2eed1 100644 --- a/docs/src/content/docs/integrations/discord.mdx +++ b/docs/src/content/docs/integrations/discord.mdx @@ -577,11 +577,18 @@ herdctl automatically registers slash commands for every Discord-enabled agent: | Command | Description | |---------|-------------| | `/help` | Show available commands and usage | +| `/ping` | Quick health check | +| `/config` | Show runtime-relevant agent configuration | +| `/tools` | Show allowed/denied tools and MCP servers | +| `/usage` | Show latest run usage for this channel | +| `/skills` | List discovered skills for this agent | +| `/skill` | Trigger a skill (with autocomplete) | | `/status` | Show bot connection status and session info | | `/session` | Show current session and run state for this channel | | `/reset` | Clear conversation context and start fresh | | `/new` | Alias for starting a fresh conversation | | `/stop` | Stop the active run in this channel | +| `/cancel` | Alias for `/stop` | | `/retry` | Retry the last prompt in this channel | #### How slash commands are entered @@ -607,6 +614,8 @@ Shows available commands and basic usage information: 🤖 Support Bot Commands /help - Show this message +/usage - Show latest run usage +/skills - List discovered skills /status - Show bot status and stats /reset - Clear conversation context diff --git a/packages/discord/README.md b/packages/discord/README.md index 2777f824..53df2729 100644 --- a/packages/discord/README.md +++ b/packages/discord/README.md @@ -140,7 +140,7 @@ This allows you to run multiple agents with different Discord identities, each w - **DM Support** - Users can chat privately with agents - **Channel Support** - Agents can participate in server channels - **Per-Agent Bots** - Each agent can have its own Discord bot identity -- **Slash Commands** - Built-in `/help`, `/status`, `/session`, `/reset`, `/new`, `/stop`, `/retry` +- **Slash Commands** - Built-in `/help`, `/ping`, `/config`, `/tools`, `/usage`, `/skills`, `/skill`, `/status`, `/session`, `/reset`, `/new`, `/stop`, `/cancel`, `/retry` - **Typing Indicators** - Visual feedback while agent is processing - **Message Splitting** - Long responses are automatically split to fit Discord's limits @@ -149,11 +149,18 @@ This allows you to run multiple agents with different Discord identities, each w | Command | Description | |---------|-------------| | `/help` | Show available commands and usage | +| `/ping` | Quick health check | +| `/config` | Show runtime-relevant agent configuration | +| `/tools` | Show allowed/denied tools and MCP servers | +| `/usage` | Show latest run usage for this channel | +| `/skills` | List discovered skills for this agent | +| `/skill` | Trigger a skill (with autocomplete) | | `/status` | Show agent status and current session info | | `/session` | Show session and run state for the current channel | | `/reset` | Clear conversation context (start fresh) | | `/new` | Start a fresh conversation (alias for reset behavior) | | `/stop` | Stop the active run in this channel | +| `/cancel` | Alias for `/stop` | | `/retry` | Retry the last prompt in this channel | To invoke slash commands, type `/` in Discord and pick the command under your bot app in Discord's command picker. Slash commands are routed as interaction events, not regular text messages. diff --git a/packages/discord/src/__tests__/manager.test.ts b/packages/discord/src/__tests__/manager.test.ts index 72a65957..01fc9f23 100644 --- a/packages/discord/src/__tests__/manager.test.ts +++ b/packages/discord/src/__tests__/manager.test.ts @@ -250,7 +250,10 @@ describe("DiscordManager", () => { const managerAny = manager as unknown as { lastPromptByChannel: Map; connectors: Map; - retryChannelRun: (qualifiedName: string, channelId: string) => Promise<{ success: boolean }>; + retryChannelRun: ( + qualifiedName: string, + channelId: string, + ) => Promise<{ success: boolean }>; handleMessage: (qualifiedName: string, event: DiscordMessageEvent) => Promise; }; @@ -296,7 +299,10 @@ describe("DiscordManager", () => { const managerAny = manager as unknown as { lastPromptByChannel: Map; connectors: Map; - retryChannelRun: (qualifiedName: string, channelId: string) => Promise<{ success: boolean }>; + retryChannelRun: ( + qualifiedName: string, + channelId: string, + ) => Promise<{ success: boolean }>; handleMessage: (qualifiedName: string, event: DiscordMessageEvent) => Promise; }; diff --git a/packages/discord/src/commands/__tests__/command-manager.test.ts b/packages/discord/src/commands/__tests__/command-manager.test.ts index e426a81d..97a7f2bb 100644 --- a/packages/discord/src/commands/__tests__/command-manager.test.ts +++ b/packages/discord/src/commands/__tests__/command-manager.test.ts @@ -1,5 +1,5 @@ import type { IChatSessionManager } from "@herdctl/chat"; -import type { ChatInputCommandInteraction, Client } from "discord.js"; +import type { AutocompleteInteraction, ChatInputCommandInteraction, Client } from "discord.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { DiscordConnectorState } from "../../types.js"; @@ -157,11 +157,18 @@ describe("CommandManager", () => { const commands = manager.getCommands(); expect(commands.has("help")).toBe(true); + expect(commands.has("ping")).toBe(true); + expect(commands.has("config")).toBe(true); + expect(commands.has("tools")).toBe(true); + expect(commands.has("usage")).toBe(true); + expect(commands.has("skills")).toBe(true); + expect(commands.has("skill")).toBe(true); expect(commands.has("reset")).toBe(true); expect(commands.has("status")).toBe(true); expect(commands.has("new")).toBe(true); expect(commands.has("session")).toBe(true); expect(commands.has("stop")).toBe(true); + expect(commands.has("cancel")).toBe(true); expect(commands.has("retry")).toBe(true); }); }); @@ -419,7 +426,39 @@ describe("CommandManager", () => { const commands = manager.getCommands(); expect(commands).toBeInstanceOf(Map); - expect(commands.size).toBe(7); + expect(commands.size).toBe(14); + }); + }); + + describe("handleAutocomplete", () => { + it("responds with skill suggestions", async () => { + const manager = new CommandManager({ + agentName: "test-agent", + client, + botToken: "test-token", + sessionManager, + getConnectorState: () => connectorState, + logger, + commandActions: { + listSkills: vi.fn().mockResolvedValue([ + { name: "cloudflare-deploy", description: "Deploy apps to Cloudflare" }, + { name: "pdf", description: "Work with PDF documents" }, + ]), + }, + }); + + const interaction = { + commandName: "skill", + options: { getFocused: vi.fn().mockReturnValue("pdf") }, + respond: vi.fn().mockResolvedValue(undefined), + responded: false, + } as unknown as AutocompleteInteraction; + + await manager.handleAutocomplete(interaction); + + expect(interaction.respond).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ value: "pdf" })]), + ); }); }); }); diff --git a/packages/discord/src/commands/__tests__/extended-commands.test.ts b/packages/discord/src/commands/__tests__/extended-commands.test.ts index f6f276c5..c11ac64e 100644 --- a/packages/discord/src/commands/__tests__/extended-commands.test.ts +++ b/packages/discord/src/commands/__tests__/extended-commands.test.ts @@ -2,16 +2,25 @@ import type { IChatSessionManager } from "@herdctl/chat"; import type { ChatInputCommandInteraction, Client } from "discord.js"; import { describe, expect, it, vi } from "vitest"; import type { DiscordConnectorState } from "../../types.js"; +import { configCommand } from "../config.js"; import { newCommand } from "../new.js"; +import { pingCommand } from "../ping.js"; import { retryCommand } from "../retry.js"; import { sessionCommand } from "../session.js"; +import { skillCommand } from "../skill.js"; +import { skillsCommand } from "../skills.js"; import { stopCommand } from "../stop.js"; +import { toolsCommand } from "../tools.js"; import type { CommandContext } from "../types.js"; +import { usageCommand } from "../usage.js"; function makeContext(): CommandContext { const interaction = { channelId: "channel-1", reply: vi.fn().mockResolvedValue(undefined), + options: { + getString: vi.fn((key: string) => (key === "name" ? "pdf" : "inspect this file")), + }, } as unknown as ChatInputCommandInteraction; const sessionManager = { @@ -45,6 +54,24 @@ function makeContext(): CommandContext { commandActions: { stopRun: vi.fn().mockResolvedValue({ success: true, message: "stopped" }), retryRun: vi.fn().mockResolvedValue({ success: true, message: "retried" }), + runSkill: vi.fn().mockResolvedValue({ success: true, message: "skill started" }), + listSkills: vi.fn().mockResolvedValue([{ name: "pdf", description: "Work with PDFs" }]), + getUsage: vi.fn().mockResolvedValue({ + timestamp: new Date().toISOString(), + numTurns: 2, + inputTokens: 120, + outputTokens: 30, + isError: false, + }), + getAgentConfig: vi.fn().mockResolvedValue({ + runtime: "sdk", + model: "claude-sonnet-4", + permissionMode: "acceptEdits", + workingDirectory: "/tmp/project", + allowedTools: ["Read"], + deniedTools: [], + mcpServers: ["filesystem"], + }), getSessionInfo: vi.fn().mockResolvedValue({ activeJobId: "job-123", lastPrompt: "hello", @@ -77,4 +104,20 @@ describe("extended commands", () => { await retryCommand.execute(ctx); expect(ctx.interaction.reply).toHaveBeenCalledOnce(); }); + + it("executes /skills and /skill", async () => { + const ctx = makeContext(); + await skillsCommand.execute(ctx); + await skillCommand.execute(ctx); + expect(ctx.interaction.reply).toHaveBeenCalledTimes(2); + }); + + it("executes /usage, /tools, /config, and /ping", async () => { + const ctx = makeContext(); + await usageCommand.execute(ctx); + await toolsCommand.execute(ctx); + await configCommand.execute(ctx); + await pingCommand.execute(ctx); + expect(ctx.interaction.reply).toHaveBeenCalledTimes(4); + }); }); diff --git a/packages/discord/src/commands/cancel.ts b/packages/discord/src/commands/cancel.ts new file mode 100644 index 00000000..47a26181 --- /dev/null +++ b/packages/discord/src/commands/cancel.ts @@ -0,0 +1,25 @@ +import type { CommandContext, SlashCommand } from "./types.js"; + +export const cancelCommand: SlashCommand = { + name: "cancel", + description: "Alias for /stop", + + async execute(context: CommandContext): Promise { + const { interaction, commandActions, agentName } = context; + const channelId = interaction.channelId; + const result = commandActions?.stopRun + ? await commandActions.stopRun(channelId) + : { success: false, message: "Stop is not available in this deployment." }; + + await interaction.reply({ + embeds: [ + { + description: result.message, + color: result.success ? 0x22c55e : 0xef4444, + footer: { text: `herdctl · ${agentName}` }, + }, + ], + ephemeral: true, + }); + }, +}; diff --git a/packages/discord/src/commands/command-manager.ts b/packages/discord/src/commands/command-manager.ts index e3cccbd8..5a4a6fd0 100644 --- a/packages/discord/src/commands/command-manager.ts +++ b/packages/discord/src/commands/command-manager.ts @@ -8,6 +8,7 @@ import type { IChatSessionManager } from "@herdctl/chat"; import { createLogger } from "@herdctl/core"; import { + type AutocompleteInteraction, type ChatInputCommandInteraction, type Client, REST, @@ -16,13 +17,19 @@ import { } from "discord.js"; import { ErrorHandler, withRetry } from "../error-handler.js"; import type { DiscordConnectorState } from "../types.js"; +import { cancelCommand } from "./cancel.js"; +import { configCommand } from "./config.js"; import { helpCommand } from "./help.js"; import { newCommand } from "./new.js"; +import { pingCommand } from "./ping.js"; import { resetCommand } from "./reset.js"; import { retryCommand } from "./retry.js"; import { sessionCommand } from "./session.js"; +import { skillCommand } from "./skill.js"; +import { skillsCommand } from "./skills.js"; import { statusCommand } from "./status.js"; import { stopCommand } from "./stop.js"; +import { toolsCommand } from "./tools.js"; import type { CommandActions, CommandContext, @@ -31,6 +38,7 @@ import type { ICommandManager, SlashCommand, } from "./types.js"; +import { usageCommand } from "./usage.js"; // ============================================================================= // Default Logger @@ -50,11 +58,18 @@ function createDefaultLogger(agentName: string): CommandManagerLogger { function getBuiltInCommands(): SlashCommand[] { return [ helpCommand, + pingCommand, + configCommand, + toolsCommand, + usageCommand, + skillsCommand, + skillCommand, statusCommand, sessionCommand, resetCommand, newCommand, stopCommand, + cancelCommand, retryCommand, ]; } @@ -136,9 +151,11 @@ export class CommandManager implements ICommandManager { const rest = new REST({ version: "10" }).setToken(this.botToken); // Build command data for Discord API - const commandData = Array.from(this.commands.values()).map((cmd) => - new SlashCommandBuilder().setName(cmd.name).setDescription(cmd.description).toJSON(), - ); + const commandData = Array.from(this.commands.values()).map((cmd) => { + const base = new SlashCommandBuilder().setName(cmd.name).setDescription(cmd.description); + const built = cmd.build ? cmd.build(base) : base; + return built.toJSON(); + }); const clientId = this.client.user?.id; if (!clientId) { @@ -240,6 +257,32 @@ export class CommandManager implements ICommandManager { } } + async handleAutocomplete(interaction: AutocompleteInteraction): Promise { + const commandName = interaction.commandName; + const command = this.commands.get(commandName); + if (!command?.autocomplete) { + await interaction.respond([]); + return; + } + + const context: Omit = { + client: this.client, + agentName: this.agentName, + sessionManager: this.sessionManager, + connectorState: this.getConnectorState(), + commandActions: this.commandActions, + }; + + try { + await command.autocomplete(interaction, context); + } catch (error) { + this.errorHandler.handleError(error, `autocomplete for command '${commandName}'`); + if (!interaction.responded) { + await interaction.respond([]); + } + } + } + /** * Get all registered commands */ diff --git a/packages/discord/src/commands/config.ts b/packages/discord/src/commands/config.ts new file mode 100644 index 00000000..97c0d514 --- /dev/null +++ b/packages/discord/src/commands/config.ts @@ -0,0 +1,36 @@ +import type { CommandContext, SlashCommand } from "./types.js"; + +export const configCommand: SlashCommand = { + name: "config", + description: "Show runtime-relevant agent configuration", + + async execute(context: CommandContext): Promise { + const { interaction, commandActions, agentName } = context; + const config = commandActions?.getAgentConfig ? await commandActions.getAgentConfig() : null; + if (!config) { + await interaction.reply({ + content: "Config details are not available in this deployment.", + ephemeral: true, + }); + return; + } + + const lines = [ + `**Runtime:** ${config.runtime ?? "default"}`, + `**Model:** ${config.model ?? "default"}`, + `**Permission Mode:** ${config.permissionMode ?? "default"}`, + `**Working Dir:** ${config.workingDirectory ?? "not set"}`, + `**MCP Servers:** ${config.mcpServers?.length ? config.mcpServers.join(", ") : "none"}`, + ]; + await interaction.reply({ + embeds: [ + { + description: lines.join("\n"), + color: 0x3b82f6, + footer: { text: `herdctl · ${agentName}` }, + }, + ], + ephemeral: true, + }); + }, +}; diff --git a/packages/discord/src/commands/help.ts b/packages/discord/src/commands/help.ts index 45403e2a..41f71414 100644 --- a/packages/discord/src/commands/help.ts +++ b/packages/discord/src/commands/help.ts @@ -18,11 +18,18 @@ export const helpCommand: SlashCommand = { { description: [ "**/help** \u2014 Show this help message", + "**/ping** \u2014 Quick connector health check", + "**/config** \u2014 Show runtime-relevant agent configuration", + "**/tools** \u2014 Show allowed/denied tools and MCP servers", + "**/usage** \u2014 Show latest run usage for this channel", + "**/skills** \u2014 List discovered skills", + "**/skill** \u2014 Trigger a skill with optional input", "**/status** \u2014 Show agent status and session info", "**/reset** \u2014 Clear conversation context", "**/new** \u2014 Start a fresh conversation", "**/session** \u2014 Show current session and run state", "**/stop** \u2014 Stop the active run in this channel", + "**/cancel** \u2014 Alias for /stop", "**/retry** \u2014 Retry the last prompt in this channel", "", "**Usage**", diff --git a/packages/discord/src/commands/index.ts b/packages/discord/src/commands/index.ts index 58322dc6..f60f225b 100644 --- a/packages/discord/src/commands/index.ts +++ b/packages/discord/src/commands/index.ts @@ -5,16 +5,22 @@ * for controlling the bot via Discord slash commands. */ +// Built-in Commands +export { cancelCommand } from "./cancel.js"; // Command Manager export { CommandManager } from "./command-manager.js"; -// Built-in Commands +export { configCommand } from "./config.js"; export { helpCommand } from "./help.js"; export { newCommand } from "./new.js"; +export { pingCommand } from "./ping.js"; export { resetCommand } from "./reset.js"; export { retryCommand } from "./retry.js"; export { sessionCommand } from "./session.js"; +export { skillCommand } from "./skill.js"; +export { skillsCommand } from "./skills.js"; export { statusCommand } from "./status.js"; export { stopCommand } from "./stop.js"; +export { toolsCommand } from "./tools.js"; // Types export type { CommandActionResult, @@ -25,3 +31,4 @@ export type { ICommandManager, SlashCommand, } from "./types.js"; +export { usageCommand } from "./usage.js"; diff --git a/packages/discord/src/commands/ping.ts b/packages/discord/src/commands/ping.ts new file mode 100644 index 00000000..12567c37 --- /dev/null +++ b/packages/discord/src/commands/ping.ts @@ -0,0 +1,24 @@ +import type { CommandContext, SlashCommand } from "./types.js"; + +export const pingCommand: SlashCommand = { + name: "ping", + description: "Quick health check", + + async execute(context: CommandContext): Promise { + const { interaction, connectorState, agentName } = context; + await interaction.reply({ + embeds: [ + { + description: [ + "**Status:** online", + `**Connector:** ${connectorState.status}`, + `**Agent:** ${agentName}`, + ].join("\n"), + color: 0x22c55e, + footer: { text: `herdctl · ${agentName}` }, + }, + ], + ephemeral: true, + }); + }, +}; diff --git a/packages/discord/src/commands/skill.ts b/packages/discord/src/commands/skill.ts new file mode 100644 index 00000000..8245c7e4 --- /dev/null +++ b/packages/discord/src/commands/skill.ts @@ -0,0 +1,57 @@ +import type { AutocompleteInteraction, SlashCommandBuilder } from "discord.js"; +import type { CommandContext, SlashCommand } from "./types.js"; + +function buildSkillCommand(builder: SlashCommandBuilder): SlashCommandBuilder { + builder.addStringOption((option) => + option.setName("name").setDescription("Skill name").setRequired(true).setAutocomplete(true), + ); + builder.addStringOption((option) => + option.setName("input").setDescription("Optional input for the skill").setRequired(false), + ); + return builder; +} + +async function autocompleteSkill( + interaction: AutocompleteInteraction, + context: Omit, +): Promise { + const focused = interaction.options.getFocused().toLowerCase(); + const skills = context.commandActions?.listSkills + ? await context.commandActions.listSkills() + : []; + const choices = skills + .filter((skill) => skill.name.toLowerCase().includes(focused)) + .slice(0, 25) + .map((skill) => ({ + name: skill.description ? `${skill.name} — ${skill.description}`.slice(0, 100) : skill.name, + value: skill.name, + })); + await interaction.respond(choices); +} + +export const skillCommand: SlashCommand = { + name: "skill", + description: "Trigger a skill in this channel", + build: buildSkillCommand, + autocomplete: autocompleteSkill, + + async execute(context: CommandContext): Promise { + const { interaction, commandActions, agentName } = context; + const skillName = interaction.options.getString("name", true); + const input = interaction.options.getString("input", false) ?? undefined; + const result = commandActions?.runSkill + ? await commandActions.runSkill(interaction.channelId, skillName, input) + : { success: false, message: "Skill execution is not available in this deployment." }; + + await interaction.reply({ + embeds: [ + { + description: result.message, + color: result.success ? 0x22c55e : 0xef4444, + footer: { text: `herdctl · ${agentName}` }, + }, + ], + ephemeral: true, + }); + }, +}; diff --git a/packages/discord/src/commands/skills.ts b/packages/discord/src/commands/skills.ts new file mode 100644 index 00000000..0a8b8ed6 --- /dev/null +++ b/packages/discord/src/commands/skills.ts @@ -0,0 +1,35 @@ +import type { CommandContext, SlashCommand } from "./types.js"; + +export const skillsCommand: SlashCommand = { + name: "skills", + description: "List discovered skills for this agent", + + async execute(context: CommandContext): Promise { + const { interaction, commandActions, agentName } = context; + const skills = commandActions?.listSkills ? await commandActions.listSkills() : []; + + if (skills.length === 0) { + await interaction.reply({ + content: "No skills were discovered for this agent.", + ephemeral: true, + }); + return; + } + + const lines = skills.slice(0, 25).map((skill) => { + const detail = skill.description ? ` — ${skill.description}` : ""; + return `• \`${skill.name}\`${detail}`; + }); + + await interaction.reply({ + embeds: [ + { + description: [`Discovered **${skills.length}** skill(s):`, "", ...lines].join("\n"), + color: 0x3b82f6, + footer: { text: `herdctl · ${agentName}` }, + }, + ], + ephemeral: true, + }); + }, +}; diff --git a/packages/discord/src/commands/tools.ts b/packages/discord/src/commands/tools.ts new file mode 100644 index 00000000..178e6317 --- /dev/null +++ b/packages/discord/src/commands/tools.ts @@ -0,0 +1,33 @@ +import type { CommandContext, SlashCommand } from "./types.js"; + +export const toolsCommand: SlashCommand = { + name: "tools", + description: "Show allowed/denied tools and MCP integration status", + + async execute(context: CommandContext): Promise { + const { interaction, commandActions, agentName } = context; + const config = commandActions?.getAgentConfig ? await commandActions.getAgentConfig() : null; + if (!config) { + await interaction.reply({ + content: "Tool details are not available in this deployment.", + ephemeral: true, + }); + return; + } + + await interaction.reply({ + embeds: [ + { + description: [ + `**Allowed:** ${config.allowedTools?.length ? config.allowedTools.join(", ") : "all defaults"}`, + `**Denied:** ${config.deniedTools?.length ? config.deniedTools.join(", ") : "none"}`, + `**MCP Servers:** ${config.mcpServers?.length ? config.mcpServers.join(", ") : "none"}`, + ].join("\n"), + color: 0x3b82f6, + footer: { text: `herdctl · ${agentName}` }, + }, + ], + ephemeral: true, + }); + }, +}; diff --git a/packages/discord/src/commands/types.ts b/packages/discord/src/commands/types.ts index 3a529c2a..67fb267b 100644 --- a/packages/discord/src/commands/types.ts +++ b/packages/discord/src/commands/types.ts @@ -6,7 +6,12 @@ */ import type { IChatSessionManager } from "@herdctl/chat"; -import type { ChatInputCommandInteraction, Client } from "discord.js"; +import type { + AutocompleteInteraction, + ChatInputCommandInteraction, + Client, + SlashCommandBuilder, +} from "discord.js"; import type { DiscordConnectorState } from "../types.js"; export interface CommandActionResult { @@ -18,6 +23,26 @@ export interface CommandActionResult { export interface CommandActions { stopRun?: (channelId: string) => Promise; retryRun?: (channelId: string) => Promise; + runSkill?: (channelId: string, skillName: string, input?: string) => Promise; + listSkills?: () => Promise>; + getUsage?: (channelId: string) => Promise<{ + timestamp: string; + numTurns?: number; + durationMs?: number; + totalCostUsd?: number; + inputTokens?: number; + outputTokens?: number; + isError?: boolean; + } | null>; + getAgentConfig?: () => Promise<{ + runtime?: string; + model?: string; + permissionMode?: string; + workingDirectory?: string; + allowedTools?: string[]; + deniedTools?: string[]; + mcpServers?: string[]; + }>; getSessionInfo?: (channelId: string) => Promise<{ activeJobId?: string; lastPrompt?: string; @@ -65,12 +90,25 @@ export interface SlashCommand { /** Command description shown in Discord's command picker */ description: string; + /** + * Optional slash-command builder customizations (options, autocomplete flags, etc.) + */ + build?: (builder: SlashCommandBuilder) => SlashCommandBuilder; + /** * Execute the command * * @param context - Command execution context */ execute(context: CommandContext): Promise; + + /** + * Optional autocomplete handler for command options + */ + autocomplete?: ( + interaction: AutocompleteInteraction, + context: Omit, + ) => Promise; } // ============================================================================= @@ -141,6 +179,11 @@ export interface ICommandManager { */ handleInteraction(interaction: ChatInputCommandInteraction): Promise; + /** + * Handle an autocomplete interaction for slash command option suggestions. + */ + handleAutocomplete(interaction: AutocompleteInteraction): Promise; + /** * Get all registered commands */ diff --git a/packages/discord/src/commands/usage.ts b/packages/discord/src/commands/usage.ts new file mode 100644 index 00000000..d4e7c620 --- /dev/null +++ b/packages/discord/src/commands/usage.ts @@ -0,0 +1,42 @@ +import { formatCompactNumber } from "@herdctl/chat"; +import type { CommandContext, SlashCommand } from "./types.js"; + +export const usageCommand: SlashCommand = { + name: "usage", + description: "Show usage summary for the most recent run in this channel", + + async execute(context: CommandContext): Promise { + const { interaction, commandActions, agentName } = context; + const usage = commandActions?.getUsage + ? await commandActions.getUsage(interaction.channelId) + : null; + if (!usage) { + await interaction.reply({ + content: "No usage data is available yet for this channel.", + ephemeral: true, + }); + return; + } + + const totalTokens = (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0); + const lines = [ + `**Status:** ${usage.isError ? "failed" : "success"}`, + `**Turns:** ${usage.numTurns ?? "n/a"}`, + `**Duration:** ${usage.durationMs !== undefined ? `${usage.durationMs}ms` : "n/a"}`, + `**Cost:** ${usage.totalCostUsd !== undefined ? `$${usage.totalCostUsd.toFixed(4)}` : "n/a"}`, + `**Tokens:** ${totalTokens > 0 ? formatCompactNumber(totalTokens) : "n/a"} (in ${usage.inputTokens ?? 0}, out ${usage.outputTokens ?? 0})`, + ]; + + await interaction.reply({ + embeds: [ + { + description: lines.join("\n"), + color: usage.isError ? 0xef4444 : 0x22c55e, + footer: { text: `herdctl · ${agentName}` }, + timestamp: usage.timestamp, + }, + ], + ephemeral: true, + }); + }, +}; diff --git a/packages/discord/src/discord-connector.ts b/packages/discord/src/discord-connector.ts index 4b772f99..2a521ba1 100644 --- a/packages/discord/src/discord-connector.ts +++ b/packages/discord/src/discord-connector.ts @@ -541,17 +541,26 @@ export class DiscordConnector extends EventEmitter implements IDiscordConnector * Handle an incoming interaction (slash command) */ private async _handleInteraction(interaction: Interaction): Promise { - // Only handle chat input (slash) commands - if (!interaction.isChatInputCommand()) { + // Only handle slash command chat input + autocomplete interactions. + if (!interaction.isChatInputCommand() && !interaction.isAutocomplete()) { return; } if (!this._commandManager) { this._logger.warn("Received command but command manager not initialized"); - await interaction.reply({ - content: "Commands are not available at this time.", - ephemeral: true, - }); + if (interaction.isChatInputCommand()) { + await interaction.reply({ + content: "Commands are not available at this time.", + ephemeral: true, + }); + } else if (interaction.isAutocomplete()) { + await interaction.respond([]); + } + return; + } + + if (interaction.isAutocomplete()) { + await this._commandManager.handleAutocomplete(interaction); return; } diff --git a/packages/discord/src/index.ts b/packages/discord/src/index.ts index 8b1ce917..5fb4ed29 100644 --- a/packages/discord/src/index.ts +++ b/packages/discord/src/index.ts @@ -34,13 +34,20 @@ export type { // Commands export { CommandManager, + cancelCommand, + configCommand, helpCommand, newCommand, + pingCommand, resetCommand, retryCommand, sessionCommand, + skillCommand, + skillsCommand, statusCommand, stopCommand, + toolsCommand, + usageCommand, } from "./commands/index.js"; // Main connector class export { DiscordConnector } from "./discord-connector.js"; diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index a3efce14..e30de530 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -11,8 +11,9 @@ */ import { randomUUID } from "node:crypto"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { basename, dirname, join } from "node:path"; +import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; import { type ChatConnectorLogger, @@ -97,6 +98,18 @@ export class DiscordManager implements IChatManager { private connectors: Map = new Map(); private activeJobsByChannel: Map = new Map(); private lastPromptByChannel: Map = new Map(); + private lastUsageByChannel: Map< + string, + { + timestamp: string; + numTurns?: number; + durationMs?: number; + totalCostUsd?: number; + inputTokens?: number; + outputTokens?: number; + isError?: boolean; + } + > = new Map(); private initialized: boolean = false; constructor(private ctx: FleetManagerContext) {} @@ -191,6 +204,12 @@ export class DiscordManager implements IChatManager { commandActions: { stopRun: (channelId: string) => this.stopChannelRun(agent.qualifiedName, channelId), retryRun: (channelId: string) => this.retryChannelRun(agent.qualifiedName, channelId), + runSkill: (channelId: string, skillName: string, input?: string) => + this.runChannelSkill(agent.qualifiedName, channelId, skillName, input), + listSkills: async () => this.discoverAgentSkills(agent), + getUsage: async (channelId: string) => + this.getChannelUsage(agent.qualifiedName, channelId), + getAgentConfig: async () => this.getAgentConfigSummary(agent), getSessionInfo: async (channelId: string) => this.getChannelRunInfo(agent.qualifiedName, channelId), }, @@ -908,6 +927,18 @@ export class DiscordManager implements IChatManager { if (normalized.resultText) { resultText = normalized.resultText; } + this.lastUsageByChannel.set( + this.getChannelKey(qualifiedName, event.metadata.channelId), + { + timestamp: new Date().toISOString(), + numTurns: normalized.numTurns, + durationMs: normalized.durationMs, + totalCostUsd: normalized.totalCostUsd, + inputTokens: normalized.usage?.input_tokens, + outputTokens: normalized.usage?.output_tokens, + isError: normalized.isError, + }, + ); latestStatusText = normalized.isError ? "Task failed" : "Task complete"; await refreshRunCard(normalized.isError ? "error" : "success"); @@ -1179,17 +1210,7 @@ export class DiscordManager implements IChatManager { qualifiedName: string, channelId: string, ): Promise<{ success: boolean; message: string; jobId?: string }> { - const logger = this.ctx.getLogger(); const key = this.getChannelKey(qualifiedName, channelId); - const activeJobId = this.activeJobsByChannel.get(key); - if (activeJobId) { - return { - success: false, - message: `A run is already active in this channel (\`${activeJobId}\`).`, - jobId: activeJobId, - }; - } - const lastPrompt = this.lastPromptByChannel.get(key); if (!lastPrompt) { return { @@ -1197,60 +1218,7 @@ export class DiscordManager implements IChatManager { message: "No previous prompt found to retry in this channel.", }; } - - const connector = this.connectors.get(qualifiedName); - if (!connector?.client?.isReady()) { - return { - success: false, - message: "Retry is unavailable because the Discord connector is not connected.", - }; - } - - const channel = await connector.client.channels.fetch(channelId); - if ( - !channel || - !("isTextBased" in channel) || - typeof channel.isTextBased !== "function" || - !channel.isTextBased() || - !("send" in channel) || - typeof channel.send !== "function" - ) { - return { - success: false, - message: "Retry failed because this channel is not text-capable.", - }; - } - - const textChannel = this.toTextChannel(channel); - const retryEvent = this.createSyntheticMessageEvent({ - qualifiedName, - prompt: lastPrompt, - channelId, - channel, - textChannel, - logger, - }); - - // Execute retry through the same Discord message pipeline so users get - // the normal streamed answer/tool/status outputs in the channel. - void this.handleMessage(qualifiedName, retryEvent) - .catch(async (error: unknown) => { - const err = error instanceof Error ? error : new Error(String(error)); - logger.error(`Background retry failed for '${qualifiedName}': ${err.message}`); - try { - await textChannel.send(this.formatErrorMessage(err, qualifiedName)); - } catch (replyError) { - logger.error(`Failed to send retry failure message: ${(replyError as Error).message}`); - } - }) - .finally(() => { - this.activeJobsByChannel.delete(key); - }); - - return { - success: true, - message: "Retry started. I will post the retried run output in this channel.", - }; + return this.runChannelPrompt(qualifiedName, channelId, lastPrompt); } // =========================================================================== @@ -1302,11 +1270,18 @@ export class DiscordManager implements IChatManager { }, replyWithRef: async ( content: string | DiscordReplyPayload, - ): Promise<{ edit: (c: string | DiscordReplyPayload) => Promise; delete: () => Promise }> => { + ): Promise<{ + edit: (c: string | DiscordReplyPayload) => Promise; + delete: () => Promise; + }> => { const sent = await textChannel.send(content); return { - edit: async (c: string | DiscordReplyPayload) => { await sent.edit(c); }, - delete: async () => { await sent.delete(); }, + edit: async (c: string | DiscordReplyPayload) => { + await sent.edit(c); + }, + delete: async () => { + await sent.delete(); + }, }; }, startTyping: (): (() => void) => { @@ -1344,6 +1319,187 @@ export class DiscordManager implements IChatManager { }; } + private async getChannelUsage( + qualifiedName: string, + channelId: string, + ): Promise<{ + timestamp: string; + numTurns?: number; + durationMs?: number; + totalCostUsd?: number; + inputTokens?: number; + outputTokens?: number; + isError?: boolean; + } | null> { + return this.lastUsageByChannel.get(this.getChannelKey(qualifiedName, channelId)) ?? null; + } + + private async runChannelSkill( + qualifiedName: string, + channelId: string, + skillName: string, + input?: string, + ): Promise<{ success: boolean; message: string; jobId?: string }> { + const instruction = [ + `Use the "${skillName}" skill to handle this request.`, + "", + input?.trim() ? input.trim() : "No additional input was provided.", + ].join("\n"); + const result = await this.runChannelPrompt(qualifiedName, channelId, instruction); + if (result.success) { + return { + ...result, + message: `Skill \`${skillName}\` started. Output will be posted in this channel.`, + }; + } + return result; + } + + private async runChannelPrompt( + qualifiedName: string, + channelId: string, + prompt: string, + ): Promise<{ success: boolean; message: string; jobId?: string }> { + const logger = this.ctx.getLogger(); + const key = this.getChannelKey(qualifiedName, channelId); + const activeJobId = this.activeJobsByChannel.get(key); + if (activeJobId) { + return { + success: false, + message: `A run is already active in this channel (\`${activeJobId}\`).`, + jobId: activeJobId, + }; + } + + const connector = this.connectors.get(qualifiedName); + if (!connector?.client?.isReady()) { + return { + success: false, + message: "Run cannot start because the Discord connector is not connected.", + }; + } + + const channel = await connector.client.channels.fetch(channelId); + if ( + !channel || + !("isTextBased" in channel) || + typeof channel.isTextBased !== "function" || + !channel.isTextBased() || + !("send" in channel) || + typeof channel.send !== "function" + ) { + return { + success: false, + message: "Run failed because this channel is not text-capable.", + }; + } + + const textChannel = this.toTextChannel(channel); + const syntheticEvent = this.createSyntheticMessageEvent({ + qualifiedName, + prompt, + channelId, + channel, + textChannel, + logger, + }); + + this.lastPromptByChannel.set(key, prompt); + void this.handleMessage(qualifiedName, syntheticEvent) + .catch(async (error: unknown) => { + const err = error instanceof Error ? error : new Error(String(error)); + logger.error(`Background slash run failed for '${qualifiedName}': ${err.message}`); + try { + await textChannel.send(this.formatErrorMessage(err, qualifiedName)); + } catch (replyError) { + logger.error(`Failed to send slash failure message: ${(replyError as Error).message}`); + } + }) + .finally(() => { + this.activeJobsByChannel.delete(key); + }); + + return { + success: true, + message: "Run started. Output will be posted in this channel.", + }; + } + + private async discoverAgentSkills( + agent: ResolvedAgent, + ): Promise> { + const roots: string[] = []; + if (agent.working_directory) { + roots.push( + typeof agent.working_directory === "string" + ? agent.working_directory + : agent.working_directory.root, + ); + } + roots.push(process.cwd()); + + const codexHome = process.env.CODEX_HOME; + if (codexHome) { + roots.push(codexHome); + } else { + roots.push(join(homedir(), ".codex")); + } + + const candidateDirs = roots.flatMap((root) => [ + join(root, ".codex", "skills"), + join(root, "skills"), + ]); + const discovered = new Map(); + + for (const dir of candidateDirs) { + try { + const entries = await readdir(resolve(dir), { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillName = entry.name; + const skillPath = join(dir, skillName, "SKILL.md"); + try { + const raw = await readFile(skillPath, "utf8"); + const lines = raw.split("\n").map((line) => line.trim()); + const description = + lines.find((line) => line.length > 0 && !line.startsWith("#"))?.slice(0, 120) ?? + undefined; + discovered.set(skillName, { name: skillName, description }); + } catch { + // Ignore directories without readable SKILL.md + } + } + } catch { + // Directory does not exist or is unreadable; skip. + } + } + + return Array.from(discovered.values()).sort((a, b) => a.name.localeCompare(b.name)); + } + + private async getAgentConfigSummary(agent: ResolvedAgent): Promise<{ + runtime?: string; + model?: string; + permissionMode?: string; + workingDirectory?: string; + allowedTools?: string[]; + deniedTools?: string[]; + mcpServers?: string[]; + }> { + return { + runtime: agent.runtime, + model: agent.model, + permissionMode: agent.permission_mode, + workingDirectory: + typeof agent.working_directory === "string" + ? agent.working_directory + : agent.working_directory?.root, + allowedTools: agent.allowed_tools, + deniedTools: agent.denied_tools, + mcpServers: agent.mcp_servers ? Object.keys(agent.mcp_servers) : [], + }; + } + // =========================================================================== // Response Formatting and Splitting // =========================================================================== From 4261b175552bb8859eccdc6731e4f13d40560a09 Mon Sep 17 00:00:00 2001 From: agent Date: Wed, 4 Mar 2026 22:00:25 -0800 Subject: [PATCH 31/48] Constrain Discord skill discovery to agent scope and validate /skill --- .../core/src/config/__tests__/schema.test.ts | 25 ++++++ packages/core/src/config/schema.ts | 12 +++ .../discord/src/__tests__/manager.test.ts | 76 ++++++++++++++++ packages/discord/src/commands/types.ts | 33 +++++-- packages/discord/src/manager.ts | 88 ++++++++++++++----- 5 files changed, 204 insertions(+), 30 deletions(-) diff --git a/packages/core/src/config/__tests__/schema.test.ts b/packages/core/src/config/__tests__/schema.test.ts index d1e7b52e..9c9c4aa2 100644 --- a/packages/core/src/config/__tests__/schema.test.ts +++ b/packages/core/src/config/__tests__/schema.test.ts @@ -1775,6 +1775,31 @@ describe("AgentChatDiscordSchema", () => { }); expect(result.success).toBe(false); }); + + it("accepts explicit discord skills list", () => { + const result = AgentChatDiscordSchema.safeParse({ + bot_token_env: "TOKEN", + guilds: [{ id: "123", channels: [{ id: "456" }] }], + skills: [ + { name: "pdf", description: "Work with PDF files" }, + { name: "cloudflare-deploy" }, + ], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.skills).toHaveLength(2); + expect(result.data.skills?.[0]?.name).toBe("pdf"); + } + }); + + it("rejects discord skills with empty name", () => { + const result = AgentChatDiscordSchema.safeParse({ + bot_token_env: "TOKEN", + guilds: [{ id: "123", channels: [{ id: "456" }] }], + skills: [{ name: "" }], + }); + expect(result.success).toBe(false); + }); }); describe("AgentChatSchema", () => { diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index f5046c15..711f1cb8 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -661,6 +661,16 @@ export const DiscordCommandRegistrationSchema = z path: ["guild_id"], }); +/** + * Explicit Discord skill entry used by /skills and /skill command UX. + */ +export const DiscordSkillSchema = z.object({ + /** Skill name as shown in autocomplete and passed to /skill */ + name: z.string().min(1), + /** Optional user-facing description */ + description: z.string().optional(), +}); + /** * Discord voice message transcription configuration * @@ -783,6 +793,8 @@ export const AgentChatDiscordSchema = z.object({ attachments: DiscordAttachmentsSchema.optional(), /** Slash command registration mode */ command_registration: DiscordCommandRegistrationSchema.optional(), + /** Explicit skill list for slash command discovery (recommended in containerized deployments) */ + skills: z.array(DiscordSkillSchema).optional(), }); // ============================================================================= diff --git a/packages/discord/src/__tests__/manager.test.ts b/packages/discord/src/__tests__/manager.test.ts index 01fc9f23..27526cc7 100644 --- a/packages/discord/src/__tests__/manager.test.ts +++ b/packages/discord/src/__tests__/manager.test.ts @@ -338,6 +338,82 @@ describe("DiscordManager", () => { }); }); + describe("skill discovery and validation", () => { + it("uses explicit chat.discord.skills when working_directory is not set", async () => { + const discordConfig: AgentChatDiscord = { + bot_token_env: "TEST_TOKEN", + session_expiry_hours: 24, + log_level: "standard", + output: { + tool_results: true, + tool_result_max_length: 900, + system_status: true, + result_summary: true, + typing_indicator: true, + errors: true, + acknowledge_emoji: "eyes", + assistant_messages: "answers" as const, + progress_indicator: true, + }, + guilds: [{ id: "g1" }], + skills: [{ name: "pdf", description: "Work with PDFs" }], + } as unknown as AgentChatDiscord; + const config: ResolvedConfig = { + fleet: { name: "test-fleet" } as unknown as ResolvedConfig["fleet"], + agents: [createDiscordAgent("agent1", discordConfig)], + configPath: "/test/herdctl.yaml", + configDir: "/test", + }; + const manager = new DiscordManager(createMockContext(config)); + const managerAny = manager as unknown as { + discoverAgentSkills: (agent: ResolvedAgent) => Promise>; + }; + + const skills = await managerAny.discoverAgentSkills(config.agents[0]); + expect(skills.map((s) => s.name)).toContain("pdf"); + }); + + it("rejects unknown /skill before attempting execution", async () => { + const discordConfig: AgentChatDiscord = { + bot_token_env: "TEST_TOKEN", + session_expiry_hours: 24, + log_level: "standard", + output: { + tool_results: true, + tool_result_max_length: 900, + system_status: true, + result_summary: true, + typing_indicator: true, + errors: true, + acknowledge_emoji: "eyes", + assistant_messages: "answers" as const, + progress_indicator: true, + }, + guilds: [{ id: "g1" }], + skills: [{ name: "pdf" }], + } as unknown as AgentChatDiscord; + const config: ResolvedConfig = { + fleet: { name: "test-fleet" } as unknown as ResolvedConfig["fleet"], + agents: [createDiscordAgent("agent1", discordConfig)], + configPath: "/test/herdctl.yaml", + configDir: "/test", + }; + const manager = new DiscordManager(createMockContext(config)); + const managerAny = manager as unknown as { + runChannelSkill: ( + qualifiedName: string, + channelId: string, + skillName: string, + input?: string, + ) => Promise<{ success: boolean; message: string }>; + }; + + const result = await managerAny.runChannelSkill("agent1", "channel-1", "nonexistent"); + expect(result.success).toBe(false); + expect(result.message).toContain("Unknown skill"); + }); + }); + describe("getConnector", () => { it("returns undefined for non-existent agent", () => { const ctx = createMockContext(null); diff --git a/packages/discord/src/commands/types.ts b/packages/discord/src/commands/types.ts index 67fb267b..e563a089 100644 --- a/packages/discord/src/commands/types.ts +++ b/packages/discord/src/commands/types.ts @@ -20,20 +20,35 @@ export interface CommandActionResult { jobId?: string; } +export interface ChannelRunUsage { + timestamp: string; + numTurns?: number; + durationMs?: number; + totalCostUsd?: number; + inputTokens?: number; + outputTokens?: number; + isError?: boolean; +} + +export interface CumulativeUsage { + totalRuns: number; + totalSuccesses: number; + totalFailures: number; + totalCostUsd: number; + totalInputTokens: number; + totalOutputTokens: number; + totalDurationMs: number; + firstRunAt: string; + lastRunAt: string; +} + export interface CommandActions { stopRun?: (channelId: string) => Promise; retryRun?: (channelId: string) => Promise; runSkill?: (channelId: string, skillName: string, input?: string) => Promise; listSkills?: () => Promise>; - getUsage?: (channelId: string) => Promise<{ - timestamp: string; - numTurns?: number; - durationMs?: number; - totalCostUsd?: number; - inputTokens?: number; - outputTokens?: number; - isError?: boolean; - } | null>; + getUsage?: (channelId: string) => Promise; + getCumulativeUsage?: () => Promise; getAgentConfig?: () => Promise<{ runtime?: string; model?: string; diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index e30de530..dbe8d225 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -12,7 +12,6 @@ import { randomUUID } from "node:crypto"; import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { @@ -37,6 +36,7 @@ import { TOOL_EMOJIS, } from "@herdctl/core"; +import type { ChannelRunUsage, CumulativeUsage } from "./commands/types.js"; import { DiscordConnector } from "./discord-connector.js"; import { buildErrorEmbed, @@ -1340,8 +1340,33 @@ export class DiscordManager implements IChatManager { skillName: string, input?: string, ): Promise<{ success: boolean; message: string; jobId?: string }> { + const agent = this.getAgentByQualifiedName(qualifiedName); + if (!agent) { + return { + success: false, + message: `Agent '${qualifiedName}' is not available.`, + }; + } + + const availableSkills = await this.discoverAgentSkills(agent); + if (availableSkills.length === 0) { + return { + success: false, + message: + "No skills are configured/discovered for this agent. Configure `chat.discord.skills` or ensure skills exist in the agent working directory.", + }; + } + + const matchedSkill = availableSkills.find((s) => s.name === skillName); + if (!matchedSkill) { + return { + success: false, + message: `Unknown skill \`${skillName}\`. Use \`/skills\` to see available skills.`, + }; + } + const instruction = [ - `Use the "${skillName}" skill to handle this request.`, + `Use the "${matchedSkill.name}" skill to handle this request.`, "", input?.trim() ? input.trim() : "No additional input was provided.", ].join("\n"); @@ -1349,7 +1374,7 @@ export class DiscordManager implements IChatManager { if (result.success) { return { ...result, - message: `Skill \`${skillName}\` started. Output will be posted in this channel.`, + message: `Skill \`${matchedSkill.name}\` started. Output will be posted in this channel.`, }; } return result; @@ -1428,28 +1453,24 @@ export class DiscordManager implements IChatManager { private async discoverAgentSkills( agent: ResolvedAgent, ): Promise> { - const roots: string[] = []; - if (agent.working_directory) { - roots.push( - typeof agent.working_directory === "string" - ? agent.working_directory - : agent.working_directory.root, - ); + const discovered = new Map(); + + // Step 1: explicit configured skills (deterministic source of truth). + for (const skill of this.getConfiguredDiscordSkills(agent)) { + if (skill.name.trim().length === 0) continue; + discovered.set(skill.name, { name: skill.name, description: skill.description }); } - roots.push(process.cwd()); - const codexHome = process.env.CODEX_HOME; - if (codexHome) { - roots.push(codexHome); - } else { - roots.push(join(homedir(), ".codex")); + // Step 2: auto-discover only from agent working directory paths. + const workingDir = + typeof agent.working_directory === "string" + ? agent.working_directory + : agent.working_directory?.root; + if (!workingDir) { + return Array.from(discovered.values()).sort((a, b) => a.name.localeCompare(b.name)); } - const candidateDirs = roots.flatMap((root) => [ - join(root, ".codex", "skills"), - join(root, "skills"), - ]); - const discovered = new Map(); + const candidateDirs = [join(workingDir, ".codex", "skills"), join(workingDir, "skills")]; for (const dir of candidateDirs) { try { @@ -1477,6 +1498,31 @@ export class DiscordManager implements IChatManager { return Array.from(discovered.values()).sort((a, b) => a.name.localeCompare(b.name)); } + private getAgentByQualifiedName(qualifiedName: string): ResolvedAgent | undefined { + const config = this.ctx.getConfig(); + return config?.agents.find((agent) => agent.qualifiedName === qualifiedName); + } + + private getConfiguredDiscordSkills( + agent: ResolvedAgent, + ): Array<{ name: string; description?: string }> { + const skills = ( + agent.chat?.discord as unknown as + | { + skills?: Array<{ name?: string; description?: string }>; + } + | undefined + )?.skills; + if (!Array.isArray(skills)) { + return []; + } + return skills + .filter( + (skill): skill is { name: string; description?: string } => typeof skill?.name === "string", + ) + .map((skill) => ({ name: skill.name, description: skill.description })); + } + private async getAgentConfigSummary(agent: ResolvedAgent): Promise<{ runtime?: string; model?: string; From 9bd527416dd8dce8c16b2866dd51b3f167b9759f Mon Sep 17 00:00:00 2001 From: agent Date: Wed, 4 Mar 2026 22:10:12 -0800 Subject: [PATCH 32/48] feat(discord): add cumulative session totals to /usage command /usage now shows two embeds: last run stats (per-channel) and session totals (per-agent lifetime) including total runs, cost, tokens, and duration. Extracts ChannelRunUsage and CumulativeUsage types to replace inline shapes. Co-Authored-By: Claude Opus 4.6 --- .../src/content/docs/integrations/discord.mdx | 2 +- .../__tests__/extended-commands.test.ts | 45 ++++++++++ packages/discord/src/commands/index.ts | 2 + packages/discord/src/commands/usage.ts | 89 +++++++++++++------ packages/discord/src/index.ts | 2 + packages/discord/src/manager.ts | 89 ++++++++++++++----- 6 files changed, 179 insertions(+), 50 deletions(-) diff --git a/docs/src/content/docs/integrations/discord.mdx b/docs/src/content/docs/integrations/discord.mdx index 83f2eed1..74ad5815 100644 --- a/docs/src/content/docs/integrations/discord.mdx +++ b/docs/src/content/docs/integrations/discord.mdx @@ -580,7 +580,7 @@ herdctl automatically registers slash commands for every Discord-enabled agent: | `/ping` | Quick health check | | `/config` | Show runtime-relevant agent configuration | | `/tools` | Show allowed/denied tools and MCP servers | -| `/usage` | Show latest run usage for this channel | +| `/usage` | Show last run stats and cumulative session totals | | `/skills` | List discovered skills for this agent | | `/skill` | Trigger a skill (with autocomplete) | | `/status` | Show bot connection status and session info | diff --git a/packages/discord/src/commands/__tests__/extended-commands.test.ts b/packages/discord/src/commands/__tests__/extended-commands.test.ts index c11ac64e..eb2f0e84 100644 --- a/packages/discord/src/commands/__tests__/extended-commands.test.ts +++ b/packages/discord/src/commands/__tests__/extended-commands.test.ts @@ -63,6 +63,17 @@ function makeContext(): CommandContext { outputTokens: 30, isError: false, }), + getCumulativeUsage: vi.fn().mockResolvedValue({ + totalRuns: 5, + totalSuccesses: 4, + totalFailures: 1, + totalCostUsd: 0.1234, + totalInputTokens: 5000, + totalOutputTokens: 1200, + totalDurationMs: 45000, + firstRunAt: new Date(Date.now() - 3600000).toISOString(), + lastRunAt: new Date().toISOString(), + }), getAgentConfig: vi.fn().mockResolvedValue({ runtime: "sdk", model: "claude-sonnet-4", @@ -120,4 +131,38 @@ describe("extended commands", () => { await pingCommand.execute(ctx); expect(ctx.interaction.reply).toHaveBeenCalledTimes(4); }); + + it("/usage shows both last run and cumulative embeds", async () => { + const ctx = makeContext(); + await usageCommand.execute(ctx); + const call = (ctx.interaction.reply as ReturnType).mock.calls[0][0]; + expect(call.embeds).toHaveLength(2); + expect(call.embeds[0].title).toBe("Last Run"); + expect(call.embeds[1].title).toBe("Session Totals"); + expect(call.embeds[1].description).toContain("5"); + expect(call.embeds[1].description).toContain("4 ok"); + expect(call.ephemeral).toBe(true); + }); + + it("/usage shows only cumulative when no last run", async () => { + const ctx = makeContext(); + ctx.commandActions!.getUsage = vi.fn().mockResolvedValue(null); + await usageCommand.execute(ctx); + const call = (ctx.interaction.reply as ReturnType).mock.calls[0][0]; + expect(call.embeds).toHaveLength(1); + expect(call.embeds[0].title).toBe("Session Totals"); + }); + + it("/usage shows empty message when no data", async () => { + const ctx = makeContext(); + ctx.commandActions!.getUsage = vi.fn().mockResolvedValue(null); + ctx.commandActions!.getCumulativeUsage = vi.fn().mockResolvedValue({ + totalRuns: 0, totalSuccesses: 0, totalFailures: 0, + totalCostUsd: 0, totalInputTokens: 0, totalOutputTokens: 0, + totalDurationMs: 0, firstRunAt: "", lastRunAt: "", + }); + await usageCommand.execute(ctx); + const call = (ctx.interaction.reply as ReturnType).mock.calls[0][0]; + expect(call.content).toContain("No usage data"); + }); }); diff --git a/packages/discord/src/commands/index.ts b/packages/discord/src/commands/index.ts index f60f225b..80467333 100644 --- a/packages/discord/src/commands/index.ts +++ b/packages/discord/src/commands/index.ts @@ -23,11 +23,13 @@ export { stopCommand } from "./stop.js"; export { toolsCommand } from "./tools.js"; // Types export type { + ChannelRunUsage, CommandActionResult, CommandActions, CommandContext, CommandManagerLogger, CommandManagerOptions, + CumulativeUsage, ICommandManager, SlashCommand, } from "./types.js"; diff --git a/packages/discord/src/commands/usage.ts b/packages/discord/src/commands/usage.ts index d4e7c620..4ea9e4fe 100644 --- a/packages/discord/src/commands/usage.ts +++ b/packages/discord/src/commands/usage.ts @@ -1,42 +1,77 @@ -import { formatCompactNumber } from "@herdctl/chat"; +import { formatCompactNumber, formatCost, formatDurationMs } from "@herdctl/chat"; import type { CommandContext, SlashCommand } from "./types.js"; export const usageCommand: SlashCommand = { name: "usage", - description: "Show usage summary for the most recent run in this channel", + description: "Show usage stats: last run and cumulative totals", async execute(context: CommandContext): Promise { const { interaction, commandActions, agentName } = context; - const usage = commandActions?.getUsage - ? await commandActions.getUsage(interaction.channelId) - : null; - if (!usage) { + + const [lastRun, cumulative] = await Promise.all([ + commandActions?.getUsage + ? commandActions.getUsage(interaction.channelId) + : null, + commandActions?.getCumulativeUsage + ? commandActions.getCumulativeUsage() + : null, + ]); + + if (!lastRun && (!cumulative || cumulative.totalRuns === 0)) { await interaction.reply({ - content: "No usage data is available yet for this channel.", + content: "No usage data is available yet.", ephemeral: true, }); return; } - const totalTokens = (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0); - const lines = [ - `**Status:** ${usage.isError ? "failed" : "success"}`, - `**Turns:** ${usage.numTurns ?? "n/a"}`, - `**Duration:** ${usage.durationMs !== undefined ? `${usage.durationMs}ms` : "n/a"}`, - `**Cost:** ${usage.totalCostUsd !== undefined ? `$${usage.totalCostUsd.toFixed(4)}` : "n/a"}`, - `**Tokens:** ${totalTokens > 0 ? formatCompactNumber(totalTokens) : "n/a"} (in ${usage.inputTokens ?? 0}, out ${usage.outputTokens ?? 0})`, - ]; - - await interaction.reply({ - embeds: [ - { - description: lines.join("\n"), - color: usage.isError ? 0xef4444 : 0x22c55e, - footer: { text: `herdctl · ${agentName}` }, - timestamp: usage.timestamp, - }, - ], - ephemeral: true, - }); + const embeds: Array<{ + title?: string; + description: string; + color: number; + footer?: { text: string }; + timestamp?: string; + }> = []; + + // Last run embed + if (lastRun) { + const totalTokens = (lastRun.inputTokens ?? 0) + (lastRun.outputTokens ?? 0); + const lines = [ + `**Status:** ${lastRun.isError ? "failed" : "success"}`, + `**Turns:** ${lastRun.numTurns ?? "n/a"}`, + `**Duration:** ${lastRun.durationMs !== undefined ? formatDurationMs(lastRun.durationMs) : "n/a"}`, + `**Cost:** ${lastRun.totalCostUsd !== undefined ? formatCost(lastRun.totalCostUsd) : "n/a"}`, + `**Tokens:** ${totalTokens > 0 ? formatCompactNumber(totalTokens) : "n/a"} (in: ${formatCompactNumber(lastRun.inputTokens ?? 0)}, out: ${formatCompactNumber(lastRun.outputTokens ?? 0)})`, + ]; + embeds.push({ + title: "Last Run", + description: lines.join("\n"), + color: lastRun.isError ? 0xef4444 : 0x22c55e, + timestamp: lastRun.timestamp, + }); + } + + // Cumulative embed + if (cumulative && cumulative.totalRuns > 0) { + const totalTokens = cumulative.totalInputTokens + cumulative.totalOutputTokens; + const lines = [ + `**Runs:** ${cumulative.totalRuns} (${cumulative.totalSuccesses} ok, ${cumulative.totalFailures} failed)`, + `**Total Cost:** ${formatCost(cumulative.totalCostUsd)}`, + `**Total Tokens:** ${formatCompactNumber(totalTokens)} (in: ${formatCompactNumber(cumulative.totalInputTokens)}, out: ${formatCompactNumber(cumulative.totalOutputTokens)})`, + `**Total Duration:** ${formatDurationMs(cumulative.totalDurationMs)}`, + `**Since:** `, + ]; + embeds.push({ + title: "Session Totals", + description: lines.join("\n"), + color: 0x326ce5, + }); + } + + // Add footer to the last embed + const lastEmbed = embeds[embeds.length - 1]; + lastEmbed.footer = { text: `herdctl · ${agentName}` }; + + await interaction.reply({ embeds, ephemeral: true }); }, }; diff --git a/packages/discord/src/index.ts b/packages/discord/src/index.ts index 5fb4ed29..3e13fc04 100644 --- a/packages/discord/src/index.ts +++ b/packages/discord/src/index.ts @@ -25,9 +25,11 @@ export { resolveChannelConfig, } from "./auto-mode-handler.js"; export type { + ChannelRunUsage, CommandContext, CommandManagerLogger, CommandManagerOptions, + CumulativeUsage, ICommandManager, SlashCommand, } from "./commands/index.js"; diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index dbe8d225..d69198c7 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -98,18 +98,8 @@ export class DiscordManager implements IChatManager { private connectors: Map = new Map(); private activeJobsByChannel: Map = new Map(); private lastPromptByChannel: Map = new Map(); - private lastUsageByChannel: Map< - string, - { - timestamp: string; - numTurns?: number; - durationMs?: number; - totalCostUsd?: number; - inputTokens?: number; - outputTokens?: number; - isError?: boolean; - } - > = new Map(); + private lastUsageByChannel: Map = new Map(); + private cumulativeUsageByAgent: Map = new Map(); private initialized: boolean = false; constructor(private ctx: FleetManagerContext) {} @@ -209,6 +199,8 @@ export class DiscordManager implements IChatManager { listSkills: async () => this.discoverAgentSkills(agent), getUsage: async (channelId: string) => this.getChannelUsage(agent.qualifiedName, channelId), + getCumulativeUsage: async () => + this.getAgentCumulativeUsage(agent.qualifiedName), getAgentConfig: async () => this.getAgentConfigSummary(agent), getSessionInfo: async (channelId: string) => this.getChannelRunInfo(agent.qualifiedName, channelId), @@ -927,10 +919,11 @@ export class DiscordManager implements IChatManager { if (normalized.resultText) { resultText = normalized.resultText; } + const now = new Date().toISOString(); this.lastUsageByChannel.set( this.getChannelKey(qualifiedName, event.metadata.channelId), { - timestamp: new Date().toISOString(), + timestamp: now, numTurns: normalized.numTurns, durationMs: normalized.durationMs, totalCostUsd: normalized.totalCostUsd, @@ -939,6 +932,14 @@ export class DiscordManager implements IChatManager { isError: normalized.isError, }, ); + this.accumulateUsage(qualifiedName, { + durationMs: normalized.durationMs, + totalCostUsd: normalized.totalCostUsd, + inputTokens: normalized.usage?.input_tokens, + outputTokens: normalized.usage?.output_tokens, + isError: normalized.isError, + timestamp: now, + }); latestStatusText = normalized.isError ? "Task failed" : "Task complete"; await refreshRunCard(normalized.isError ? "error" : "success"); @@ -1322,18 +1323,62 @@ export class DiscordManager implements IChatManager { private async getChannelUsage( qualifiedName: string, channelId: string, - ): Promise<{ - timestamp: string; - numTurns?: number; - durationMs?: number; - totalCostUsd?: number; - inputTokens?: number; - outputTokens?: number; - isError?: boolean; - } | null> { + ): Promise { return this.lastUsageByChannel.get(this.getChannelKey(qualifiedName, channelId)) ?? null; } + private accumulateUsage( + qualifiedName: string, + run: { + durationMs?: number; + totalCostUsd?: number; + inputTokens?: number; + outputTokens?: number; + isError?: boolean; + timestamp: string; + }, + ): void { + const existing = this.cumulativeUsageByAgent.get(qualifiedName); + if (existing) { + existing.totalRuns++; + if (run.isError) existing.totalFailures++; + else existing.totalSuccesses++; + existing.totalCostUsd += run.totalCostUsd ?? 0; + existing.totalInputTokens += run.inputTokens ?? 0; + existing.totalOutputTokens += run.outputTokens ?? 0; + existing.totalDurationMs += run.durationMs ?? 0; + existing.lastRunAt = run.timestamp; + } else { + this.cumulativeUsageByAgent.set(qualifiedName, { + totalRuns: 1, + totalSuccesses: run.isError ? 0 : 1, + totalFailures: run.isError ? 1 : 0, + totalCostUsd: run.totalCostUsd ?? 0, + totalInputTokens: run.inputTokens ?? 0, + totalOutputTokens: run.outputTokens ?? 0, + totalDurationMs: run.durationMs ?? 0, + firstRunAt: run.timestamp, + lastRunAt: run.timestamp, + }); + } + } + + private async getAgentCumulativeUsage(qualifiedName: string): Promise { + return ( + this.cumulativeUsageByAgent.get(qualifiedName) ?? { + totalRuns: 0, + totalSuccesses: 0, + totalFailures: 0, + totalCostUsd: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalDurationMs: 0, + firstRunAt: "", + lastRunAt: "", + } + ); + } + private async runChannelSkill( qualifiedName: string, channelId: string, From bf5db5df2847c0e59090e696975572c414691947 Mon Sep 17 00:00:00 2001 From: agent Date: Thu, 5 Mar 2026 11:36:07 -0800 Subject: [PATCH 33/48] fix(discord): discover skills from .claude/skills/ and support skills override - Add .claude/skills/ to skill discovery paths (was only scanning .codex/skills/ and skills/, missing all Claude Code skills) - When skills: [] is set in config, disable skill discovery entirely - When skills: [...] is set, use exactly those and skip auto-discovery - When skills is omitted, auto-discover from filesystem Co-Authored-By: Claude Opus 4.6 --- .../__tests__/extended-commands.test.ts | 12 +++-- packages/discord/src/commands/usage.ts | 8 +--- packages/discord/src/manager.ts | 45 ++++++++++++------- 3 files changed, 39 insertions(+), 26 deletions(-) diff --git a/packages/discord/src/commands/__tests__/extended-commands.test.ts b/packages/discord/src/commands/__tests__/extended-commands.test.ts index eb2f0e84..9fc89d13 100644 --- a/packages/discord/src/commands/__tests__/extended-commands.test.ts +++ b/packages/discord/src/commands/__tests__/extended-commands.test.ts @@ -157,9 +157,15 @@ describe("extended commands", () => { const ctx = makeContext(); ctx.commandActions!.getUsage = vi.fn().mockResolvedValue(null); ctx.commandActions!.getCumulativeUsage = vi.fn().mockResolvedValue({ - totalRuns: 0, totalSuccesses: 0, totalFailures: 0, - totalCostUsd: 0, totalInputTokens: 0, totalOutputTokens: 0, - totalDurationMs: 0, firstRunAt: "", lastRunAt: "", + totalRuns: 0, + totalSuccesses: 0, + totalFailures: 0, + totalCostUsd: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalDurationMs: 0, + firstRunAt: "", + lastRunAt: "", }); await usageCommand.execute(ctx); const call = (ctx.interaction.reply as ReturnType).mock.calls[0][0]; diff --git a/packages/discord/src/commands/usage.ts b/packages/discord/src/commands/usage.ts index 4ea9e4fe..e25d754c 100644 --- a/packages/discord/src/commands/usage.ts +++ b/packages/discord/src/commands/usage.ts @@ -9,12 +9,8 @@ export const usageCommand: SlashCommand = { const { interaction, commandActions, agentName } = context; const [lastRun, cumulative] = await Promise.all([ - commandActions?.getUsage - ? commandActions.getUsage(interaction.channelId) - : null, - commandActions?.getCumulativeUsage - ? commandActions.getCumulativeUsage() - : null, + commandActions?.getUsage ? commandActions.getUsage(interaction.channelId) : null, + commandActions?.getCumulativeUsage ? commandActions.getCumulativeUsage() : null, ]); if (!lastRun && (!cumulative || cumulative.totalRuns === 0)) { diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index d69198c7..d063ef3f 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -199,8 +199,7 @@ export class DiscordManager implements IChatManager { listSkills: async () => this.discoverAgentSkills(agent), getUsage: async (channelId: string) => this.getChannelUsage(agent.qualifiedName, channelId), - getCumulativeUsage: async () => - this.getAgentCumulativeUsage(agent.qualifiedName), + getCumulativeUsage: async () => this.getAgentCumulativeUsage(agent.qualifiedName), getAgentConfig: async () => this.getAgentConfigSummary(agent), getSessionInfo: async (channelId: string) => this.getChannelRunInfo(agent.qualifiedName, channelId), @@ -1500,13 +1499,18 @@ export class DiscordManager implements IChatManager { ): Promise> { const discovered = new Map(); - // Step 1: explicit configured skills (deterministic source of truth). - for (const skill of this.getConfiguredDiscordSkills(agent)) { - if (skill.name.trim().length === 0) continue; - discovered.set(skill.name, { name: skill.name, description: skill.description }); + // Step 1: if skills are explicitly configured, use them as-is. + // - undefined → not configured, fall through to auto-discovery + // - [] → explicitly disabled, return empty + // - [...] → use exactly those skills + const configuredSkills = this.getConfiguredDiscordSkills(agent); + if (configuredSkills !== undefined) { + return configuredSkills + .filter((s) => s.name.trim().length > 0) + .sort((a, b) => a.name.localeCompare(b.name)); } - // Step 2: auto-discover only from agent working directory paths. + // Step 2: auto-discover from agent working directory paths. const workingDir = typeof agent.working_directory === "string" ? agent.working_directory @@ -1515,7 +1519,11 @@ export class DiscordManager implements IChatManager { return Array.from(discovered.values()).sort((a, b) => a.name.localeCompare(b.name)); } - const candidateDirs = [join(workingDir, ".codex", "skills"), join(workingDir, "skills")]; + const candidateDirs = [ + join(workingDir, ".claude", "skills"), + join(workingDir, ".codex", "skills"), + join(workingDir, "skills"), + ]; for (const dir of candidateDirs) { try { @@ -1550,17 +1558,20 @@ export class DiscordManager implements IChatManager { private getConfiguredDiscordSkills( agent: ResolvedAgent, - ): Array<{ name: string; description?: string }> { - const skills = ( - agent.chat?.discord as unknown as - | { - skills?: Array<{ name?: string; description?: string }>; - } - | undefined - )?.skills; + ): Array<{ name: string; description?: string }> | undefined { + const discord = agent.chat?.discord as unknown as + | { + skills?: Array<{ name?: string; description?: string }>; + } + | undefined; + if (!discord || !("skills" in discord)) { + return undefined; // not configured → auto-discover + } + const skills = discord.skills; if (!Array.isArray(skills)) { - return []; + return undefined; } + // [] → explicitly empty (no skills); [...] → those exact skills return skills .filter( (skill): skill is { name: string; description?: string } => typeof skill?.name === "string", From 7ce7a079b812af2e190778512ac07a4dcfce0cdc Mon Sep 17 00:00:00 2001 From: agent Date: Thu, 5 Mar 2026 14:27:41 -0800 Subject: [PATCH 34/48] =?UTF-8?q?feat:=20multimodal=20image=20support=20?= =?UTF-8?q?=E2=80=94=20send=20Discord/Slack=20images=20as=20content=20bloc?= =?UTF-8?q?ks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Images attached in Discord or Slack messages are now downloaded as base64 and sent directly to the agent as native image content blocks, eliminating the extra Read tool round-trip. Non-image attachments and CLI runtimes gracefully fall back to text-only prompts. - Add ContentBlock, ImageBlock, TextBlock, PromptContent types to core - Widen prompt type from string to PromptContent through the pipeline - SDK runtime wraps ContentBlock[] as AsyncIterable for query() - CLI runtime extracts text via extractPromptText() (stdin is text-only) - Docker SDK wrapper handles multimodal arrays in the container - Job executor serializes prompt to string for job metadata storage - Discord connector extracts attachment info from Message.attachments - Discord manager downloads images as base64, builds ContentBlock[] - Slack connector passes event.files through to SlackMessageEvent - Slack manager downloads images via authenticated url_private Co-Authored-By: Claude Opus 4.6 --- .../core/src/fleet-manager/job-control.ts | 4 +- packages/core/src/fleet-manager/types.ts | 5 +- packages/core/src/runner/index.ts | 5 + packages/core/src/runner/job-executor.ts | 3 +- .../core/src/runner/runtime/cli-runtime.ts | 7 +- .../src/runner/runtime/docker-sdk-wrapper.js | 14 ++- packages/core/src/runner/runtime/interface.ts | 6 +- .../core/src/runner/runtime/sdk-runtime.ts | 20 ++- packages/core/src/runner/types.ts | 55 ++++++++- packages/discord/src/discord-connector.ts | 13 ++ packages/discord/src/manager.ts | 103 +++++++++++++++- packages/discord/src/types.ts | 20 +++ packages/slack/src/manager.ts | 116 +++++++++++++++++- packages/slack/src/slack-connector.ts | 26 ++++ packages/slack/src/types.ts | 17 +++ 15 files changed, 396 insertions(+), 18 deletions(-) diff --git a/packages/core/src/fleet-manager/job-control.ts b/packages/core/src/fleet-manager/job-control.ts index 34045c62..b74065d0 100644 --- a/packages/core/src/fleet-manager/job-control.ts +++ b/packages/core/src/fleet-manager/job-control.ts @@ -11,7 +11,7 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import type { HookEvent, ResolvedAgent } from "../config/index.js"; import { type HookContext, HookExecutor } from "../hooks/index.js"; -import { JobExecutor, RuntimeFactory } from "../runner/index.js"; +import { extractPromptText, JobExecutor, RuntimeFactory } from "../runner/index.js"; import { createJob, getJob, getSessionInfo, readJobOutputAll, updateJob } from "../state/index.js"; import type { JobMetadata } from "../state/schemas/job-metadata.js"; import type { FleetManagerContext } from "./context.js"; @@ -218,7 +218,7 @@ export class JobControl { agentName, scheduleName: scheduleName ?? null, startedAt: jobMetadata?.started_at ?? timestamp, - prompt, + prompt: typeof prompt === "string" ? prompt : extractPromptText(prompt), success: result.success, sessionId: result.sessionId, error: result.error, diff --git a/packages/core/src/fleet-manager/types.ts b/packages/core/src/fleet-manager/types.ts index bceff8aa..10a0c8fd 100644 --- a/packages/core/src/fleet-manager/types.ts +++ b/packages/core/src/fleet-manager/types.ts @@ -523,9 +523,10 @@ export interface TriggerOptions { * Override the prompt for this trigger * * This prompt will be used instead of the schedule's configured prompt - * or the agent's default prompt. + * or the agent's default prompt. Can be a plain string or an array of + * content blocks for multimodal input (text + images). */ - prompt?: string; + prompt?: import("../runner/types.js").PromptContent; /** * Session ID to resume for conversation continuity diff --git a/packages/core/src/runner/index.ts b/packages/core/src/runner/index.ts index 4656ad94..3e8a9fe2 100644 --- a/packages/core/src/runner/index.ts +++ b/packages/core/src/runner/index.ts @@ -51,11 +51,14 @@ export { } from "./sdk-adapter.js"; // Export types export type { + ContentBlock, + ImageBlock, InjectedMcpServerDef, InjectedMcpToolDef, McpToolCallResult, MessageCallback, ProcessedMessage, + PromptContent, RunnerErrorDetails, RunnerOptions, RunnerOptionsWithCallbacks, @@ -64,4 +67,6 @@ export type { SDKMessage, SDKQueryOptions, SDKSystemPrompt, + TextBlock, } from "./types.js"; +export { extractPromptText } from "./types.js"; diff --git a/packages/core/src/runner/job-executor.ts b/packages/core/src/runner/job-executor.ts index 878a190f..9d3feb3e 100644 --- a/packages/core/src/runner/job-executor.ts +++ b/packages/core/src/runner/job-executor.ts @@ -37,6 +37,7 @@ import { } from "./errors.js"; import { extractSummary, isTerminalMessage, processSDKMessage } from "./message-processor.js"; import type { RuntimeInterface } from "./runtime/index.js"; +import { extractPromptText } from "./types.js"; import type { ProcessedMessage, RunnerErrorDetails, @@ -164,7 +165,7 @@ export class JobExecutor { job = await createJob(jobsDir, { agent: agent.qualifiedName, trigger_type: effectiveTriggerType, - prompt, + prompt: extractPromptText(prompt), schedule, forked_from: options.fork ? options.forkedFrom : undefined, }); diff --git a/packages/core/src/runner/runtime/cli-runtime.ts b/packages/core/src/runner/runtime/cli-runtime.ts index 43471771..ac39b23f 100644 --- a/packages/core/src/runner/runtime/cli-runtime.ts +++ b/packages/core/src/runner/runtime/cli-runtime.ts @@ -17,6 +17,7 @@ import { execa, type Subprocess } from "execa"; import { createLogger } from "../../utils/logger.js"; import { transformMcpServers } from "../sdk-adapter.js"; +import { extractPromptText } from "../types.js"; import type { SDKMessage } from "../types.js"; import { getCliSessionDir, getCliSessionFile, waitForNewSessionFile } from "./cli-session-path.js"; import { CLISessionWatcher } from "./cli-session-watcher.js"; @@ -185,10 +186,12 @@ export class CLIRuntime implements RuntimeInterface { } // Note: Prompt is NOT added to args - it's provided via stdin (see processSpawner call below) + // CLI mode is text-only (stdin pipe), so extract text from content blocks + const promptText = extractPromptText(options.prompt); // DEBUG: Log the command being executed logger.debug(`Executing command: claude ${args.join(" ")}`); - logger.debug(`Prompt: ${options.prompt}`); + logger.debug(`Prompt: ${promptText}`); // Track process and watcher for cleanup let subprocess: Subprocess | undefined; @@ -218,7 +221,7 @@ export class CLIRuntime implements RuntimeInterface { // Spawn claude subprocess with prompt via stdin // Uses custom spawner if provided (e.g., for Docker execution) // Note: processSpawner returns Subprocess directly (which is promise-like) - subprocess = this.processSpawner(args, cwd, options.prompt, options.abortController?.signal); + subprocess = this.processSpawner(args, cwd, promptText, options.abortController?.signal); logger.debug(`Subprocess spawned, PID: ${subprocess.pid}`); diff --git a/packages/core/src/runner/runtime/docker-sdk-wrapper.js b/packages/core/src/runner/runtime/docker-sdk-wrapper.js index abb8d7ce..187126b8 100644 --- a/packages/core/src/runner/runtime/docker-sdk-wrapper.js +++ b/packages/core/src/runner/runtime/docker-sdk-wrapper.js @@ -29,9 +29,21 @@ async function main() { const options = JSON.parse(optionsJson); console.error("[docker-sdk-wrapper] Calling SDK query()..."); + // When prompt is an array of content blocks (multimodal), wrap it as + // an async iterable yielding a single user message with content blocks. + // The SDK's query() accepts prompt: string | AsyncIterable. + let promptInput = options.prompt; + if (Array.isArray(promptInput)) { + const contentBlocks = promptInput; + async function* makeUserMessage() { + yield { message: { role: "user", content: contentBlocks } }; + } + promptInput = makeUserMessage(); + } + // Execute SDK query const messages = query({ - prompt: options.prompt, + prompt: promptInput, options: options.sdkOptions, }); diff --git a/packages/core/src/runner/runtime/interface.ts b/packages/core/src/runner/runtime/interface.ts index 050627fb..44a09ff3 100644 --- a/packages/core/src/runner/runtime/interface.ts +++ b/packages/core/src/runner/runtime/interface.ts @@ -10,14 +10,14 @@ */ import type { ResolvedAgent } from "../../config/index.js"; -import type { SDKMessage } from "../types.js"; +import type { PromptContent, SDKMessage } from "../types.js"; /** * Options for executing a runtime */ export interface RuntimeExecuteOptions { - /** The prompt to execute */ - prompt: string; + /** The prompt to execute (string or multimodal content blocks) */ + prompt: PromptContent; /** Resolved agent configuration */ agent: ResolvedAgent; diff --git a/packages/core/src/runner/runtime/sdk-runtime.ts b/packages/core/src/runner/runtime/sdk-runtime.ts index 4329e11f..c749e2b9 100644 --- a/packages/core/src/runner/runtime/sdk-runtime.ts +++ b/packages/core/src/runner/runtime/sdk-runtime.ts @@ -11,7 +11,7 @@ import { createSdkMcpServer, query, tool } from "@anthropic-ai/claude-agent-sdk"; import { z } from "zod"; import { toSDKOptions } from "../sdk-adapter.js"; -import type { InjectedMcpServerDef, SDKMessage } from "../types.js"; +import type { ContentBlock, InjectedMcpServerDef, SDKMessage } from "../types.js"; import type { RuntimeExecuteOptions, RuntimeInterface } from "./interface.js"; /** @@ -146,8 +146,24 @@ export class SDKRuntime implements RuntimeInterface { // Execute via SDK query() // Note: SDK does not currently support AbortController for cancellation // This is tracked for future enhancement when SDK adds support + + // When prompt is ContentBlock[], construct an async iterable that yields + // a single user message with multimodal content blocks (text + images). + // The SDK's query() accepts prompt: string | AsyncIterable. + let promptInput: string | AsyncIterable<{ message: { role: "user"; content: ContentBlock[] } }>; + + if (Array.isArray(options.prompt)) { + const contentBlocks = options.prompt; + async function* makeUserMessage() { + yield { message: { role: "user" as const, content: contentBlocks } }; + } + promptInput = makeUserMessage(); + } else { + promptInput = options.prompt; + } + const messages = query({ - prompt: options.prompt, + prompt: promptInput as Parameters[0]["prompt"], options: sdkOptions as Record, }); diff --git a/packages/core/src/runner/types.ts b/packages/core/src/runner/types.ts index 025d8196..f40d819a 100644 --- a/packages/core/src/runner/types.ts +++ b/packages/core/src/runner/types.ts @@ -7,6 +7,57 @@ import type { ResolvedAgent } from "../config/index.js"; import type { JobOutputInput, TriggerType } from "../state/index.js"; +// ============================================================================= +// Multimodal Content Block Types +// ============================================================================= + +/** Text content block for multimodal prompts */ +export type TextBlock = { type: "text"; text: string }; + +/** Image content block with base64-encoded data */ +export type ImageBlock = { + type: "image"; + source: { + type: "base64"; + media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp"; + data: string; // base64-encoded + }; +}; + +/** A single content block in a multimodal prompt */ +export type ContentBlock = TextBlock | ImageBlock; + +/** + * Prompt content: either a plain string or an array of content blocks + * (text + images) for multimodal input. + */ +export type PromptContent = string | ContentBlock[]; + +/** + * Extract the text portion of a PromptContent value. + * + * When `prompt` is a string, returns it as-is. + * When `prompt` is ContentBlock[], joins all text blocks and adds + * "[N image(s) attached]" placeholders for image blocks. + */ +export function extractPromptText(prompt: PromptContent): string { + if (typeof prompt === "string") return prompt; + + const parts: string[] = []; + let imageCount = 0; + for (const block of prompt) { + if (block.type === "text") { + parts.push(block.text); + } else if (block.type === "image") { + imageCount++; + } + } + if (imageCount > 0) { + parts.push(`[${imageCount} image(s) attached]`); + } + return parts.join("\n"); +} + // ============================================================================= // Runner Options Types // ============================================================================= @@ -17,8 +68,8 @@ import type { JobOutputInput, TriggerType } from "../state/index.js"; export interface RunnerOptions { /** Fully resolved agent configuration */ agent: ResolvedAgent; - /** The prompt to send to the agent */ - prompt: string; + /** The prompt to send to the agent (string or multimodal content blocks) */ + prompt: PromptContent; /** Path to the .herdctl directory */ stateDir: string; /** How this run was triggered */ diff --git a/packages/discord/src/discord-connector.ts b/packages/discord/src/discord-connector.ts index a3cd5b27..364628c3 100644 --- a/packages/discord/src/discord-connector.ts +++ b/packages/discord/src/discord-connector.ts @@ -681,6 +681,18 @@ export class DiscordConnector extends EventEmitter implements IDiscordConnector }; }; + // Extract file attachments from the Discord message + const attachments: DiscordConnectorEventMap["message"]["attachments"] = + message.attachments.size > 0 + ? message.attachments.map((a) => ({ + id: a.id, + name: a.name ?? "unknown", + url: a.url, + contentType: a.contentType ?? "application/octet-stream", + size: a.size, + })) + : undefined; + // Emit message event const payload: DiscordConnectorEventMap["message"] = { agentName: this.agentName, @@ -697,6 +709,7 @@ export class DiscordConnector extends EventEmitter implements IDiscordConnector }, reply, startTyping, + attachments, }; this.emit("message", payload); } diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index 451a5be6..cb8ba670 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -19,8 +19,10 @@ import { } from "@herdctl/chat"; import type { ChatManagerConnectorState, + ContentBlock, FleetManagerContext, IChatManager, + PromptContent, ResolvedAgent, } from "@herdctl/core"; import { @@ -32,6 +34,7 @@ import { import { DiscordConnector } from "./discord-connector.js"; import type { + DiscordAttachmentInfo, DiscordConnectorEventMap, DiscordReplyEmbed, DiscordReplyEmbedField, @@ -432,6 +435,14 @@ export class DiscordManager implements IChatManager { let typingStopped = false; try { + // Build prompt: if images are attached, create multimodal ContentBlock[] + // Otherwise use plain string + const prompt = await DiscordManager.buildPromptWithAttachments( + event.prompt, + event.attachments, + logger as ChatConnectorLogger, + ); + // Track pending tool_use blocks so we can pair them with results const pendingToolUses = new Map< string, @@ -444,7 +455,7 @@ export class DiscordManager implements IChatManager { // The onMessage callback streams output incrementally to Discord const result = await this.ctx.trigger(qualifiedName, undefined, { triggerType: "discord", - prompt: event.prompt, + prompt, resume: existingSessionId, onMessage: async (message) => { // Extract text content from assistant messages and stream to Discord @@ -867,4 +878,94 @@ export class DiscordManager implements IChatManager { await reply(chunk); } } + + // =========================================================================== + // Attachment Handling + // =========================================================================== + + /** MIME types that can be sent as native image content blocks */ + private static readonly IMAGE_MIME_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + ]); + + /** + * Build a prompt with image attachments as multimodal content blocks. + * + * When the message includes image attachments (JPEG, PNG, GIF, WebP), + * downloads them as base64 and returns a ContentBlock[] with text + images. + * For non-image attachments or no attachments, returns the plain text prompt. + */ + private static async buildPromptWithAttachments( + textPrompt: string, + attachments: DiscordAttachmentInfo[] | undefined, + logger: ChatConnectorLogger, + ): Promise { + if (!attachments || attachments.length === 0) { + return textPrompt; + } + + // Filter to supported image types + const imageAttachments = attachments.filter((a) => + DiscordManager.IMAGE_MIME_TYPES.has(a.contentType), + ); + + if (imageAttachments.length === 0) { + return textPrompt; + } + + // Download images and build content blocks + const blocks: ContentBlock[] = []; + + // Add text block first + if (textPrompt.trim()) { + blocks.push({ type: "text", text: textPrompt }); + } + + for (const attachment of imageAttachments) { + try { + const response = await fetch(attachment.url, { + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) { + logger.warn(`Failed to download attachment ${attachment.name}: HTTP ${response.status}`); + continue; + } + const buffer = Buffer.from(await response.arrayBuffer()); + const base64 = buffer.toString("base64"); + blocks.push({ + type: "image", + source: { + type: "base64", + media_type: attachment.contentType as + | "image/jpeg" + | "image/png" + | "image/gif" + | "image/webp", + data: base64, + }, + }); + logger.info( + `Attached image ${attachment.name} (${Math.round(buffer.length / 1024)}KB) as content block`, + ); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + logger.warn(`Failed to download image attachment ${attachment.name}: ${errMsg}`); + } + } + + // If no images were successfully downloaded, fall back to text + if (blocks.length === 0) { + return textPrompt; + } + + // If only images (no text), add a minimal text block + if (!textPrompt.trim() && blocks.length > 0) { + blocks.unshift({ type: "text", text: "The user sent the following image(s):" }); + } + + return blocks; + } } diff --git a/packages/discord/src/types.ts b/packages/discord/src/types.ts index 4cfe3d72..3be3e7b2 100644 --- a/packages/discord/src/types.ts +++ b/packages/discord/src/types.ts @@ -257,6 +257,24 @@ export interface DiscordReplyPayload { embeds: DiscordReplyEmbed[]; } +// ============================================================================= +// Attachment Types +// ============================================================================= + +/** Information about a Discord message attachment */ +export interface DiscordAttachmentInfo { + /** Attachment ID */ + id: string; + /** Filename */ + name: string; + /** CDN URL to download the file */ + url: string; + /** Content type (MIME type) */ + contentType: string; + /** File size in bytes */ + size: number; +} + // ============================================================================= // Event Types // ============================================================================= @@ -346,6 +364,8 @@ export interface DiscordConnectorEventMap { * The indicator auto-refreshes every 8 seconds until stopped. */ startTyping: () => () => void; + /** File attachments on the message (if any) */ + attachments?: DiscordAttachmentInfo[]; }; /** diff --git a/packages/slack/src/manager.ts b/packages/slack/src/manager.ts index a8679b64..15359224 100644 --- a/packages/slack/src/manager.ts +++ b/packages/slack/src/manager.ts @@ -19,9 +19,11 @@ import { } from "@herdctl/chat"; import type { ChatManagerConnectorState, + ContentBlock, FleetManagerContext, IChatManager, InjectedMcpServerDef, + PromptContent, ResolvedAgent, TriggerOptions, } from "@herdctl/core"; @@ -35,7 +37,7 @@ import { } from "@herdctl/core"; import { markdownToMrkdwn } from "./formatting.js"; import { SlackConnector } from "./slack-connector.js"; -import type { SlackConnectorEventMap, SlackMessageEvent } from "./types.js"; +import type { SlackAttachmentInfo, SlackConnectorEventMap, SlackMessageEvent } from "./types.js"; // ============================================================================= // Slack Manager @@ -468,6 +470,14 @@ export class SlackManager implements IChatManager { let processingStopped = false; try { + // Build prompt: if images are attached, create multimodal ContentBlock[] + const prompt = await SlackManager.buildPromptWithAttachments( + event.prompt, + event.attachments, + this.getBotToken(qualifiedName), + logger as ChatConnectorLogger, + ); + // Track pending tool_use blocks so we can pair them with results const pendingToolUses = new Map< string, @@ -479,7 +489,7 @@ export class SlackManager implements IChatManager { // The onMessage callback streams output incrementally to Slack const result = await this.ctx.trigger(qualifiedName, undefined, { triggerType: "slack", - prompt: event.prompt, + prompt, resume: existingSessionId, injectedMcpServers, onMessage: async (message) => { @@ -727,6 +737,108 @@ export class SlackManager implements IChatManager { return agent.working_directory.root; } + + /** + * Get the bot token for a specific agent's Slack connector + */ + private getBotToken(qualifiedName: string): string | undefined { + const config = this.ctx.getConfig(); + const agent = config?.agents.find((a) => a.qualifiedName === qualifiedName); + const tokenEnv = agent?.chat?.slack?.bot_token_env; + return tokenEnv ? process.env[tokenEnv] : undefined; + } + + // =========================================================================== + // Attachment Handling + // =========================================================================== + + /** MIME types that can be sent as native image content blocks */ + private static readonly IMAGE_MIME_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + ]); + + /** + * Build a prompt with image attachments as multimodal content blocks. + * + * Downloads image files from Slack using the bot token for auth and + * returns a ContentBlock[] with text + images. Non-image files are ignored. + */ + private static async buildPromptWithAttachments( + textPrompt: string, + attachments: SlackAttachmentInfo[] | undefined, + botToken: string | undefined, + logger: ChatConnectorLogger, + ): Promise { + if (!attachments || attachments.length === 0 || !botToken) { + return textPrompt; + } + + // Filter to supported image types + const imageAttachments = attachments.filter((a) => + SlackManager.IMAGE_MIME_TYPES.has(a.mimetype), + ); + + if (imageAttachments.length === 0) { + return textPrompt; + } + + // Download images and build content blocks + const blocks: ContentBlock[] = []; + + // Add text block first + if (textPrompt.trim()) { + blocks.push({ type: "text", text: textPrompt }); + } + + for (const attachment of imageAttachments) { + try { + // Slack url_private requires Bearer token authentication + const response = await fetch(attachment.urlPrivate, { + headers: { Authorization: `Bearer ${botToken}` }, + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) { + logger.warn(`Failed to download Slack file ${attachment.name}: HTTP ${response.status}`); + continue; + } + const buffer = Buffer.from(await response.arrayBuffer()); + const base64 = buffer.toString("base64"); + blocks.push({ + type: "image", + source: { + type: "base64", + media_type: attachment.mimetype as + | "image/jpeg" + | "image/png" + | "image/gif" + | "image/webp", + data: base64, + }, + }); + logger.info( + `Attached Slack image ${attachment.name} (${Math.round(buffer.length / 1024)}KB) as content block`, + ); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + logger.warn(`Failed to download Slack image ${attachment.name}: ${errMsg}`); + } + } + + // If no images were successfully downloaded, fall back to text + if (blocks.length === 0) { + return textPrompt; + } + + // If only images (no text), add a minimal text block + if (!textPrompt.trim() && blocks.length > 0) { + blocks.unshift({ type: "text", text: "The user sent the following image(s):" }); + } + + return blocks; + } } // ============================================================================= diff --git a/packages/slack/src/slack-connector.ts b/packages/slack/src/slack-connector.ts index 3f40a4d3..2c563a6e 100644 --- a/packages/slack/src/slack-connector.ts +++ b/packages/slack/src/slack-connector.ts @@ -351,6 +351,7 @@ export class SlackConnector extends EventEmitter implements ISlackConnector { true, isDM, say, + event.files, ); this.emit("message", messageEvent); @@ -482,6 +483,7 @@ export class SlackConnector extends EventEmitter implements ISlackConnector { false, isDM, say, + event.files, ); this.emit("message", messageEvent); @@ -585,6 +587,7 @@ export class SlackConnector extends EventEmitter implements ISlackConnector { isDM: boolean, // eslint-disable-next-line @typescript-eslint/no-explicit-any say: any, + files?: SlackFileInfo[], ): SlackMessageEvent { const reply = async (content: string): Promise => { await say({ @@ -623,6 +626,17 @@ export class SlackConnector extends EventEmitter implements ISlackConnector { }; }; + // Extract file attachment info + const attachments: SlackMessageEvent["attachments"] = files + ?.filter((f) => f.url_private && f.mimetype) + .map((f) => ({ + id: f.id, + name: f.name ?? "unknown", + mimetype: f.mimetype!, + size: f.size ?? 0, + urlPrivate: f.url_private!, + })); + return { agentName: this.agentName, prompt, @@ -635,6 +649,7 @@ export class SlackConnector extends EventEmitter implements ISlackConnector { }, reply, startProcessingIndicator, + attachments: attachments && attachments.length > 0 ? attachments : undefined, }; } @@ -687,6 +702,15 @@ function isSlackDM(channelId: string): boolean { // Internal Slack Event Types (subset of Bolt types) // ============================================================================= +/** Slack file object attached to a message */ +interface SlackFileInfo { + id: string; + name?: string; + mimetype?: string; + size?: number; + url_private?: string; +} + interface AppMentionEvent { type: "app_mention"; user: string; @@ -694,6 +718,7 @@ interface AppMentionEvent { ts: string; channel: string; thread_ts?: string; + files?: SlackFileInfo[]; } interface MessageEvent { @@ -705,6 +730,7 @@ interface MessageEvent { ts: string; channel: string; thread_ts?: string; + files?: SlackFileInfo[]; } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/slack/src/types.ts b/packages/slack/src/types.ts index 3027f844..7f84a0ef 100644 --- a/packages/slack/src/types.ts +++ b/packages/slack/src/types.ts @@ -154,6 +154,23 @@ export interface SlackMessageEvent { /** Add hourglass reaction while processing, returns remove function */ startProcessingIndicator: () => () => void; + + /** File attachments on the message (if any) */ + attachments?: SlackAttachmentInfo[]; +} + +/** Information about a Slack file attachment */ +export interface SlackAttachmentInfo { + /** Slack file ID */ + id: string; + /** Filename */ + name: string; + /** MIME type */ + mimetype: string; + /** File size in bytes */ + size: number; + /** Authenticated URL to download the file */ + urlPrivate: string; } /** From bfa09bed2a1b51d1d7af21d5d91305db492c1e8a Mon Sep 17 00:00:00 2001 From: agent Date: Thu, 5 Mar 2026 15:17:54 -0800 Subject: [PATCH 35/48] feat(discord): send image attachments as native multimodal content blocks Images (JPEG, PNG, GIF, WebP) are now downloaded as base64 and sent to the agent as ContentBlock[] instead of being saved to disk with file path references. This eliminates the extra Read tool round-trip. PDFs and text files continue using their existing handling (disk save / inline text). Co-Authored-By: Claude Opus 4.6 --- packages/discord/src/manager.ts | 68 ++++++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index d063ef3f..e4587f1d 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -22,10 +22,12 @@ import { } from "@herdctl/chat"; import type { ChatManagerConnectorState, + ContentBlock, DiscordAttachments, FleetManagerContext, IChatManager, InjectedMcpServerDef, + PromptContent, ResolvedAgent, } from "@herdctl/core"; import { @@ -635,6 +637,8 @@ export class DiscordManager implements IChatManager { } // Handle file attachments: download, process, and prepend to prompt + // Images are sent as native multimodal content blocks; text/PDFs as text + let promptContent: PromptContent = prompt; if ( event.metadata.attachments && event.metadata.attachments.length > 0 && @@ -654,8 +658,9 @@ export class DiscordManager implements IChatManager { } } + // Build the text portion (inline text files + file path references) if (result.promptSections.length > 0) { - const attachmentBlock = [ + prompt = [ "The user sent the following file attachment(s) with their message:", "", ...result.promptSections, @@ -664,7 +669,20 @@ export class DiscordManager implements IChatManager { "", `User message: ${prompt}`, ].join("\n"); - prompt = attachmentBlock; + } + + // If we have image content blocks, build a ContentBlock[] prompt + if (result.imageBlocks.length > 0) { + const blocks: ContentBlock[] = []; + if (prompt.trim()) { + blocks.push({ type: "text", text: prompt }); + } else { + blocks.push({ type: "text", text: "The user sent the following image(s):" }); + } + blocks.push(...result.imageBlocks); + promptContent = blocks; + } else { + promptContent = prompt; } } @@ -735,7 +753,7 @@ export class DiscordManager implements IChatManager { // The onMessage callback streams output incrementally to Discord const result = await this.ctx.trigger(qualifiedName, undefined, { triggerType: "discord", - prompt, + prompt: promptContent, resume: existingSessionId, injectedMcpServers, onJobCreated: async (jobId) => { @@ -1709,6 +1727,14 @@ export class DiscordManager implements IChatManager { * Returns prompt sections to prepend, paths of downloaded files for cleanup, * and a list of skipped files with reasons. */ + /** MIME types supported as native image content blocks by the Anthropic API */ + private static readonly IMAGE_CONTENT_BLOCK_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + ]); + private static async processAttachments( attachments: DiscordAttachmentInfo[], config: DiscordAttachments, @@ -1716,10 +1742,12 @@ export class DiscordManager implements IChatManager { logger: ChatConnectorLogger, ): Promise<{ promptSections: string[]; + imageBlocks: ContentBlock[]; downloadedPaths: string[]; skippedFiles: { name: string; reason: string }[]; }> { const promptSections: string[] = []; + const imageBlocks: ContentBlock[] = []; const downloadedPaths: string[] = []; const skippedFiles: { name: string; reason: string }[] = []; const maxBytes = config.max_file_size_mb * 1024 * 1024; @@ -1760,7 +1788,7 @@ export class DiscordManager implements IChatManager { try { if (attachment.category === "text") { - // Text/code: download and inline + // Text/code: download and inline as text const response = await fetch(attachment.url, { signal: AbortSignal.timeout(30_000) }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); @@ -1772,8 +1800,36 @@ export class DiscordManager implements IChatManager { promptSections.push( `--- File: ${attachment.name} (${attachment.contentType}) ---\n${text}\n--- End of ${attachment.name} ---`, ); + } else if ( + attachment.category === "image" && + DiscordManager.IMAGE_CONTENT_BLOCK_TYPES.has( + attachment.contentType.toLowerCase().split(";")[0].trim(), + ) + ) { + // Images: download as base64 and send as native content blocks + const response = await fetch(attachment.url, { signal: AbortSignal.timeout(30_000) }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const buffer = Buffer.from(await response.arrayBuffer()); + const mediaType = attachment.contentType.toLowerCase().split(";")[0].trim() as + | "image/jpeg" + | "image/png" + | "image/gif" + | "image/webp"; + imageBlocks.push({ + type: "image", + source: { + type: "base64", + media_type: mediaType, + data: buffer.toString("base64"), + }, + }); + logger.info( + `Attached image ${attachment.name} (${Math.round(buffer.length / 1024)}KB) as content block`, + ); } else { - // Image/PDF: download to disk + // PDF or unsupported image type: download to disk if (!workingDir) { skippedFiles.push({ name: attachment.name, @@ -1807,7 +1863,7 @@ export class DiscordManager implements IChatManager { } } - return { promptSections, downloadedPaths, skippedFiles }; + return { promptSections, imageBlocks, downloadedPaths, skippedFiles }; } /** From 4314b04a0eae6341720c8f78aab7fc9e666f3f34 Mon Sep 17 00:00:00 2001 From: agent Date: Thu, 5 Mar 2026 15:35:18 -0800 Subject: [PATCH 36/48] feat(discord): always process image attachments as content blocks Image attachments (JPEG, PNG, GIF, WebP) are now processed as multimodal content blocks even without `attachments.enabled` in the agent config. The full attachment pipeline (text inlining, PDF disk saves) still requires the config flag, but images work out of the box for all Discord agents. Co-Authored-By: Claude Opus 4.6 --- packages/discord/src/manager.ts | 131 ++++++++++++++++++++++---------- 1 file changed, 92 insertions(+), 39 deletions(-) diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index e4587f1d..07a66106 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -636,53 +636,68 @@ export class DiscordManager implements IChatManager { } } - // Handle file attachments: download, process, and prepend to prompt - // Images are sent as native multimodal content blocks; text/PDFs as text + // Handle file attachments: images always become content blocks; + // text/PDF processing requires attachments.enabled in agent config let promptContent: PromptContent = prompt; - if ( - event.metadata.attachments && - event.metadata.attachments.length > 0 && - attachmentConfig?.enabled - ) { - const result = await DiscordManager.processAttachments( - event.metadata.attachments, - attachmentConfig, - workingDir, - logger as ChatConnectorLogger, - ); - attachmentDownloadedPaths = result.downloadedPaths; + if (event.metadata.attachments && event.metadata.attachments.length > 0) { + if (attachmentConfig?.enabled) { + // Full attachment pipeline: images → content blocks, text → inline, PDF → disk + const result = await DiscordManager.processAttachments( + event.metadata.attachments, + attachmentConfig, + workingDir, + logger as ChatConnectorLogger, + ); + attachmentDownloadedPaths = result.downloadedPaths; - if (result.skippedFiles.length > 0) { - for (const skipped of result.skippedFiles) { - logger.debug(`Skipped attachment ${skipped.name}: ${skipped.reason}`); + if (result.skippedFiles.length > 0) { + for (const skipped of result.skippedFiles) { + logger.debug(`Skipped attachment ${skipped.name}: ${skipped.reason}`); + } } - } - // Build the text portion (inline text files + file path references) - if (result.promptSections.length > 0) { - prompt = [ - "The user sent the following file attachment(s) with their message:", - "", - ...result.promptSections, - "", - "---", - "", - `User message: ${prompt}`, - ].join("\n"); - } + // Build the text portion (inline text files + file path references) + if (result.promptSections.length > 0) { + prompt = [ + "The user sent the following file attachment(s) with their message:", + "", + ...result.promptSections, + "", + "---", + "", + `User message: ${prompt}`, + ].join("\n"); + } - // If we have image content blocks, build a ContentBlock[] prompt - if (result.imageBlocks.length > 0) { - const blocks: ContentBlock[] = []; - if (prompt.trim()) { - blocks.push({ type: "text", text: prompt }); + // If we have image content blocks, build a ContentBlock[] prompt + if (result.imageBlocks.length > 0) { + const blocks: ContentBlock[] = []; + if (prompt.trim()) { + blocks.push({ type: "text", text: prompt }); + } else { + blocks.push({ type: "text", text: "The user sent the following image(s):" }); + } + blocks.push(...result.imageBlocks); + promptContent = blocks; } else { - blocks.push({ type: "text", text: "The user sent the following image(s):" }); + promptContent = prompt; } - blocks.push(...result.imageBlocks); - promptContent = blocks; } else { - promptContent = prompt; + // Lightweight path: only process images as content blocks (no config needed) + const imageBlocks = await DiscordManager.downloadImageContentBlocks( + event.metadata.attachments, + logger as ChatConnectorLogger, + ); + if (imageBlocks.length > 0) { + const blocks: ContentBlock[] = []; + if (prompt.trim()) { + blocks.push({ type: "text", text: prompt }); + } else { + blocks.push({ type: "text", text: "The user sent the following image(s):" }); + } + blocks.push(...imageBlocks); + promptContent = blocks; + } } } @@ -1701,6 +1716,44 @@ export class DiscordManager implements IChatManager { // Attachment Processing // =========================================================================== + /** + * Lightweight image-only extraction: download supported images as base64 + * content blocks without requiring the full attachments config. + */ + private static async downloadImageContentBlocks( + attachments: DiscordAttachmentInfo[], + logger: ChatConnectorLogger, + ): Promise { + const blocks: ContentBlock[] = []; + for (const attachment of attachments) { + const mime = attachment.contentType.toLowerCase().split(";")[0].trim(); + if (!DiscordManager.IMAGE_CONTENT_BLOCK_TYPES.has(mime)) continue; + try { + const response = await fetch(attachment.url, { signal: AbortSignal.timeout(30_000) }); + if (!response.ok) { + logger.warn(`Failed to download image ${attachment.name}: HTTP ${response.status}`); + continue; + } + const buffer = Buffer.from(await response.arrayBuffer()); + blocks.push({ + type: "image", + source: { + type: "base64", + media_type: mime as "image/jpeg" | "image/png" | "image/gif" | "image/webp", + data: buffer.toString("base64"), + }, + }); + logger.info( + `Attached image ${attachment.name} (${Math.round(buffer.length / 1024)}KB) as content block`, + ); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + logger.warn(`Failed to download image ${attachment.name}: ${errMsg}`); + } + } + return blocks; + } + /** Maximum characters to inline for text/code file content */ private static readonly TEXT_INLINE_MAX_CHARS = 50_000; From d039563a27e95381d4f5b0963523a9a8eb87452e Mon Sep 17 00:00:00 2001 From: agent Date: Thu, 5 Mar 2026 17:18:50 -0800 Subject: [PATCH 37/48] feat(core): auto-promote CLI runtime to SDK for multimodal prompts in Docker When a Docker container agent uses CLI runtime but receives a ContentBlock[] prompt (multimodal images), automatically route through the SDK wrapper instead of the CLI path. CLI runtime can only pipe plain text via stdin, so content blocks would be lost. The SDK wrapper handles them natively. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/runner/runtime/container-runner.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/core/src/runner/runtime/container-runner.ts b/packages/core/src/runner/runtime/container-runner.ts index f8e2b92e..0fac2e10 100644 --- a/packages/core/src/runner/runtime/container-runner.ts +++ b/packages/core/src/runner/runtime/container-runner.ts @@ -130,12 +130,16 @@ export class ContainerRunner implements RuntimeInterface { const containerInfo = await container.inspect(); const containerId = containerInfo.Id; + // When prompt contains multimodal content blocks, always use the SDK + // wrapper — CLI runtime can only pipe plain text via stdin. + const hasContentBlocks = Array.isArray(effectiveOptions.prompt); + // Handle CLI runtime with session file watching - if (this.wrapped instanceof CLIRuntime) { + if (this.wrapped instanceof CLIRuntime && !hasContentBlocks) { yield* this.executeCLIRuntime(containerId, dockerSessionsDir, effectiveOptions); } - // Handle SDK runtime with wrapper script - else if (this.wrapped instanceof SDKRuntime) { + // Handle SDK runtime with wrapper script (also used for CLI agents with content blocks) + else if (this.wrapped instanceof SDKRuntime || hasContentBlocks) { yield* this.executeSDKRuntime(container, effectiveOptions); } // Unknown runtime type From ff278714673c880f227319796ce84aa3a3e4687a Mon Sep 17 00:00:00 2001 From: agent Date: Thu, 5 Mar 2026 17:27:53 -0800 Subject: [PATCH 38/48] fix(core): drop CLI session when auto-promoting to SDK for multimodal CLI session IDs are incompatible with SDK sessions. When a CLI-runtime Docker agent is auto-promoted to SDK for multimodal content blocks, the resume session ID must be cleared to avoid the SDK wrapper hanging on an unrecognized session. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/runner/runtime/container-runner.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/core/src/runner/runtime/container-runner.ts b/packages/core/src/runner/runtime/container-runner.ts index 0fac2e10..e9ab1565 100644 --- a/packages/core/src/runner/runtime/container-runner.ts +++ b/packages/core/src/runner/runtime/container-runner.ts @@ -140,7 +140,12 @@ export class ContainerRunner implements RuntimeInterface { } // Handle SDK runtime with wrapper script (also used for CLI agents with content blocks) else if (this.wrapped instanceof SDKRuntime || hasContentBlocks) { - yield* this.executeSDKRuntime(container, effectiveOptions); + // When auto-promoting from CLI to SDK for multimodal, drop the resume + // session — CLI session IDs are incompatible with SDK sessions. + const sdkOptions = hasContentBlocks && this.wrapped instanceof CLIRuntime + ? { ...effectiveOptions, resume: undefined } + : effectiveOptions; + yield* this.executeSDKRuntime(container, sdkOptions); } // Unknown runtime type else { From 77352bf880ba88fc4342cece03ee01643dd3d813 Mon Sep 17 00:00:00 2001 From: agent Date: Thu, 5 Mar 2026 17:38:48 -0800 Subject: [PATCH 39/48] fix(discord): revert multimodal content blocks, use file-to-disk for all attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All agents use CLI runtime which pipes text via stdin — ContentBlock[] gets flattened and images are lost. Reverts the auto-promote-to-SDK approach which also broke session continuity. Images now go through the same file-to-disk pipeline as PDFs: saved to the agent's working directory and referenced in the prompt. The agent views them via the Read tool (which is multimodal). This preserves conversation context across all message types. The core PromptContent/ContentBlock types remain available for future SDK-runtime agents. Co-Authored-By: Claude Opus 4.6 --- .../src/runner/runtime/container-runner.ts | 17 +- packages/discord/src/manager.ts | 173 ++++-------------- 2 files changed, 36 insertions(+), 154 deletions(-) diff --git a/packages/core/src/runner/runtime/container-runner.ts b/packages/core/src/runner/runtime/container-runner.ts index e9ab1565..f8e2b92e 100644 --- a/packages/core/src/runner/runtime/container-runner.ts +++ b/packages/core/src/runner/runtime/container-runner.ts @@ -130,22 +130,13 @@ export class ContainerRunner implements RuntimeInterface { const containerInfo = await container.inspect(); const containerId = containerInfo.Id; - // When prompt contains multimodal content blocks, always use the SDK - // wrapper — CLI runtime can only pipe plain text via stdin. - const hasContentBlocks = Array.isArray(effectiveOptions.prompt); - // Handle CLI runtime with session file watching - if (this.wrapped instanceof CLIRuntime && !hasContentBlocks) { + if (this.wrapped instanceof CLIRuntime) { yield* this.executeCLIRuntime(containerId, dockerSessionsDir, effectiveOptions); } - // Handle SDK runtime with wrapper script (also used for CLI agents with content blocks) - else if (this.wrapped instanceof SDKRuntime || hasContentBlocks) { - // When auto-promoting from CLI to SDK for multimodal, drop the resume - // session — CLI session IDs are incompatible with SDK sessions. - const sdkOptions = hasContentBlocks && this.wrapped instanceof CLIRuntime - ? { ...effectiveOptions, resume: undefined } - : effectiveOptions; - yield* this.executeSDKRuntime(container, sdkOptions); + // Handle SDK runtime with wrapper script + else if (this.wrapped instanceof SDKRuntime) { + yield* this.executeSDKRuntime(container, effectiveOptions); } // Unknown runtime type else { diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index 07a66106..1d7240ae 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -22,12 +22,10 @@ import { } from "@herdctl/chat"; import type { ChatManagerConnectorState, - ContentBlock, DiscordAttachments, FleetManagerContext, IChatManager, InjectedMcpServerDef, - PromptContent, ResolvedAgent, } from "@herdctl/core"; import { @@ -636,68 +634,37 @@ export class DiscordManager implements IChatManager { } } - // Handle file attachments: images always become content blocks; - // text/PDF processing requires attachments.enabled in agent config - let promptContent: PromptContent = prompt; - if (event.metadata.attachments && event.metadata.attachments.length > 0) { - if (attachmentConfig?.enabled) { - // Full attachment pipeline: images → content blocks, text → inline, PDF → disk - const result = await DiscordManager.processAttachments( - event.metadata.attachments, - attachmentConfig, - workingDir, - logger as ChatConnectorLogger, - ); - attachmentDownloadedPaths = result.downloadedPaths; - - if (result.skippedFiles.length > 0) { - for (const skipped of result.skippedFiles) { - logger.debug(`Skipped attachment ${skipped.name}: ${skipped.reason}`); - } - } + // Handle file attachments: download, process, and prepend to prompt + // Images/PDFs are saved to disk (agent views via Read tool); text is inlined + if ( + event.metadata.attachments && + event.metadata.attachments.length > 0 && + attachmentConfig?.enabled + ) { + const result = await DiscordManager.processAttachments( + event.metadata.attachments, + attachmentConfig, + workingDir, + logger as ChatConnectorLogger, + ); + attachmentDownloadedPaths = result.downloadedPaths; - // Build the text portion (inline text files + file path references) - if (result.promptSections.length > 0) { - prompt = [ - "The user sent the following file attachment(s) with their message:", - "", - ...result.promptSections, - "", - "---", - "", - `User message: ${prompt}`, - ].join("\n"); + if (result.skippedFiles.length > 0) { + for (const skipped of result.skippedFiles) { + logger.debug(`Skipped attachment ${skipped.name}: ${skipped.reason}`); } + } - // If we have image content blocks, build a ContentBlock[] prompt - if (result.imageBlocks.length > 0) { - const blocks: ContentBlock[] = []; - if (prompt.trim()) { - blocks.push({ type: "text", text: prompt }); - } else { - blocks.push({ type: "text", text: "The user sent the following image(s):" }); - } - blocks.push(...result.imageBlocks); - promptContent = blocks; - } else { - promptContent = prompt; - } - } else { - // Lightweight path: only process images as content blocks (no config needed) - const imageBlocks = await DiscordManager.downloadImageContentBlocks( - event.metadata.attachments, - logger as ChatConnectorLogger, - ); - if (imageBlocks.length > 0) { - const blocks: ContentBlock[] = []; - if (prompt.trim()) { - blocks.push({ type: "text", text: prompt }); - } else { - blocks.push({ type: "text", text: "The user sent the following image(s):" }); - } - blocks.push(...imageBlocks); - promptContent = blocks; - } + if (result.promptSections.length > 0) { + prompt = [ + "The user sent the following file attachment(s) with their message:", + "", + ...result.promptSections, + "", + "---", + "", + `User message: ${prompt}`, + ].join("\n"); } } @@ -768,7 +735,7 @@ export class DiscordManager implements IChatManager { // The onMessage callback streams output incrementally to Discord const result = await this.ctx.trigger(qualifiedName, undefined, { triggerType: "discord", - prompt: promptContent, + prompt, resume: existingSessionId, injectedMcpServers, onJobCreated: async (jobId) => { @@ -1716,44 +1683,6 @@ export class DiscordManager implements IChatManager { // Attachment Processing // =========================================================================== - /** - * Lightweight image-only extraction: download supported images as base64 - * content blocks without requiring the full attachments config. - */ - private static async downloadImageContentBlocks( - attachments: DiscordAttachmentInfo[], - logger: ChatConnectorLogger, - ): Promise { - const blocks: ContentBlock[] = []; - for (const attachment of attachments) { - const mime = attachment.contentType.toLowerCase().split(";")[0].trim(); - if (!DiscordManager.IMAGE_CONTENT_BLOCK_TYPES.has(mime)) continue; - try { - const response = await fetch(attachment.url, { signal: AbortSignal.timeout(30_000) }); - if (!response.ok) { - logger.warn(`Failed to download image ${attachment.name}: HTTP ${response.status}`); - continue; - } - const buffer = Buffer.from(await response.arrayBuffer()); - blocks.push({ - type: "image", - source: { - type: "base64", - media_type: mime as "image/jpeg" | "image/png" | "image/gif" | "image/webp", - data: buffer.toString("base64"), - }, - }); - logger.info( - `Attached image ${attachment.name} (${Math.round(buffer.length / 1024)}KB) as content block`, - ); - } catch (err) { - const errMsg = err instanceof Error ? err.message : String(err); - logger.warn(`Failed to download image ${attachment.name}: ${errMsg}`); - } - } - return blocks; - } - /** Maximum characters to inline for text/code file content */ private static readonly TEXT_INLINE_MAX_CHARS = 50_000; @@ -1780,14 +1709,6 @@ export class DiscordManager implements IChatManager { * Returns prompt sections to prepend, paths of downloaded files for cleanup, * and a list of skipped files with reasons. */ - /** MIME types supported as native image content blocks by the Anthropic API */ - private static readonly IMAGE_CONTENT_BLOCK_TYPES = new Set([ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - ]); - private static async processAttachments( attachments: DiscordAttachmentInfo[], config: DiscordAttachments, @@ -1795,12 +1716,10 @@ export class DiscordManager implements IChatManager { logger: ChatConnectorLogger, ): Promise<{ promptSections: string[]; - imageBlocks: ContentBlock[]; downloadedPaths: string[]; skippedFiles: { name: string; reason: string }[]; }> { const promptSections: string[] = []; - const imageBlocks: ContentBlock[] = []; const downloadedPaths: string[] = []; const skippedFiles: { name: string; reason: string }[] = []; const maxBytes = config.max_file_size_mb * 1024 * 1024; @@ -1841,7 +1760,7 @@ export class DiscordManager implements IChatManager { try { if (attachment.category === "text") { - // Text/code: download and inline as text + // Text/code: download and inline const response = await fetch(attachment.url, { signal: AbortSignal.timeout(30_000) }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); @@ -1853,36 +1772,8 @@ export class DiscordManager implements IChatManager { promptSections.push( `--- File: ${attachment.name} (${attachment.contentType}) ---\n${text}\n--- End of ${attachment.name} ---`, ); - } else if ( - attachment.category === "image" && - DiscordManager.IMAGE_CONTENT_BLOCK_TYPES.has( - attachment.contentType.toLowerCase().split(";")[0].trim(), - ) - ) { - // Images: download as base64 and send as native content blocks - const response = await fetch(attachment.url, { signal: AbortSignal.timeout(30_000) }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - const buffer = Buffer.from(await response.arrayBuffer()); - const mediaType = attachment.contentType.toLowerCase().split(";")[0].trim() as - | "image/jpeg" - | "image/png" - | "image/gif" - | "image/webp"; - imageBlocks.push({ - type: "image", - source: { - type: "base64", - media_type: mediaType, - data: buffer.toString("base64"), - }, - }); - logger.info( - `Attached image ${attachment.name} (${Math.round(buffer.length / 1024)}KB) as content block`, - ); } else { - // PDF or unsupported image type: download to disk + // Image/PDF: download to disk for the agent to view via Read tool if (!workingDir) { skippedFiles.push({ name: attachment.name, @@ -1916,7 +1807,7 @@ export class DiscordManager implements IChatManager { } } - return { promptSections, imageBlocks, downloadedPaths, skippedFiles }; + return { promptSections, downloadedPaths, skippedFiles }; } /** From 0fc6474e8b11625077e54332f97c60bc6699ff7a Mon Sep 17 00:00:00 2001 From: agent Date: Thu, 5 Mar 2026 21:07:40 -0800 Subject: [PATCH 40/48] feat(discord): echo voice transcription text in channel After transcribing a voice message, post the transcription as a subtle grey embed so all channel members can read what was said without needing to listen to the audio. Co-Authored-By: Claude Opus 4.6 --- packages/discord/src/manager.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index 1d7240ae..658814f5 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -625,6 +625,17 @@ export class DiscordManager implements IChatManager { prompt = `[Voice message transcription]: ${transcription.text}`; logger.info(`Voice message transcribed: "${prompt.substring(0, 80)}..."`); + + // Echo the transcription to the channel so everyone can read the voice message + await event.reply({ + embeds: [ + { + description: transcription.text, + color: 0x95a5a6, // subtle grey + footer: { text: "Voice transcription" }, + }, + ], + }); } catch (transcribeError) { const errMsg = transcribeError instanceof Error ? transcribeError.message : String(transcribeError); From 1d97eee5e84fcd8db46f23cf21426116a1fb9a12 Mon Sep 17 00:00:00 2001 From: agent Date: Fri, 6 Mar 2026 20:53:03 -0800 Subject: [PATCH 41/48] fix(discord): translate attachment paths for Docker-containerized agents When an agent runs in Docker, the working_directory is bind-mounted at /workspace inside the container. Attachment download paths in the prompt were using the host path, making them inaccessible to the agent. Now rewrites host paths to /workspace paths when docker is enabled. Co-Authored-By: Claude Opus 4.6 --- packages/discord/src/manager.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index 658814f5..57101f2a 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -667,10 +667,17 @@ export class DiscordManager implements IChatManager { } if (result.promptSections.length > 0) { + // When agent runs in Docker, translate host paths to container paths + // (working_directory is mounted at /workspace inside the container) + let sections = result.promptSections; + if (agent.docker?.enabled && workingDir) { + sections = sections.map((s) => s.replaceAll(workingDir, "/workspace")); + } + prompt = [ "The user sent the following file attachment(s) with their message:", "", - ...result.promptSections, + ...sections, "", "---", "", From 7ec759435b5821500773d79f4042578f6799c01e Mon Sep 17 00:00:00 2001 From: agent Date: Sun, 8 Mar 2026 09:27:11 -0700 Subject: [PATCH 42/48] Fix container accumulation: reuse stopped containers across restarts Non-ephemeral containers were orphaned on every herdctl restart because getOrCreateContainer only checked an in-memory map. Now queries Docker for the most recent stopped container and restarts it, preserving tools agents installed. Also reduce DEFAULT_MAX_CONTAINERS from 5 to 1 as a safety net against future accumulation. Co-Authored-By: Claude Opus 4.6 --- .../src/runner/runtime/container-manager.ts | 36 +++++++++++++++---- .../core/src/runner/runtime/docker-config.ts | 2 +- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/core/src/runner/runtime/container-manager.ts b/packages/core/src/runner/runtime/container-manager.ts index fce648ff..ae5b1d4a 100644 --- a/packages/core/src/runner/runtime/container-manager.ts +++ b/packages/core/src/runner/runtime/container-manager.ts @@ -45,20 +45,44 @@ export class ContainerManager { mounts: PathMapping[], env: string[], ): Promise { - // For persistent containers, check if already running + // For persistent containers, try to reuse an existing container if (!config.ephemeral) { - const existing = this.runningContainers.get(agentName); - if (existing) { + // First check in-memory cache (same herdctl process) + const cached = this.runningContainers.get(agentName); + if (cached) { try { - const info = await existing.inspect(); + const info = await cached.inspect(); if (info.State.Running) { - return existing; + return cached; } } catch { - // Container no longer exists, remove from map this.runningContainers.delete(agentName); } } + + // Query Docker for the most recent stopped container for this agent + // This handles herdctl restarts where the in-memory map is empty + const existing = await this.docker.listContainers({ + all: true, + limit: 1, + filters: { + name: [`herdctl-${agentName}-`], + status: ["exited", "created"], + }, + }); + + if (existing.length > 0) { + const container = this.docker.getContainer(existing[0].Id); + try { + await container.start(); + this.runningContainers.set(agentName, container); + logger.info(`Restarted existing container for ${agentName}: ${existing[0].Names[0]}`); + return container; + } catch (err) { + logger.warn(`Failed to restart existing container for ${agentName}, creating new: ${err}`); + // Fall through to create a new container + } + } } // Create new container diff --git a/packages/core/src/runner/runtime/docker-config.ts b/packages/core/src/runner/runtime/docker-config.ts index 311af662..33b5a85b 100644 --- a/packages/core/src/runner/runtime/docker-config.ts +++ b/packages/core/src/runner/runtime/docker-config.ts @@ -108,7 +108,7 @@ export const DEFAULT_MEMORY_LIMIT = "2g"; /** * Default max containers to keep per agent */ -export const DEFAULT_MAX_CONTAINERS = 5; +export const DEFAULT_MAX_CONTAINERS = 1; /** * Parse memory string (e.g., "2g", "512m") to bytes From fb6179678540329f73aa015915225a1fb84f879f Mon Sep 17 00:00:00 2001 From: agent Date: Mon, 9 Mar 2026 05:46:26 -0700 Subject: [PATCH 43/48] fix(discord): PDF attachment handling, result_summary default, and attachment diagnostics - Guess MIME type from file extension when Discord returns null contentType, fixing PDF uploads that silently fail in DMs - Revert result_summary default from true back to false (unintentional flip) - Sync manager fallback defaults with schema defaults - Upgrade attachment skip logging from debug to warn - Warn when message has attachments but attachments.enabled is false - Add _guessContentType tests Co-Authored-By: Claude Opus 4.6 --- .changeset/discord-pdf-ack-fixes.md | 11 ++++ .../core/src/config/__tests__/schema.test.ts | 2 +- packages/core/src/config/schema.ts | 4 +- .../discord/src/__tests__/attachments.test.ts | 38 +++++++++++ packages/discord/src/discord-connector.ts | 38 ++++++++++- packages/discord/src/manager.ts | 65 ++++++++++--------- 6 files changed, 125 insertions(+), 33 deletions(-) create mode 100644 .changeset/discord-pdf-ack-fixes.md diff --git a/.changeset/discord-pdf-ack-fixes.md b/.changeset/discord-pdf-ack-fixes.md new file mode 100644 index 00000000..d91c6aa3 --- /dev/null +++ b/.changeset/discord-pdf-ack-fixes.md @@ -0,0 +1,11 @@ +--- +"@herdctl/discord": patch +"@herdctl/core": patch +--- + +Fix Discord PDF attachment handling, revert result_summary default, and improve attachment diagnostics + +- Guess MIME type from file extension when Discord returns null contentType (fixes PDF uploads in DMs) +- Revert result_summary default from true back to false (unintentional change in PR 194) +- Upgrade attachment skip/ignore logging from debug to warn for easier troubleshooting +- Add warn log when message has attachments but attachments.enabled is false diff --git a/packages/core/src/config/__tests__/schema.test.ts b/packages/core/src/config/__tests__/schema.test.ts index 9c9c4aa2..f2e09128 100644 --- a/packages/core/src/config/__tests__/schema.test.ts +++ b/packages/core/src/config/__tests__/schema.test.ts @@ -1708,7 +1708,7 @@ describe("AgentChatDiscordSchema", () => { tool_results: true, tool_result_max_length: 900, system_status: true, - result_summary: true, + result_summary: false, errors: true, typing_indicator: true, acknowledge_emoji: "👀", diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 711f1cb8..11d5e17b 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -501,6 +501,8 @@ export const McpServerSchema = z.object({ args: z.array(z.string()).optional(), env: z.record(z.string(), z.string()).optional(), url: z.string().optional(), + /** Custom headers for HTTP-based MCP servers (e.g. Authorization) */ + headers: z.record(z.string(), z.string()).optional(), /** When true and Docker is enabled, run this MCP server on the host and bridge into the container */ host: z.boolean().optional(), }); @@ -632,7 +634,7 @@ export const ChatOutputSchema = z.object({ */ export const DiscordOutputSchema = ChatOutputSchema.extend({ /** Show a summary embed when the agent finishes a turn (cost, tokens, turns) (default: true) */ - result_summary: z.boolean().optional().default(true), + result_summary: z.boolean().optional().default(false), /** Show typing indicator while the agent is processing (default: true) */ typing_indicator: z.boolean().optional().default(true), /** Emoji to react with when a message is received (empty string to disable, default: "👀") */ diff --git a/packages/discord/src/__tests__/attachments.test.ts b/packages/discord/src/__tests__/attachments.test.ts index a257ac4c..ff7003fb 100644 --- a/packages/discord/src/__tests__/attachments.test.ts +++ b/packages/discord/src/__tests__/attachments.test.ts @@ -147,6 +147,44 @@ describe("_categorizeContentType", () => { }); }); +// ============================================================================= +// Guess Content Type Tests +// ============================================================================= + +describe("_guessContentType", () => { + const guess = ConnectorAny._guessContentType; + + it("guesses PDF from .pdf extension", () => { + expect(guess("document.pdf")).toBe("application/pdf"); + }); + + it("guesses image types from extensions", () => { + expect(guess("photo.png")).toBe("image/png"); + expect(guess("photo.jpg")).toBe("image/jpeg"); + expect(guess("photo.jpeg")).toBe("image/jpeg"); + expect(guess("anim.gif")).toBe("image/gif"); + expect(guess("img.webp")).toBe("image/webp"); + }); + + it("guesses text/code types from extensions", () => { + expect(guess("readme.txt")).toBe("text/plain"); + expect(guess("notes.md")).toBe("text/markdown"); + expect(guess("data.json")).toBe("application/json"); + expect(guess("config.yaml")).toBe("application/x-yaml"); + expect(guess("config.yml")).toBe("application/x-yaml"); + }); + + it("returns undefined for unknown extensions", () => { + expect(guess("archive.zip")).toBeUndefined(); + expect(guess("video.mp4")).toBeUndefined(); + }); + + it("returns undefined for files without extension", () => { + expect(guess("Makefile")).toBeUndefined(); + expect(guess("")).toBeUndefined(); + }); +}); + // ============================================================================= // MIME Pattern Matching Tests // ============================================================================= diff --git a/packages/discord/src/discord-connector.ts b/packages/discord/src/discord-connector.ts index 2a521ba1..8f37b56c 100644 --- a/packages/discord/src/discord-connector.ts +++ b/packages/discord/src/discord-connector.ts @@ -775,7 +775,12 @@ export class DiscordConnector extends EventEmitter implements IDiscordConnector if (!isVoiceMessage && message.attachments.size > 0) { const extracted: DiscordAttachmentInfo[] = []; for (const [, attachment] of message.attachments) { - const contentType = attachment.contentType ?? "application/octet-stream"; + // Discord may return null contentType (especially in DMs), so fall back + // to guessing from the file extension before giving up + const contentType = + attachment.contentType ?? + DiscordConnector._guessContentType(attachment.name ?? "") ?? + "application/octet-stream"; const category = DiscordConnector._categorizeContentType(contentType); if (category !== "unsupported") { extracted.push({ @@ -866,6 +871,37 @@ export class DiscordConnector extends EventEmitter implements IDiscordConnector return "unsupported"; } + /** + * Guess MIME type from file extension when Discord doesn't provide contentType. + * Returns undefined if the extension is unrecognized. + */ + private static _guessContentType(filename: string): string | undefined { + const ext = filename.split(".").pop()?.toLowerCase(); + if (!ext) return undefined; + const map: Record = { + pdf: "application/pdf", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + txt: "text/plain", + md: "text/markdown", + json: "application/json", + js: "application/javascript", + ts: "application/typescript", + yaml: "application/x-yaml", + yml: "application/x-yaml", + xml: "application/xml", + sh: "application/x-sh", + csv: "text/csv", + html: "text/html", + css: "text/css", + }; + return map[ext]; + } + /** * Set bot presence based on configuration */ diff --git a/packages/discord/src/manager.ts b/packages/discord/src/manager.ts index 57101f2a..444fffb4 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -449,7 +449,7 @@ export class DiscordManager implements IChatManager { tool_results: true, tool_result_max_length: 900, system_status: true, - result_summary: true, + result_summary: false, errors: true, typing_indicator: true, acknowledge_emoji: "👀", @@ -649,40 +649,45 @@ export class DiscordManager implements IChatManager { // Images/PDFs are saved to disk (agent views via Read tool); text is inlined if ( event.metadata.attachments && - event.metadata.attachments.length > 0 && - attachmentConfig?.enabled + event.metadata.attachments.length > 0 ) { - const result = await DiscordManager.processAttachments( - event.metadata.attachments, - attachmentConfig, - workingDir, - logger as ChatConnectorLogger, - ); - attachmentDownloadedPaths = result.downloadedPaths; + if (!attachmentConfig?.enabled) { + logger.warn( + `Message has ${event.metadata.attachments.length} attachment(s) but attachments.enabled is false — files will be ignored. Set chat.discord.attachments.enabled: true to process them.`, + ); + } else { + const result = await DiscordManager.processAttachments( + event.metadata.attachments, + attachmentConfig, + workingDir, + logger as ChatConnectorLogger, + ); + attachmentDownloadedPaths = result.downloadedPaths; - if (result.skippedFiles.length > 0) { - for (const skipped of result.skippedFiles) { - logger.debug(`Skipped attachment ${skipped.name}: ${skipped.reason}`); + if (result.skippedFiles.length > 0) { + for (const skipped of result.skippedFiles) { + logger.warn(`Skipped attachment ${skipped.name}: ${skipped.reason}`); + } } - } - if (result.promptSections.length > 0) { - // When agent runs in Docker, translate host paths to container paths - // (working_directory is mounted at /workspace inside the container) - let sections = result.promptSections; - if (agent.docker?.enabled && workingDir) { - sections = sections.map((s) => s.replaceAll(workingDir, "/workspace")); - } + if (result.promptSections.length > 0) { + // When agent runs in Docker, translate host paths to container paths + // (working_directory is mounted at /workspace inside the container) + let sections = result.promptSections; + if (agent.docker?.enabled && workingDir) { + sections = sections.map((s) => s.replaceAll(workingDir, "/workspace")); + } - prompt = [ - "The user sent the following file attachment(s) with their message:", - "", - ...sections, - "", - "---", - "", - `User message: ${prompt}`, - ].join("\n"); + prompt = [ + "The user sent the following file attachment(s) with their message:", + "", + ...sections, + "", + "---", + "", + `User message: ${prompt}`, + ].join("\n"); + } } } From b9e22266c20fde7cc8d598ae1e1555f4d6f68488 Mon Sep 17 00:00:00 2001 From: agent Date: Mon, 9 Mar 2026 19:53:52 -0700 Subject: [PATCH 44/48] fix(core): race session file watcher against process exit for CLI -p mode Claude Code 2.1.71+ no longer writes .jsonl session files in -p (pipe) mode. This caused a 60s timeout on every new Docker agent session. Now races waitForNewSessionFile against process completion. If the process exits first, extracts session_id from stdout JSON and creates a stub .jsonl file so the session watcher has something to work with. Co-Authored-By: Claude Opus 4.6 --- .../core/src/runner/runtime/cli-runtime.ts | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/core/src/runner/runtime/cli-runtime.ts b/packages/core/src/runner/runtime/cli-runtime.ts index 3f75c9fa..81dad06d 100644 --- a/packages/core/src/runner/runtime/cli-runtime.ts +++ b/packages/core/src/runner/runtime/cli-runtime.ts @@ -377,9 +377,20 @@ export class CLIRuntime implements RuntimeInterface { logger.debug(`Subprocess spawned, PID: ${subprocess.pid}`); - // Log subprocess output for debugging + // Capture stdout for session ID extraction and logging. + // Claude -p mode may not write a .jsonl session file, so we parse + // the session_id from stdout and create the file ourselves if needed. + let capturedSessionId: string | undefined; subprocess.stdout?.on("data", (data) => { - logger.info(data.toString()); + const text = data.toString(); + logger.info(text); + // Extract session_id from stream-json result lines + if (!capturedSessionId) { + const match = text.match(/"session_id"\s*:\s*"([0-9a-f-]{36})"/); + if (match) { + capturedSessionId = match[1]; + } + } }); subprocess.stderr?.on("data", (data) => { logger.warn(data.toString()); @@ -417,12 +428,33 @@ export class CLIRuntime implements RuntimeInterface { } logger.debug(`Resuming session, watching file: ${sessionFilePath}`); } else { - // When starting new session, wait for a NEW file created after process start + // When starting new session, wait for a NEW file created after process start. + // Claude -p mode may not write session files, so we race the file watcher + // against process completion and fall back to creating the file from stdout. logger.debug("Waiting for new session file..."); - sessionFilePath = await waitForNewSessionFile(sessionDir, processStartTime, { - timeoutMs: 60000, // Allow up to 60s for MCP servers to initialize + const sessionFilePromise = waitForNewSessionFile(sessionDir, processStartTime, { + timeoutMs: 60000, pollIntervalMs: 200, }); + // Race: session file appears OR process exits (whichever comes first) + const raceResult = await Promise.race([ + sessionFilePromise.then((path) => ({ type: "file" as const, path })), + processExitPromise.then(() => ({ type: "process_done" as const, path: undefined })), + ]); + if (raceResult.type === "file") { + sessionFilePath = raceResult.path; + } else { + // Process finished without writing a session file. + // Create a stub using the session ID captured from stdout. + // Use captured session ID from stdout, or generate one if CLI didn't + // output structured JSON (e.g., -p mode in Claude CLI 2.1.71+). + const stubSessionId = capturedSessionId ?? crypto.randomUUID(); + logger.info(`No session file written by CLI; creating stub for ${stubSessionId}`); + const { mkdir, writeFile } = await import("node:fs/promises"); + await mkdir(sessionDir, { recursive: true }); + sessionFilePath = `${sessionDir}/${stubSessionId}.jsonl`; + await writeFile(sessionFilePath, ""); + } logger.debug(`New session, watching newly created file: ${sessionFilePath}`); } From 36b88e5b075ec0ae39e902c1dcc89e665832abe8 Mon Sep 17 00:00:00 2001 From: agent Date: Mon, 9 Mar 2026 19:54:02 -0700 Subject: [PATCH 45/48] feat(core): support custom headers on HTTP MCP servers Adds headers field to McpServerSchema and SDKMcpServerConfig, allowing Authorization and other custom headers on HTTP-based MCP servers. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/runner/sdk-adapter.ts | 3 +++ packages/core/src/runner/types.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/packages/core/src/runner/sdk-adapter.ts b/packages/core/src/runner/sdk-adapter.ts index 316dc10c..e69ec5b5 100644 --- a/packages/core/src/runner/sdk-adapter.ts +++ b/packages/core/src/runner/sdk-adapter.ts @@ -46,6 +46,9 @@ export function transformMcpServer(server: McpServer): SDKMcpServerConfig { if (server.url) { result.type = "http"; result.url = server.url; + if (server.headers && Object.keys(server.headers).length > 0) { + result.headers = server.headers; + } } // Process-based MCP server diff --git a/packages/core/src/runner/types.ts b/packages/core/src/runner/types.ts index 24165d01..ed34a5cf 100644 --- a/packages/core/src/runner/types.ts +++ b/packages/core/src/runner/types.ts @@ -257,6 +257,7 @@ export interface InjectedMcpServerDef { export interface SDKMcpServerConfig { type?: "http"; url?: string; + headers?: Record; command?: string; args?: string[]; env?: Record; From 70d39e5aed34e02423ea7fb19f8efc91348c16d9 Mon Sep 17 00:00:00 2001 From: agent Date: Mon, 9 Mar 2026 19:54:14 -0700 Subject: [PATCH 46/48] fix(core): run self-scheduling MCP server on host for Docker agents The scheduler MCP server needs host filesystem access to the state directory. Set host: true so it gets HTTP-bridged into containers automatically via the MCP host bridge. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/config/__tests__/self-scheduling.test.ts | 1 + packages/core/src/config/self-scheduling.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/packages/core/src/config/__tests__/self-scheduling.test.ts b/packages/core/src/config/__tests__/self-scheduling.test.ts index 46f2094d..14b14739 100644 --- a/packages/core/src/config/__tests__/self-scheduling.test.ts +++ b/packages/core/src/config/__tests__/self-scheduling.test.ts @@ -29,6 +29,7 @@ describe("injectSchedulerMcpServers", () => { expect(server.command).toBe("node"); expect(server.args).toHaveLength(1); expect(server.args![0]).toContain("scheduler-mcp.js"); + expect(server.host).toBe(true); expect(server.env!.HERDCTL_AGENT_NAME).toBe("test-agent"); expect(server.env!.HERDCTL_STATE_DIR).toBe("/tmp/.herdctl"); expect(server.env!.HERDCTL_MAX_SCHEDULES).toBe("10"); diff --git a/packages/core/src/config/self-scheduling.ts b/packages/core/src/config/self-scheduling.ts index 20f55860..bb688ad3 100644 --- a/packages/core/src/config/self-scheduling.ts +++ b/packages/core/src/config/self-scheduling.ts @@ -91,6 +91,10 @@ export function injectSchedulerMcpServers(agents: ResolvedAgent[], stateDir: str HERDCTL_STATIC_SCHEDULES: staticScheduleNames.join(","), }), }, + // Always run on host — the scheduler needs host filesystem access to + // the state directory and the compiled MCP script. For Docker agents, + // this gets HTTP-bridged into the container automatically. + host: true, }; } From 069b82cd5c13c0a17b7776de398a1e32b667c900 Mon Sep 17 00:00:00 2001 From: agent Date: Tue, 10 Mar 2026 12:41:47 -0700 Subject: [PATCH 47/48] fix(core): prevent empty stub session files from breaking resume - Remove stub file creation when CLI exits without writing a session file. Empty stubs cause "No conversation found" errors when herdctl tries to resume them. Instead, use a synthetic session ID so the current run completes but won't be resumed. - Validate Docker session files have content (size > 0) before allowing resume. Empty files are treated as missing. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/runner/runtime/cli-runtime.ts | 16 +++++++--------- packages/core/src/state/session-validation.ts | 7 ++++--- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/core/src/runner/runtime/cli-runtime.ts b/packages/core/src/runner/runtime/cli-runtime.ts index 81dad06d..331c8b60 100644 --- a/packages/core/src/runner/runtime/cli-runtime.ts +++ b/packages/core/src/runner/runtime/cli-runtime.ts @@ -445,15 +445,13 @@ export class CLIRuntime implements RuntimeInterface { sessionFilePath = raceResult.path; } else { // Process finished without writing a session file. - // Create a stub using the session ID captured from stdout. - // Use captured session ID from stdout, or generate one if CLI didn't - // output structured JSON (e.g., -p mode in Claude CLI 2.1.71+). - const stubSessionId = capturedSessionId ?? crypto.randomUUID(); - logger.info(`No session file written by CLI; creating stub for ${stubSessionId}`); - const { mkdir, writeFile } = await import("node:fs/promises"); - await mkdir(sessionDir, { recursive: true }); - sessionFilePath = `${sessionDir}/${stubSessionId}.jsonl`; - await writeFile(sessionFilePath, ""); + // This can happen if the CLI exits quickly or if the session directory + // isn't writable from inside the container. Don't create stub files — + // they break resume. Instead, generate a synthetic session ID so the + // current run can complete, but the session won't be resumable. + const syntheticId = capturedSessionId ?? crypto.randomUUID(); + logger.warn(`No session file written by CLI (session ${syntheticId}). Run will complete but session cannot be resumed.`); + sessionFilePath = `${sessionDir}/${syntheticId}.jsonl`; } logger.debug(`New session, watching newly created file: ${sessionFilePath}`); } diff --git a/packages/core/src/state/session-validation.ts b/packages/core/src/state/session-validation.ts index 17f4bf33..a0a53078 100644 --- a/packages/core/src/state/session-validation.ts +++ b/packages/core/src/state/session-validation.ts @@ -5,7 +5,7 @@ * unexpected logouts when resuming expired sessions. */ -import { access } from "node:fs/promises"; +import { access, stat } from "node:fs/promises"; import { dirname, join } from "node:path"; import { getCliSessionFile } from "../runner/runtime/cli-session-path.js"; import type { SessionInfo } from "./schemas/session-info.js"; @@ -141,8 +141,9 @@ export async function dockerSessionFileExists( const stateDir = dirname(sessionsDir); const dockerSessionsDir = join(stateDir, "docker-sessions"); const sessionFile = join(dockerSessionsDir, `${sessionId}.jsonl`); - await access(sessionFile); - return true; + const stats = await stat(sessionFile); + // Empty files (0 bytes) are invalid — they can't be resumed + return stats.size > 0; } catch { return false; } From 700c57f22ca258c927beca78dfd2176984a4de1d Mon Sep 17 00:00:00 2001 From: agent Date: Mon, 16 Mar 2026 12:41:24 -0700 Subject: [PATCH 48/48] feat(core): enable self-scheduling by default for new agents Self-scheduling is now opt-out instead of opt-in. Agents that omit self_scheduling config get it enabled with default limits (max 10 schedules, 5m min interval). Set enabled: false to disable. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/src/commands/__tests__/agent.test.ts | 8 +-- .../core/src/config/__tests__/merge.test.ts | 61 +++++++++++++------ packages/core/src/config/schema.ts | 7 ++- .../src/runner/__tests__/job-executor.test.ts | 1 + .../src/runner/__tests__/sdk-adapter.test.ts | 1 + .../src/scheduler/__tests__/scheduler.test.ts | 1 + .../work-sources/__tests__/manager.test.ts | 1 + .../src/__tests__/discord-connector.test.ts | 1 + .../discord/src/__tests__/manager.test.ts | 8 +++ packages/slack/src/__tests__/manager.test.ts | 2 + .../web/src/server/chat/web-chat-manager.ts | 1 + 11 files changed, 65 insertions(+), 27 deletions(-) diff --git a/packages/cli/src/commands/__tests__/agent.test.ts b/packages/cli/src/commands/__tests__/agent.test.ts index bcb16c24..8e0c9978 100644 --- a/packages/cli/src/commands/__tests__/agent.test.ts +++ b/packages/cli/src/commands/__tests__/agent.test.ts @@ -312,7 +312,7 @@ describe("agentAddCommand", () => { mockedValidateRepository.mockResolvedValue({ valid: true, agentName, - agentConfig: { name: agentName, permission_mode: "default", runtime: "sdk" }, + agentConfig: { name: agentName, permission_mode: "default", runtime: "sdk", self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" } }, repoMetadata: null, errors: [], warnings: [], @@ -368,7 +368,7 @@ describe("agentAddCommand", () => { mockedValidateRepository.mockResolvedValue({ valid: true, agentName, - agentConfig: { name: agentName, permission_mode: "default", runtime: "sdk" }, + agentConfig: { name: agentName, permission_mode: "default", runtime: "sdk", self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" } }, repoMetadata: null, errors: [], warnings: [], @@ -566,7 +566,7 @@ describe("agentAddCommand", () => { mockedValidateRepository.mockResolvedValue({ valid: true, agentName: "existing-agent", - agentConfig: { name: "existing-agent", permission_mode: "default", runtime: "sdk" }, + agentConfig: { name: "existing-agent", permission_mode: "default", runtime: "sdk", self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" } }, repoMetadata: null, errors: [], warnings: [], @@ -629,7 +629,7 @@ describe("agentAddCommand", () => { mockedValidateRepository.mockResolvedValue({ valid: true, agentName: "warning-agent", - agentConfig: { name: "warning-agent", permission_mode: "default", runtime: "sdk" }, + agentConfig: { name: "warning-agent", permission_mode: "default", runtime: "sdk", self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" } }, repoMetadata: null, errors: [], warnings: [ diff --git a/packages/core/src/config/__tests__/merge.test.ts b/packages/core/src/config/__tests__/merge.test.ts index 25fb80d8..3413256c 100644 --- a/packages/core/src/config/__tests__/merge.test.ts +++ b/packages/core/src/config/__tests__/merge.test.ts @@ -3,6 +3,9 @@ import type { ExtendedDefaults } from "../merge.js"; import { deepMerge, mergeAgentConfig, mergeAllAgentConfigs } from "../merge.js"; import type { AgentConfig } from "../schema.js"; +/** Default self_scheduling value matching the Zod schema default */ +const DEFAULT_SELF_SCHEDULING = { enabled: true, max_schedules: 10, min_interval: "5m" } as const; + describe("deepMerge", () => { describe("basic behavior", () => { it("returns undefined when both inputs are undefined", () => { @@ -220,6 +223,7 @@ describe("mergeAgentConfig", () => { const agent: AgentConfig = { name: "test-agent", model: "claude-sonnet-4-20250514", + self_scheduling: DEFAULT_SELF_SCHEDULING, }; const result = mergeAgentConfig(undefined, agent); expect(result).toEqual(agent); @@ -232,7 +236,7 @@ describe("mergeAgentConfig", () => { permission_mode: "acceptEdits", allowed_tools: ["Read", "Write"], }; - const agent: AgentConfig = { name: "test-agent" }; + const agent: AgentConfig = { name: "test-agent", self_scheduling: DEFAULT_SELF_SCHEDULING }; const result = mergeAgentConfig(defaults, agent); expect(result.permission_mode).toBe("acceptEdits"); expect(result.allowed_tools).toEqual(["Read", "Write"]); @@ -245,6 +249,7 @@ describe("mergeAgentConfig", () => { const agent: AgentConfig = { name: "test-agent", allowed_tools: ["Bash(git *)"], + self_scheduling: DEFAULT_SELF_SCHEDULING, }; const result = mergeAgentConfig(defaults, agent); // Agent's allowed_tools should completely replace defaults (arrays are not merged) @@ -255,7 +260,7 @@ describe("mergeAgentConfig", () => { const defaults: ExtendedDefaults = { denied_tools: ["WebFetch", "Bash(rm *)"], }; - const agent: AgentConfig = { name: "test-agent" }; + const agent: AgentConfig = { name: "test-agent", self_scheduling: DEFAULT_SELF_SCHEDULING }; const result = mergeAgentConfig(defaults, agent); expect(result.denied_tools).toEqual(["WebFetch", "Bash(rm *)"]); }); @@ -267,6 +272,7 @@ describe("mergeAgentConfig", () => { const agent: AgentConfig = { name: "test-agent", denied_tools: ["Bash(sudo *)"], + self_scheduling: DEFAULT_SELF_SCHEDULING, }; const result = mergeAgentConfig(defaults, agent); expect(result.denied_tools).toEqual(["Bash(sudo *)"]); @@ -281,7 +287,7 @@ describe("mergeAgentConfig", () => { labels: { ready: "ready", in_progress: "in-progress" }, }, }; - const agent: AgentConfig = { name: "test-agent" }; + const agent: AgentConfig = { name: "test-agent", self_scheduling: DEFAULT_SELF_SCHEDULING }; const result = mergeAgentConfig(defaults, agent); expect(result.work_source?.type).toBe("github"); expect(result.work_source?.labels?.ready).toBe("ready"); @@ -300,6 +306,7 @@ describe("mergeAgentConfig", () => { type: "github", labels: { ready: "custom-ready" }, }, + self_scheduling: DEFAULT_SELF_SCHEDULING, }; const result = mergeAgentConfig(defaults, agent); expect(result.work_source?.labels?.ready).toBe("custom-ready"); @@ -316,7 +323,7 @@ describe("mergeAgentConfig", () => { model: "claude-sonnet-4-20250514", }, }; - const agent: AgentConfig = { name: "test-agent" }; + const agent: AgentConfig = { name: "test-agent", self_scheduling: DEFAULT_SELF_SCHEDULING }; const result = mergeAgentConfig(defaults, agent); expect(result.session?.max_turns).toBe(50); expect(result.session?.timeout).toBe("30m"); @@ -335,6 +342,7 @@ describe("mergeAgentConfig", () => { session: { max_turns: 100, }, + self_scheduling: DEFAULT_SELF_SCHEDULING, }; const result = mergeAgentConfig(defaults, agent); expect(result.session?.max_turns).toBe(100); @@ -355,7 +363,7 @@ describe("mergeAgentConfig", () => { image: "herdctl-base:latest", }, }; - const agent: AgentConfig = { name: "test-agent" }; + const agent: AgentConfig = { name: "test-agent", self_scheduling: DEFAULT_SELF_SCHEDULING }; const result = mergeAgentConfig(defaults, agent); expect(result.docker?.enabled).toBe(true); // Fleet-level image is preserved in merged result @@ -384,6 +392,7 @@ describe("mergeAgentConfig", () => { max_containers: 10, workspace_mode: "ro" as const, }, + self_scheduling: DEFAULT_SELF_SCHEDULING, }; const result = mergeAgentConfig(defaults, agent); expect(result.docker?.enabled).toBe(false); @@ -400,7 +409,7 @@ describe("mergeAgentConfig", () => { max_concurrent: 3, }, }; - const agent: AgentConfig = { name: "test-agent" }; + const agent: AgentConfig = { name: "test-agent", self_scheduling: DEFAULT_SELF_SCHEDULING }; const result = mergeAgentConfig(defaults, agent); expect(result.instances?.max_concurrent).toBe(3); }); @@ -416,6 +425,7 @@ describe("mergeAgentConfig", () => { instances: { max_concurrent: 5, }, + self_scheduling: DEFAULT_SELF_SCHEDULING, }; const result = mergeAgentConfig(defaults, agent); expect(result.instances?.max_concurrent).toBe(5); @@ -425,7 +435,7 @@ describe("mergeAgentConfig", () => { const defaults: ExtendedDefaults = { model: "claude-sonnet-4-20250514", }; - const agent: AgentConfig = { name: "test-agent" }; + const agent: AgentConfig = { name: "test-agent", self_scheduling: DEFAULT_SELF_SCHEDULING }; const result = mergeAgentConfig(defaults, agent); expect(result.instances).toBeUndefined(); }); @@ -436,7 +446,7 @@ describe("mergeAgentConfig", () => { const defaults: ExtendedDefaults = { model: "claude-sonnet-4-20250514", }; - const agent: AgentConfig = { name: "test-agent" }; + const agent: AgentConfig = { name: "test-agent", self_scheduling: DEFAULT_SELF_SCHEDULING }; const result = mergeAgentConfig(defaults, agent); expect(result.model).toBe("claude-sonnet-4-20250514"); }); @@ -448,6 +458,7 @@ describe("mergeAgentConfig", () => { const agent: AgentConfig = { name: "test-agent", model: "claude-opus-4-20250514", + self_scheduling: DEFAULT_SELF_SCHEDULING, }; const result = mergeAgentConfig(defaults, agent); expect(result.model).toBe("claude-opus-4-20250514"); @@ -457,7 +468,7 @@ describe("mergeAgentConfig", () => { const defaults: ExtendedDefaults = { max_turns: 50, }; - const agent: AgentConfig = { name: "test-agent" }; + const agent: AgentConfig = { name: "test-agent", self_scheduling: DEFAULT_SELF_SCHEDULING }; const result = mergeAgentConfig(defaults, agent); expect(result.max_turns).toBe(50); }); @@ -469,6 +480,7 @@ describe("mergeAgentConfig", () => { const agent: AgentConfig = { name: "test-agent", max_turns: 100, + self_scheduling: DEFAULT_SELF_SCHEDULING, }; const result = mergeAgentConfig(defaults, agent); expect(result.max_turns).toBe(100); @@ -478,7 +490,7 @@ describe("mergeAgentConfig", () => { const defaults: ExtendedDefaults = { permission_mode: "acceptEdits", }; - const agent: AgentConfig = { name: "test-agent" }; + const agent: AgentConfig = { name: "test-agent", self_scheduling: DEFAULT_SELF_SCHEDULING }; const result = mergeAgentConfig(defaults, agent); expect(result.permission_mode).toBe("acceptEdits"); }); @@ -490,6 +502,7 @@ describe("mergeAgentConfig", () => { const agent: AgentConfig = { name: "test-agent", permission_mode: "bypassPermissions", + self_scheduling: DEFAULT_SELF_SCHEDULING, }; const result = mergeAgentConfig(defaults, agent); expect(result.permission_mode).toBe("bypassPermissions"); @@ -499,7 +512,7 @@ describe("mergeAgentConfig", () => { describe("preserves non-mergeable fields", () => { it("preserves agent name", () => { const defaults: ExtendedDefaults = { model: "default-model" }; - const agent: AgentConfig = { name: "my-agent" }; + const agent: AgentConfig = { name: "my-agent", self_scheduling: DEFAULT_SELF_SCHEDULING }; const result = mergeAgentConfig(defaults, agent); expect(result.name).toBe("my-agent"); }); @@ -509,6 +522,7 @@ describe("mergeAgentConfig", () => { const agent: AgentConfig = { name: "my-agent", description: "A test agent", + self_scheduling: DEFAULT_SELF_SCHEDULING, }; const result = mergeAgentConfig(defaults, agent); expect(result.description).toBe("A test agent"); @@ -519,6 +533,7 @@ describe("mergeAgentConfig", () => { const agent: AgentConfig = { name: "my-agent", working_directory: "/path/to/workspace", + self_scheduling: DEFAULT_SELF_SCHEDULING, }; const result = mergeAgentConfig(defaults, agent); expect(result.working_directory).toBe("/path/to/workspace"); @@ -529,6 +544,7 @@ describe("mergeAgentConfig", () => { const agent: AgentConfig = { name: "my-agent", identity: { name: "Claude", role: "assistant" }, + self_scheduling: DEFAULT_SELF_SCHEDULING, }; const result = mergeAgentConfig(defaults, agent); expect(result.identity?.name).toBe("Claude"); @@ -547,6 +563,7 @@ describe("mergeAgentConfig", () => { resume_session: false, }, }, + self_scheduling: DEFAULT_SELF_SCHEDULING, }; const result = mergeAgentConfig(defaults, agent); expect(result.schedules?.main.type).toBe("interval"); @@ -560,6 +577,7 @@ describe("mergeAgentConfig", () => { mcp_servers: { github: { command: "npx", args: ["-y", "@mcp/github"] }, }, + self_scheduling: DEFAULT_SELF_SCHEDULING, }; const result = mergeAgentConfig(defaults, agent); expect(result.mcp_servers?.github.command).toBe("npx"); @@ -587,6 +605,7 @@ describe("mergeAgentConfig", () => { const agent: AgentConfig = { name: "my-agent", system_prompt: "You are helpful.", + self_scheduling: DEFAULT_SELF_SCHEDULING, }; const result = mergeAgentConfig(defaults, agent); expect(result.system_prompt).toBe("You are helpful."); @@ -597,6 +616,7 @@ describe("mergeAgentConfig", () => { const agent: AgentConfig = { name: "my-agent", repo: "https://github.com/example/repo", + self_scheduling: DEFAULT_SELF_SCHEDULING, }; const result = mergeAgentConfig(defaults, agent); expect(result.repo).toBe("https://github.com/example/repo"); @@ -644,6 +664,7 @@ describe("mergeAgentConfig", () => { }, }, model: "claude-opus-4-20250514", + self_scheduling: DEFAULT_SELF_SCHEDULING, }; const result = mergeAgentConfig(defaults, agent); @@ -687,9 +708,9 @@ describe("mergeAllAgentConfigs", () => { max_turns: 50, }; const agents: AgentConfig[] = [ - { name: "agent-1" }, - { name: "agent-2", model: "claude-opus-4-20250514" }, - { name: "agent-3", max_turns: 100 }, + { name: "agent-1", self_scheduling: DEFAULT_SELF_SCHEDULING }, + { name: "agent-2", model: "claude-opus-4-20250514", self_scheduling: DEFAULT_SELF_SCHEDULING }, + { name: "agent-3", max_turns: 100, self_scheduling: DEFAULT_SELF_SCHEDULING }, ]; const result = mergeAllAgentConfigs(defaults, agents); @@ -714,25 +735,25 @@ describe("mergeAllAgentConfigs", () => { it("returns agents unchanged when defaults is undefined", () => { const agents: AgentConfig[] = [ - { name: "agent-1", model: "model-1" }, - { name: "agent-2", model: "model-2" }, + { name: "agent-1", model: "model-1", self_scheduling: DEFAULT_SELF_SCHEDULING }, + { name: "agent-2", model: "model-2", self_scheduling: DEFAULT_SELF_SCHEDULING }, ]; const result = mergeAllAgentConfigs(undefined, agents); expect(result).toHaveLength(2); - expect(result[0]).toEqual({ name: "agent-1", model: "model-1" }); - expect(result[1]).toEqual({ name: "agent-2", model: "model-2" }); + expect(result[0]).toEqual({ name: "agent-1", model: "model-1", self_scheduling: DEFAULT_SELF_SCHEDULING }); + expect(result[1]).toEqual({ name: "agent-2", model: "model-2", self_scheduling: DEFAULT_SELF_SCHEDULING }); }); it("does not mutate original agents array", () => { const defaults: ExtendedDefaults = { model: "default-model" }; - const agents: AgentConfig[] = [{ name: "agent-1" }]; + const agents: AgentConfig[] = [{ name: "agent-1", self_scheduling: DEFAULT_SELF_SCHEDULING }]; const result = mergeAllAgentConfigs(defaults, agents); expect(result).not.toBe(agents); - expect(agents[0]).toEqual({ name: "agent-1" }); + expect(agents[0]).toEqual({ name: "agent-1", self_scheduling: DEFAULT_SELF_SCHEDULING }); expect(result[0].model).toBe("default-model"); }); }); diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 11d5e17b..0581ed23 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -988,7 +988,7 @@ export const AgentWorkingDirectorySchema = z.union([z.string(), WorkingDirectory */ export const SelfSchedulingSchema = z.object({ /** Whether self-scheduling is enabled for this agent */ - enabled: z.boolean().default(false), + enabled: z.boolean().default(true), /** Maximum number of dynamic schedules this agent can create (default: 10) */ max_schedules: z.number().int().positive().optional().default(10), /** Minimum interval between schedule triggers (default: "5m") */ @@ -1040,8 +1040,9 @@ export const AgentConfigSchema = z denied_tools: z.array(z.string()).optional(), /** Path to metadata JSON file written by agent (default: metadata.json in workspace) */ metadata_file: z.string().optional(), - /** Self-scheduling configuration — allows agent to create its own schedules at runtime */ - self_scheduling: SelfSchedulingSchema.optional(), + /** Self-scheduling configuration — allows agent to create its own schedules at runtime. + * Defaults to enabled with standard limits when omitted. Set enabled: false to disable. */ + self_scheduling: SelfSchedulingSchema.optional().default({}), /** * Setting sources for Claude SDK configuration discovery. * Controls where Claude looks for CLAUDE.md, skills, commands, etc. diff --git a/packages/core/src/runner/__tests__/job-executor.test.ts b/packages/core/src/runner/__tests__/job-executor.test.ts index 465e0175..f4946edd 100644 --- a/packages/core/src/runner/__tests__/job-executor.test.ts +++ b/packages/core/src/runner/__tests__/job-executor.test.ts @@ -32,6 +32,7 @@ function createTestAgent(overrides: Partial = {}): ResolvedAgent configPath: "/path/to/agent.yaml", fleetPath: [], qualifiedName: overrides.name ?? "test-agent", + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, ...overrides, }; } diff --git a/packages/core/src/runner/__tests__/sdk-adapter.test.ts b/packages/core/src/runner/__tests__/sdk-adapter.test.ts index 31aaee5f..4712bc61 100644 --- a/packages/core/src/runner/__tests__/sdk-adapter.test.ts +++ b/packages/core/src/runner/__tests__/sdk-adapter.test.ts @@ -17,6 +17,7 @@ function createTestAgent(overrides: Partial = {}): ResolvedAgent configPath: "/path/to/agent.yaml", fleetPath: [], qualifiedName: overrides.name ?? "test-agent", + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, ...overrides, }; } diff --git a/packages/core/src/scheduler/__tests__/scheduler.test.ts b/packages/core/src/scheduler/__tests__/scheduler.test.ts index e79ed502..d335b897 100644 --- a/packages/core/src/scheduler/__tests__/scheduler.test.ts +++ b/packages/core/src/scheduler/__tests__/scheduler.test.ts @@ -54,6 +54,7 @@ function createTestAgent( fleetPath: [], configPath: `/fake/path/${name}.yaml`, schedules, + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, } as ResolvedAgent; } diff --git a/packages/core/src/work-sources/__tests__/manager.test.ts b/packages/core/src/work-sources/__tests__/manager.test.ts index 15ffba81..4a5f5528 100644 --- a/packages/core/src/work-sources/__tests__/manager.test.ts +++ b/packages/core/src/work-sources/__tests__/manager.test.ts @@ -40,6 +40,7 @@ function createMockAgent(overrides: Partial = {}): ResolvedAgent configPath: "/path/to/agent.yaml", fleetPath: [], qualifiedName: overrides.name ?? "test-agent", + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, work_source: { type: "github", repo: "org/repo", diff --git a/packages/discord/src/__tests__/discord-connector.test.ts b/packages/discord/src/__tests__/discord-connector.test.ts index e77c19a9..868aa1cb 100644 --- a/packages/discord/src/__tests__/discord-connector.test.ts +++ b/packages/discord/src/__tests__/discord-connector.test.ts @@ -112,6 +112,7 @@ function createMockAgentConfig(): AgentConfig { return { name: "test-agent", description: "Test agent for Discord connector tests", + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, }; } diff --git a/packages/discord/src/__tests__/manager.test.ts b/packages/discord/src/__tests__/manager.test.ts index 27526cc7..d49dda66 100644 --- a/packages/discord/src/__tests__/manager.test.ts +++ b/packages/discord/src/__tests__/manager.test.ts @@ -76,6 +76,7 @@ function createDiscordAgent(name: string, discordConfig: AgentChatDiscord): Reso configPath: "/test/herdctl.yaml", fleetPath: [], qualifiedName: name, + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, } as ResolvedAgent; } @@ -88,6 +89,7 @@ function createNonDiscordAgent(name: string): ResolvedAgent { configPath: "/test/herdctl.yaml", fleetPath: [], qualifiedName: name, + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, } as ResolvedAgent; } @@ -4976,6 +4978,7 @@ describe.skip("DiscordManager output configuration", () => { configPath: "/test/herdctl.yaml", fleetPath: [], qualifiedName: "no-tool-results-agent", + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, } as ResolvedAgent, ], configPath: "/test/herdctl.yaml", @@ -5148,6 +5151,7 @@ describe.skip("DiscordManager output configuration", () => { configPath: "/test/herdctl.yaml", fleetPath: [], qualifiedName: "system-status-agent", + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, } as ResolvedAgent, ], configPath: "/test/herdctl.yaml", @@ -5319,6 +5323,7 @@ describe.skip("DiscordManager output configuration", () => { configPath: "/test/herdctl.yaml", fleetPath: [], qualifiedName: "no-system-status-agent", + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, } as ResolvedAgent, ], configPath: "/test/herdctl.yaml", @@ -5491,6 +5496,7 @@ describe.skip("DiscordManager output configuration", () => { configPath: "/test/herdctl.yaml", fleetPath: [], qualifiedName: "result-summary-agent", + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, } as ResolvedAgent, ], configPath: "/test/herdctl.yaml", @@ -5666,6 +5672,7 @@ describe.skip("DiscordManager output configuration", () => { configPath: "/test/herdctl.yaml", fleetPath: [], qualifiedName: "error-agent", + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, } as ResolvedAgent, ], configPath: "/test/herdctl.yaml", @@ -5836,6 +5843,7 @@ describe.skip("DiscordManager output configuration", () => { configPath: "/test/herdctl.yaml", fleetPath: [], qualifiedName: "no-errors-agent", + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, } as ResolvedAgent, ], configPath: "/test/herdctl.yaml", diff --git a/packages/slack/src/__tests__/manager.test.ts b/packages/slack/src/__tests__/manager.test.ts index 34454518..2f60381b 100644 --- a/packages/slack/src/__tests__/manager.test.ts +++ b/packages/slack/src/__tests__/manager.test.ts @@ -70,6 +70,7 @@ function createSlackAgent(name: string, slackConfig: AgentChatSlack): ResolvedAg configPath: "/test/herdctl.yaml", fleetPath: [], qualifiedName: name, + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, } as ResolvedAgent; } @@ -81,6 +82,7 @@ function createNonSlackAgent(name: string): ResolvedAgent { configPath: "/test/herdctl.yaml", fleetPath: [], qualifiedName: name, + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, } as ResolvedAgent; } diff --git a/packages/web/src/server/chat/web-chat-manager.ts b/packages/web/src/server/chat/web-chat-manager.ts index 29dee64c..0a5fa728 100644 --- a/packages/web/src/server/chat/web-chat-manager.ts +++ b/packages/web/src/server/chat/web-chat-manager.ts @@ -431,6 +431,7 @@ export class WebChatManager { working_directory: workingDirectory, runtime: "cli", permission_mode: "default", + self_scheduling: { enabled: true, max_schedules: 10, min_interval: "5m" }, } as ResolvedAgent; }