diff --git a/.beads/backup/backup_state.json b/.beads/backup/backup_state.json new file mode 100644 index 00000000..b5c2cfbc --- /dev/null +++ b/.beads/backup/backup_state.json @@ -0,0 +1,13 @@ +{ + "last_dolt_commit": "mcgoglttb785sns4t2u2d5avsvj06ql0", + "last_event_id": 0, + "timestamp": "2026-03-04T23:50:42.764912Z", + "counts": { + "issues": 10, + "events": 10, + "comments": 0, + "dependencies": 16, + "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..06943246 --- /dev/null +++ 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 new file mode 100644 index 00000000..52d73c6c --- /dev/null +++ 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 new file mode 100644 index 00000000..6db080b1 --- /dev/null +++ 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":""} diff --git a/.beads/backup/labels.jsonl b/.beads/backup/labels.jsonl new file mode 100644 index 00000000..e69de29b 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/.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/.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/.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/.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..74ad5815 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):** @@ -562,8 +577,23 @@ 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 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 | +| `/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 + +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: @@ -584,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/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/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__/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/__tests__/schema.test.ts b/packages/core/src/config/__tests__/schema.test.ts index 11a65a45..f2e09128 100644 --- a/packages/core/src/config/__tests__/schema.test.ts +++ b/packages/core/src/config/__tests__/schema.test.ts @@ -1710,6 +1710,10 @@ describe("AgentChatDiscordSchema", () => { system_status: true, result_summary: false, errors: true, + typing_indicator: true, + acknowledge_emoji: "👀", + assistant_messages: "answers", + progress_indicator: true, }); } }); @@ -1744,6 +1748,58 @@ 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); + }); + + 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/__tests__/self-scheduling.test.ts b/packages/core/src/config/__tests__/self-scheduling.test.ts new file mode 100644 index 00000000..14b14739 --- /dev/null +++ b/packages/core/src/config/__tests__/self-scheduling.test.ts @@ -0,0 +1,222 @@ +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.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"); + 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(); + }); + + 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("injects system prompt even 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, 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/index.ts b/packages/core/src/config/index.ts index cd283f05..5ab6ecc4 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, @@ -109,6 +111,8 @@ export { type DiscordPresence, // Agent Chat Discord schemas DiscordPresenceSchema, + type DiscordVoice, + DiscordVoiceSchema, type Docker, DockerSchema, // Types @@ -143,6 +147,8 @@ export { ScheduleSchema, type ScheduleType, ScheduleTypeSchema, + type SelfScheduling, + SelfSchedulingSchema, type Session, SessionSchema, type ShellHookConfig, @@ -172,3 +178,6 @@ export { type WorkSourceType, WorkSourceTypeSchema, } from "./schema.js"; + +// Self-scheduling injection +export { injectSchedulerMcpServers } from "./self-scheduling.js"; 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 e6d200f5..0581ed23 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(), }); // ============================================================================= @@ -495,6 +501,10 @@ 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(), }); // ============================================================================= @@ -575,6 +585,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(), }); @@ -614,13 +626,122 @@ 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) */ + /** Show a summary embed when the agent finishes a turn (cost, tokens, turns) (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: "👀") */ + acknowledge_emoji: z.string().optional().default("👀"), + /** 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), +}); + +/** + * 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"], + }); + +/** + * 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 + * + * 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(), +}); + +/** + * 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") + .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), }); /** @@ -668,6 +789,14 @@ 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(), + /** File attachment handling configuration */ + 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(), }); // ============================================================================= @@ -838,6 +967,40 @@ 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(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") */ + 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)", + }), +}); + // ============================================================================= // Agent Configuration Schema // ============================================================================= @@ -877,6 +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. + * 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. @@ -1070,12 +1236,15 @@ 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 DiscordAttachments = z.infer; export type AgentChatDiscord = z.infer; export type AgentChat = z.infer; // Agent Chat Slack types 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..bb688ad3 --- /dev/null +++ b/packages/core/src/config/self-scheduling.ts @@ -0,0 +1,109 @@ +/** + * 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 and appends a + * system prompt snippet that teaches the agent how to use self-scheduling. + * + * 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"); +} + +/** + * 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 + */ +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)) { + 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(","), + }), + }, + // 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, + }; + } + + // 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; + } else { + agent.system_prompt = schedulingPrompt; + } + } +} 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/job-control.ts b/packages/core/src/fleet-manager/job-control.ts index 34045c62..c9247d90 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"; @@ -163,8 +163,10 @@ export class JobControl { schedule: scheduleName, outputToFile: schedule?.outputToFile ?? false, onMessage: options?.onMessage, + onJobCreated: options?.onJobCreated, resume: sessionId, injectedMcpServers: options?.injectedMcpServers, + systemPromptAppend: options?.systemPromptAppend, }); // Emit job:created event @@ -218,7 +220,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/schedule-management.ts b/packages/core/src/fleet-manager/schedule-management.ts index 37f00727..1189373a 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 { 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"; @@ -40,13 +41,53 @@ 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 (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) { 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; @@ -74,29 +115,61 @@ 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(); + 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]; + 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 (error) { + const msg = error instanceof Error ? error.message : String(error); + this.ctx + .getLogger() + .warn(`Failed to load dynamic schedules for ${agent.qualifiedName}: ${msg}`); + } + + const staticNames = agent.schedules ? Object.keys(agent.schedules) : []; + const availableSchedules = [...new Set([...staticNames, ...dynamicScheduleNames])]; + throw new ScheduleNotFoundError(agentName, scheduleName, { + availableSchedules, + }); } /** @@ -117,9 +190,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); @@ -129,12 +201,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"); @@ -171,9 +239,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); @@ -183,12 +250,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/fleet-manager/status-queries.ts b/packages/core/src/fleet-manager/status-queries.ts index f1cb0ed8..0380bbb3 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 { 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"; @@ -143,9 +145,19 @@ 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 (error) { + const msg = error instanceof Error ? error.message : String(error); + this.ctx.getLogger().warn(`Failed to load dynamic schedules: ${msg}`); + } + 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 +198,21 @@ 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 specific agent (avoids reading all agents' files) + let agentDynamic: Record | undefined; + try { + 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 + .getLogger() + .warn(`Failed to load dynamic schedules for ${agent.qualifiedName}: ${msg}`); + } + + return buildAgentInfo(agent, agentState, this.ctx.getScheduler(), chatManagers, agentDynamic); } } @@ -201,6 +227,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 +235,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; @@ -347,6 +396,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..e4604c29 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"; } /** @@ -523,9 +528,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 @@ -577,6 +583,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 * @@ -588,6 +602,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/mcp/scheduler-mcp.ts b/packages/core/src/mcp/scheduler-mcp.ts new file mode 100644 index 00000000..0a3e4d4b --- /dev/null +++ b/packages/core/src/mcp/scheduler-mcp.ts @@ -0,0 +1,321 @@ +#!/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 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(""), +}); + +const envResult = EnvSchema.safeParse(process.env); +if (!envResult.success) { + console.error(`Invalid environment: ${envResult.error.issues.map((i) => i.message).join(", ")}`); + 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, + 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() + .max(10000) + .describe( + "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() + .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/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/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..f67ef07d 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, }); @@ -329,6 +330,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/__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/__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/cli-runtime.ts b/packages/core/src/runner/runtime/cli-runtime.ts index 43471771..331c8b60 100644 --- a/packages/core/src/runner/runtime/cli-runtime.ts +++ b/packages/core/src/runner/runtime/cli-runtime.ts @@ -17,10 +17,12 @@ 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"; import type { RuntimeExecuteOptions, RuntimeInterface } from "./interface.js"; +import { type McpHttpBridge, startMcpHttpBridge } from "./mcp-http-bridge.js"; const logger = createLogger("CLIRuntime"); @@ -63,6 +65,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; } /** @@ -98,6 +108,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 @@ -111,6 +122,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 @@ -146,9 +158,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) @@ -170,12 +189,89 @@ 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); } + // 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)) { + 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 + // 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://${this.mcpBridgeHost}:${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. + // 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]; + 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. + // 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"; + } + } + } + // Add session options if (options.resume) { args.push("--resume", options.resume); @@ -185,16 +281,75 @@ 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; 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 = ""; + 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?: { + 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; @@ -218,13 +373,24 @@ 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}`); - // 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()); @@ -262,12 +428,31 @@ 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: 15000, // Increase timeout to 15 seconds for debugging + 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. + // 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}`); } @@ -323,10 +508,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; @@ -335,6 +526,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; } } @@ -359,10 +551,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 @@ -373,6 +574,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) { @@ -408,6 +630,24 @@ export class CLIRuntime implements RuntimeInterface { } finally { // 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 { + await bridge.close(); + } catch (err) { + logger.error(`Failed to close MCP HTTP bridge: ${err}`); + } + } } } } 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/container-runner.ts b/packages/core/src/runner/runtime/container-runner.ts index a71728ba..f8e2b92e 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) { @@ -146,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 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 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..135589fd 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; @@ -31,8 +31,11 @@ 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 */ + systemPromptAppend?: string; } /** 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]; +} diff --git a/packages/core/src/runner/runtime/sdk-runtime.ts b/packages/core/src/runner/runtime/sdk-runtime.ts index 4329e11f..c46b6fe1 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"; /** @@ -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 ?? {}; @@ -146,8 +165,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/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 025d8196..ed34a5cf 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 */ @@ -35,8 +86,10 @@ 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; } /** @@ -204,6 +257,7 @@ export interface InjectedMcpServerDef { export interface SDKMcpServerConfig { type?: "http"; url?: string; + headers?: Record; command?: string; args?: string[]; env?: Record; 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..2163c3c9 --- /dev/null +++ b/packages/core/src/scheduler/__tests__/dynamic-schedules.test.ts @@ -0,0 +1,670 @@ +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("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", + ); + }); + }); + + // =========================================================================== + // 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/__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/scheduler/dynamic-schedules.ts b/packages/core/src/scheduler/dynamic-schedules.ts new file mode 100644 index 00000000..40c80a6c --- /dev/null +++ b/packages/core/src/scheduler/dynamic-schedules.ts @@ -0,0 +1,526 @@ +/** + * 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: 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 { 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 +// ============================================================================= + +/** + * 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; + +/** + * 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 + */ +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. + // 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 ${QUALIFIED_NAME_PATTERN.source} (no ".." sequences)`, + ); + } + return join(getDynamicSchedulesDir(stateDir), `${agentName}.yaml`); +} + +// ============================================================================= +// Read / Write +// ============================================================================= + +/** + * Read dynamic schedules for an agent from disk. + * + * 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); + + 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: {} }; + } + + return DynamicScheduleFileSchema.parse(raw); +} + +/** + * 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(); + for (const [name, schedule] of Object.entries(file.schedules)) { + if (schedule.expires_at && new Date(schedule.expires_at) <= now) { + delete file.schedules[name]; + } + } +} + +/** + * 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.name, 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 (raw — includes expired entries for cleanup) + const file = await readRawDynamicSchedules(stateDir, agentName); + stripExpired(file); + + // 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 (against non-expired count) + if (Object.keys(file.schedules).length >= options.maxSchedules) { + throw new ScheduleLimitExceededError(agentName, options.maxSchedules); + } + + // Build and validate the schedule before persisting + const now = new Date().toISOString(); + const schedule = DynamicScheduleSchema.parse({ + 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 readRawDynamicSchedules(stateDir, agentName); + stripExpired(file); + + 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(scheduleName, 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 — 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 + updated.ttl_hours = undefined; + updated.expires_at = null; + } else { + updated.ttl_hours = updates.ttl_hours; + updated.expires_at = new Date(Date.now() + updates.ttl_hours * 3600000).toISOString(); + } + } + + const validated = DynamicScheduleSchema.parse(updated); + file.schedules[scheduleName] = validated; + await writeDynamicSchedules(stateDir, agentName, file); + + return validated; +} + +/** + * Delete a dynamic schedule + */ +export async function deleteDynamicSchedule( + stateDir: string, + agentName: string, + scheduleName: string, +): Promise { + const file = await readRawDynamicSchedules(stateDir, agentName); + stripExpired(file); + + 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 (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); + logger.warn(`Failed to load schedules for "${agentName}": ${msg}`); + } + } + + 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( + scheduleName: string, + 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(scheduleName, 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( + scheduleName, + `~${Math.round(gapMs / 1000)}s`, + minInterval, + ); + } + } 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/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..9519a5b4 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 { type DynamicSchedule, loadAllDynamicSchedules } from "./dynamic-schedules.js"; import { SchedulerShutdownError } from "./errors.js"; import { calculateNextTrigger, isScheduleDue } from "./interval.js"; import { @@ -106,6 +107,11 @@ 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> = 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) + constructor(options: SchedulerOptions) { this.checkInterval = options.checkInterval ?? DEFAULT_CHECK_INTERVAL; this.stateDir = options.stateDir; @@ -163,6 +169,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`, @@ -297,18 +305,41 @@ 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 { + this.dynamicScheduleCache = await loadAllDynamicSchedules(this.stateDir); + 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) { 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; } 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/README.md b/packages/discord/README.md index 5f4fbd69..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 `/status`, `/reset`, and `/help` commands +- **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,8 +149,21 @@ 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. ## 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__/attachments.test.ts b/packages/discord/src/__tests__/attachments.test.ts new file mode 100644 index 00000000..ff7003fb --- /dev/null +++ b/packages/discord/src/__tests__/attachments.test.ts @@ -0,0 +1,620 @@ +/** + * 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 { 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"; +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"); + }); +}); + +// ============================================================================= +// 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 +// ============================================================================= + +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 () => { + vi.unstubAllGlobals(); + 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("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]); + 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("att_1-document.pdf"); + expect(result.downloadedPaths).toHaveLength(1); + expect(basename(result.downloadedPaths[0])).toBe("att_1-document.pdf"); + + 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(); + }); + + 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(); + }); +}); + +// ============================================================================= +// 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/__tests__/discord-connector.test.ts b/packages/discord/src/__tests__/discord-connector.test.ts index 553e2fa4..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" }, }; } @@ -124,8 +125,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", + assistant_messages: "answers" as const, + progress_indicator: true, }, guilds: [ { 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__/logger.test.ts b/packages/discord/src/__tests__/logger.test.ts index fb73859e..6dfe32e8 100644 --- a/packages/discord/src/__tests__/logger.test.ts +++ b/packages/discord/src/__tests__/logger.test.ts @@ -20,8 +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", + assistant_messages: "answers" as const, + progress_indicator: true, }, guilds: [], }; diff --git a/packages/discord/src/__tests__/manager.test.ts b/packages/discord/src/__tests__/manager.test.ts index ce4b123a..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; } @@ -177,8 +179,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", + assistant_messages: "answers" as const, + progress_indicator: true, }, guilds: [], }; @@ -239,6 +245,177 @@ 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("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); @@ -404,6 +581,12 @@ describe("DiscordMessageEvent type", () => { console.log("Reply:", content); }, 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"); @@ -432,6 +615,12 @@ describe("DiscordMessageEvent type", () => { }, reply: async () => {}, 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(); @@ -701,6 +890,805 @@ 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: "", + assistant_messages: "answers" as const, + progress_indicator: false, // disable for cleaner assertions + }, + 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 }; + } + + // ---- answers mode: suppresses reasoning turns, sends answer turns ---- + + 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) { + // Turn 1: reasoning (text + tool_use) — should be suppressed + await options.onMessage({ + type: "assistant", + message: { + id: "msg_1", + 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: { + 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 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."); + }); + + 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 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) { + // 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 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) { + // 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 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).toHaveLength(0); + expect(textCalls).toHaveLength(1); + expect(textCalls[0][0]).toBe("Found 2 files."); + }); + + // ---- No system prompt injection (concise_mode removed) ---- + + it("does not inject a systemPromptAppend", 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).toBeUndefined(); + }); + + // ---- 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)); + + // 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."); + }); + + // ---- "all" mode: sends every assistant turn with text ---- + + 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; + 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: use "all" mode to send every turn + { + 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 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."); + }); + + 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 // The new DiscordManager uses this.ctx.trigger() directly instead of emitter.trigger // and delegates message extraction/splitting to @herdctl/chat @@ -731,7 +1719,11 @@ describe.skip("DiscordManager message handling", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", + assistant_messages: "answers" as const, + progress_indicator: true, }, guilds: [], }), @@ -867,6 +1859,12 @@ describe.skip("DiscordManager message handling", () => { }, reply: replyMock, 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 @@ -916,7 +1914,11 @@ describe.skip("DiscordManager message handling", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", + assistant_messages: "answers" as const, + progress_indicator: true, }, guilds: [], }), @@ -1021,6 +2023,12 @@ describe.skip("DiscordManager message handling", () => { }, reply: replyMock, 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 @@ -1063,7 +2071,11 @@ describe.skip("DiscordManager message handling", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", + assistant_messages: "answers" as const, + progress_indicator: true, }, guilds: [], }), @@ -1168,6 +2180,12 @@ describe.skip("DiscordManager message handling", () => { }, reply: replyMock, 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 @@ -1241,7 +2259,11 @@ describe.skip("DiscordManager message handling", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", + assistant_messages: "answers" as const, + progress_indicator: true, }, guilds: [], }), @@ -1343,6 +2365,12 @@ describe.skip("DiscordManager message handling", () => { }, reply: replyMock, 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); @@ -1358,7 +2386,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) @@ -1402,7 +2430,11 @@ describe.skip("DiscordManager message handling", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", + assistant_messages: "answers" as const, + progress_indicator: true, }, guilds: [], }), @@ -1504,6 +2536,12 @@ describe.skip("DiscordManager message handling", () => { }, reply: replyMock, 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); @@ -1515,7 +2553,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 @@ -1624,6 +2662,12 @@ describe.skip("DiscordManager message handling", () => { }, reply: replyMock, 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 @@ -1750,6 +2794,12 @@ describe.skip("DiscordManager message handling", () => { }, reply: replyMock, 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 @@ -1824,6 +2874,12 @@ describe.skip("DiscordManager message handling", () => { }, reply: replyMock, 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 @@ -1898,6 +2954,12 @@ describe.skip("DiscordManager message handling", () => { }, reply: replyMock, 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 @@ -2470,7 +3532,11 @@ describe.skip("DiscordManager session integration", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", + assistant_messages: "answers" as const, + progress_indicator: true, }, guilds: [], }), @@ -2564,6 +3630,12 @@ describe.skip("DiscordManager session integration", () => { }, reply: replyMock, 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 @@ -2635,6 +3707,12 @@ describe.skip("DiscordManager session integration", () => { }, reply: replyMock, 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 @@ -2708,6 +3786,12 @@ describe.skip("DiscordManager session integration", () => { }, reply: replyMock, 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 @@ -2783,6 +3867,12 @@ describe.skip("DiscordManager session integration", () => { }, reply: replyMock, 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 @@ -3157,7 +4247,11 @@ describe.skip("DiscordManager lifecycle", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", + assistant_messages: "answers" as const, + progress_indicator: true, }, guilds: [], }), @@ -3256,6 +4350,12 @@ describe.skip("DiscordManager lifecycle", () => { }, reply: replyMock, 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 @@ -3301,7 +4401,11 @@ describe.skip("DiscordManager lifecycle", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", + assistant_messages: "answers" as const, + progress_indicator: true, }, guilds: [], }), @@ -3400,6 +4504,12 @@ describe.skip("DiscordManager lifecycle", () => { }, reply: replyMock, 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 @@ -3606,6 +4716,12 @@ describe.skip("DiscordManager lifecycle", () => { }, reply: replyMock, 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 @@ -3651,7 +4767,11 @@ describe.skip("DiscordManager lifecycle", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", + assistant_messages: "answers" as const, + progress_indicator: true, }, guilds: [], }), @@ -3750,6 +4870,12 @@ describe.skip("DiscordManager lifecycle", () => { }, reply: replyMock, 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 @@ -3840,7 +4966,11 @@ describe.skip("DiscordManager output configuration", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", + assistant_messages: "answers" as const, + progress_indicator: true, }, guilds: [], }, @@ -3848,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", @@ -3943,6 +5074,12 @@ describe.skip("DiscordManager output configuration", () => { }, reply: replyMock, 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); @@ -4002,7 +5139,11 @@ describe.skip("DiscordManager output configuration", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", + assistant_messages: "answers" as const, + progress_indicator: true, }, guilds: [], }, @@ -4010,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", @@ -4105,6 +5247,12 @@ describe.skip("DiscordManager output configuration", () => { }, reply: replyMock, 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); @@ -4116,7 +5264,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); @@ -4163,7 +5311,11 @@ describe.skip("DiscordManager output configuration", () => { tool_result_max_length: 900, system_status: false, result_summary: false, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", + assistant_messages: "answers" as const, + progress_indicator: true, }, guilds: [], }, @@ -4171,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", @@ -4266,6 +5419,12 @@ describe.skip("DiscordManager output configuration", () => { }, reply: replyMock, 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); @@ -4325,7 +5484,11 @@ describe.skip("DiscordManager output configuration", () => { 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: [], }, @@ -4333,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", @@ -4428,6 +5592,12 @@ describe.skip("DiscordManager output configuration", () => { }, reply: replyMock, 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); @@ -4439,7 +5609,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); @@ -4490,7 +5660,11 @@ describe.skip("DiscordManager output configuration", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: true, + acknowledge_emoji: "eyes", + assistant_messages: "answers" as const, + progress_indicator: true, }, guilds: [], }, @@ -4498,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", @@ -4593,6 +5768,12 @@ describe.skip("DiscordManager output configuration", () => { }, reply: replyMock, 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); @@ -4604,7 +5785,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); @@ -4650,7 +5831,11 @@ describe.skip("DiscordManager output configuration", () => { tool_result_max_length: 900, system_status: true, result_summary: false, + typing_indicator: true, errors: false, + acknowledge_emoji: "eyes", + assistant_messages: "answers" as const, + progress_indicator: true, }, guilds: [], }, @@ -4658,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", @@ -4753,6 +5939,12 @@ describe.skip("DiscordManager output configuration", () => { }, reply: replyMock, 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); @@ -4766,6 +5958,155 @@ 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: "", + assistant_messages: "answers" as const, + progress_indicator: true, + }, + 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), + replyWithRef: vi.fn().mockResolvedValue({ + edit: vi.fn().mockResolvedValue(undefined), + delete: 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/__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/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; } diff --git a/packages/discord/src/commands/__tests__/command-manager.test.ts b/packages/discord/src/commands/__tests__/command-manager.test.ts index 681b8796..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"; @@ -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`, }, }; }); @@ -155,8 +157,19 @@ 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); }); }); @@ -182,6 +195,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", @@ -362,10 +397,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"), + }), + }), + ]), }), ); }); @@ -385,7 +426,39 @@ describe("CommandManager", () => { const commands = manager.getCommands(); expect(commands).toBeInstanceOf(Map); - expect(commands.size).toBe(3); + 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 new file mode 100644 index 00000000..9fc89d13 --- /dev/null +++ b/packages/discord/src/commands/__tests__/extended-commands.test.ts @@ -0,0 +1,174 @@ +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 = { + 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" }), + 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, + }), + 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", + permissionMode: "acceptEdits", + workingDirectory: "/tmp/project", + allowedTools: ["Read"], + deniedTools: [], + mcpServers: ["filesystem"], + }), + 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(); + }); + + 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); + }); + + 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/__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/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 3e7af244..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,16 +17,28 @@ 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, CommandManagerLogger, CommandManagerOptions, ICommandManager, SlashCommand, } from "./types.js"; +import { usageCommand } from "./usage.js"; // ============================================================================= // Default Logger @@ -43,7 +56,22 @@ function createDefaultLogger(agentName: string): CommandManagerLogger { * Get all built-in commands */ function getBuiltInCommands(): SlashCommand[] { - return [helpCommand, resetCommand, statusCommand]; + return [ + helpCommand, + pingCommand, + configCommand, + toolsCommand, + usageCommand, + skillsCommand, + skillCommand, + statusCommand, + sessionCommand, + resetCommand, + newCommand, + stopCommand, + cancelCommand, + retryCommand, + ]; } // ============================================================================= @@ -86,6 +114,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 +124,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({ @@ -119,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) { @@ -137,7 +171,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 +230,7 @@ export class CommandManager implements ICommandManager { agentName: this.agentName, sessionManager: this.sessionManager, connectorState: this.getConnectorState(), + commandActions: this.commandActions, }; try { @@ -218,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 628beff1..41f71414 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,32 @@ 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", + "**/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**", + "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/index.ts b/packages/discord/src/commands/index.ts index 5257454a..80467333 100644 --- a/packages/discord/src/commands/index.ts +++ b/packages/discord/src/commands/index.ts @@ -5,17 +5,32 @@ * 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 { + ChannelRunUsage, + CommandActionResult, + CommandActions, CommandContext, CommandManagerLogger, CommandManagerOptions, + CumulativeUsage, ICommandManager, SlashCommand, } from "./types.js"; +export { usageCommand } from "./usage.js"; 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/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/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/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/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/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/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/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 0c8a7f17..e563a089 100644 --- a/packages/discord/src/commands/types.ts +++ b/packages/discord/src/commands/types.ts @@ -6,9 +6,64 @@ */ 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 { + success: boolean; + message: string; + 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; + getCumulativeUsage?: () => Promise; + getAgentConfig?: () => Promise<{ + runtime?: string; + model?: string; + permissionMode?: string; + workingDirectory?: string; + allowedTools?: string[]; + deniedTools?: string[]; + mcpServers?: string[]; + }>; + getSessionInfo?: (channelId: string) => Promise<{ + activeJobId?: string; + lastPrompt?: string; + }>; +} + // ============================================================================= // Command Context // ============================================================================= @@ -31,6 +86,9 @@ export interface CommandContext { /** Current connector state */ connectorState: DiscordConnectorState; + + /** Optional manager-backed command actions */ + commandActions?: CommandActions; } // ============================================================================= @@ -47,12 +105,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; } // ============================================================================= @@ -90,6 +161,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; + }; } // ============================================================================= @@ -114,6 +194,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..e25d754c --- /dev/null +++ b/packages/discord/src/commands/usage.ts @@ -0,0 +1,73 @@ +import { formatCompactNumber, formatCost, formatDurationMs } from "@herdctl/chat"; +import type { CommandContext, SlashCommand } from "./types.js"; + +export const usageCommand: SlashCommand = { + name: "usage", + description: "Show usage stats: last run and cumulative totals", + + async execute(context: CommandContext): Promise { + const { interaction, commandActions, agentName } = context; + + 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.", + ephemeral: true, + }); + return; + } + + 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/discord-connector.ts b/packages/discord/src/discord-connector.ts index a3cd5b27..8f37b56c 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, @@ -25,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"; @@ -34,12 +37,15 @@ import { type TextBasedChannel, } from "./mention-handler.js"; import type { + AttachmentCategory, + DiscordAttachmentInfo, DiscordConnectionStatus, DiscordConnectorEventMap, DiscordConnectorEventName, DiscordConnectorLogger, DiscordConnectorOptions, DiscordConnectorState, + DiscordFileUploadParams, DiscordReplyPayload, IDiscordConnector, } from "./types.js"; @@ -75,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; @@ -103,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 = @@ -305,6 +318,38 @@ 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 }); + // Channel type is validated above via isTextBased() + "send" check + const sent = await (channel as TextChannel | DMChannel).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 */ @@ -477,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(); @@ -494,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; } @@ -654,6 +710,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) => { @@ -681,6 +757,72 @@ 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; + } + } + + // 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) { + // 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({ + 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 { + 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,13 +836,72 @@ export class DiscordConnector extends EventEmitter implements IDiscordConnector username: message.author.username, wasMentioned: context.wasMentioned, mode, + isVoiceMessage, + voiceAttachmentUrl, + voiceAttachmentName, + attachments, }, reply, + replyWithRef, startTyping, + addReaction, + removeReaction, }; 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"; + } + + /** + * 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/embeds.ts b/packages/discord/src/embeds.ts new file mode 100644 index 00000000..440c9b52 --- /dev/null +++ b/packages/discord/src/embeds.ts @@ -0,0 +1,157 @@ +import { formatCompactNumber, formatDurationMs } 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}` }; +} + +// Re-export so existing callers (tests, snapshots) don't break. +export const formatDuration = formatDurationMs; + +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 933d9f9f..3e13fc04 100644 --- a/packages/discord/src/index.ts +++ b/packages/discord/src/index.ts @@ -25,14 +25,32 @@ export { resolveChannelConfig, } from "./auto-mode-handler.js"; export type { + ChannelRunUsage, CommandContext, CommandManagerLogger, CommandManagerOptions, + CumulativeUsage, ICommandManager, SlashCommand, } from "./commands/index.js"; // Commands -export { CommandManager, helpCommand, resetCommand, statusCommand } from "./commands/index.js"; +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"; export type { ErrorHandlerOptions } from "./error-handler.js"; @@ -81,6 +99,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, @@ -108,3 +128,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 451a5be6..444fffb4 100644 --- a/packages/discord/src/manager.ts +++ b/packages/discord/src/manager.ts @@ -10,32 +10,53 @@ * @module manager */ +import { randomUUID } from "node:crypto"; +import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; + import { type ChatConnectorLogger, ChatSessionManager, - extractMessageContent, StreamingResponder, splitMessage, } from "@herdctl/chat"; import type { ChatManagerConnectorState, + DiscordAttachments, FleetManagerContext, IChatManager, + InjectedMcpServerDef, ResolvedAgent, } from "@herdctl/core"; import { - extractToolResults, - extractToolUseBlocks, + createFileSenderDef, + type FileSenderContext, getToolInputSummary, + type SDKMessage, TOOL_EMOJIS, } from "@herdctl/core"; +import type { ChannelRunUsage, CumulativeUsage } from "./commands/types.js"; 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"; + +// ============================================================================= +// Constants +// ============================================================================= // ============================================================================= // Discord Manager @@ -46,6 +67,19 @@ import type { */ 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 */ @@ -62,6 +96,10 @@ 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 lastUsageByChannel: Map = new Map(); + private cumulativeUsageByAgent: Map = new Map(); private initialized: boolean = false; constructor(private ctx: FleetManagerContext) {} @@ -153,6 +191,25 @@ 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), + 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), + getCumulativeUsage: async () => this.getAgentCumulativeUsage(agent.qualifiedName), + getAgentConfig: async () => this.getAgentConfigSummary(agent), + 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); @@ -368,6 +425,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(); @@ -390,8 +451,16 @@ export class DiscordManager implements IChatManager { system_status: true, result_summary: false, errors: true, + typing_indicator: true, + acknowledge_emoji: "👀", + assistant_messages: "answers" as const, + progress_indicator: true, }; + // 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); let existingSessionId: string | undefined; @@ -415,9 +484,46 @@ export class DiscordManager implements IChatManager { } } - // Create streaming responder for incremental message delivery + // 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 fileSenderContext: FileSenderContext = { + workingDirectory: workingDir, + uploadFile: async (params) => { + // Queue the file — it will be attached to the next answer message + pendingFiles.push({ + buffer: params.fileBuffer, + filename: params.filename, + }); + 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. + // 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 @@ -425,13 +531,166 @@ 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; + // Add acknowledgement reaction if configured (non-fatal — don't abort message handling) + const ackEmoji = outputConfig.acknowledge_emoji; + if (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 }; + 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) { + 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, { + signal: AbortSignal.timeout(30_000), + }); + 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 = `[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); + logger.error(`Voice transcription failed: ${errMsg}`); + await event.reply(`Failed to transcribe voice message: ${errMsg}`); + return; + } + } + + // 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 + ) { + 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.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")); + } + + prompt = [ + "The user sent the following file attachment(s) with their message:", + "", + ...sections, + "", + "---", + "", + `User message: ${prompt}`, + ].join("\n"); + } + } + } + // Track pending tool_use blocks so we can pair them with results const pendingToolUses = new Map< string, @@ -439,168 +698,299 @@ 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(); + + // 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 // 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, + 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]; - const content = extractMessageContent(sdkMessage); - if (content) { - // Each assistant message is a complete turn - send immediately - await streamer.addMessageAndSend(content); - } + for (const normalized of normalizeDiscordMessage(message as SDKMessage)) { + if (normalized.kind === "assistant_delta") { + if (!enableDeltaStreaming) { + continue; + } - // Track tool_use blocks for pairing with results later - 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(), - }); + 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; } - } - // Build and send embeds for tool results - if (message.type === "user" && outputConfig.tool_results) { - // 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 === "assistant_final") { + for (const block of normalized.toolUses) { + if (block.id) { + pendingToolUses.set(block.id, { + name: block.name, + input: block.input, + startTime: Date.now(), + }); + } + + 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"); + } } - const embed = this.buildToolEmbed( - toolUse ?? null, - toolResult, - outputConfig.tool_result_max_length, - ); + if (normalized.messageId && normalized.stopReason === null) { + continue; + } + if (normalized.messageId) { + if (deliveredAssistantIds.has(normalized.messageId)) { + continue; + } + deliveredAssistantIds.add(normalized.messageId); + } - // Flush any buffered text before sending embed to preserve ordering - await streamer.flush(); - await event.reply({ embeds: [embed] }); - embedsSent++; - } - } + const content = normalized.content; + if (!content) { + streamedDeltaSinceFinal = false; + 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..." - : `Status: ${sysMessage.status}`; - await streamer.flush(); - await event.reply({ - embeds: [ - { - title: "\u2699\uFE0F System", - description: statusText, - color: DiscordManager.EMBED_COLOR_SYSTEM, - }, - ], - }); - embedsSent++; + if (assistantMessages === "answers") { + 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 { + await streamer.addMessageAndSend(content); + sentAnswer = true; + } + streamedDeltaSinceFinal = false; + 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 fields: DiscordReplyEmbedField[] = []; - - if (resultMessage.duration_ms !== undefined) { - fields.push({ - name: "Duration", - value: DiscordManager.formatDuration(resultMessage.duration_ms), - inline: true, - }); + 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++; + } + } + continue; } - if (resultMessage.num_turns !== undefined) { - fields.push({ - name: "Turns", - value: String(resultMessage.num_turns), - inline: true, - }); + if (normalized.kind === "system_status" && outputConfig.system_status) { + latestStatusText = + normalized.status === "compacting" ? "Compacting context…" : normalized.status; + pushTraceLine(`ℹ ${latestStatusText}`); + await refreshRunCard("running"); + continue; } - if (resultMessage.total_cost_usd !== undefined) { - fields.push({ - name: "Cost", - value: `$${resultMessage.total_cost_usd.toFixed(4)}`, - inline: true, - }); + if (normalized.kind === "tool_progress" && outputConfig.system_status) { + latestStatusText = normalized.content; + pushTraceLine(`ℹ ${normalized.content}`); + await refreshRunCard("running"); + continue; } - 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, - }); + 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; } - const isError = resultMessage.is_error === true; - await streamer.flush(); - await event.reply({ - embeds: [ + if (normalized.kind === "result") { + if (normalized.resultText) { + resultText = normalized.resultText; + } + const now = new Date().toISOString(); + this.lastUsageByChannel.set( + this.getChannelKey(qualifiedName, event.metadata.channelId), { - title: isError ? "\u274C Task Failed" : "\u2705 Task Complete", - color: isError - ? DiscordManager.EMBED_COLOR_ERROR - : DiscordManager.EMBED_COLOR_SUCCESS, - fields, + timestamp: now, + numTurns: normalized.numTurns, + durationMs: normalized.durationMs, + totalCostUsd: normalized.totalCostUsd, + inputTokens: normalized.usage?.input_tokens, + outputTokens: normalized.usage?.output_tokens, + isError: normalized.isError, }, - ], - }); - embedsSent++; - } + ); + 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"); + + 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: [ - { - title: "\u274C Error", - description: - errorText.length > 4000 ? `${errorText.substring(0, 4000)}...` : errorText, - color: DiscordManager.EMBED_COLOR_ERROR, - }, - ], - }); - embedsSent++; + if (normalized.kind === "error" && outputConfig.errors) { + await streamer.flush(); + await event.reply({ + embeds: [buildErrorEmbed(normalized.message, qualifiedName)], + }); + embedsSent++; + } } }, }); @@ -612,26 +1002,70 @@ export class DiscordManager implements IChatManager { typingStopped = true; } + // Fall back to SDK result text if no answer turns produced text + 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 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})` : ""}`, ); - // If no messages were sent (text or embeds), send an appropriate fallback - if (!streamer.hasSentMessages() && embedsSent === 0) { + 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 (!sentAnswer && !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: [ + buildStatusEmbed( + "Task completed — no additional output to share.", + "info", + 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: [ + buildErrorEmbed(`${errorMessage}\n\nThe task could not be completed.`, qualifiedName), + ], + }); } // Stop typing after sending fallback message (if not already stopped) @@ -673,9 +1107,26 @@ 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)); + await event.reply(this.formatErrorMessage(err, qualifiedName)); } catch (replyError) { logger.error(`Failed to send error reply: ${(replyError as Error).message}`); } @@ -689,114 +1140,35 @@ 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(); } - } - } - - // ============================================================================= - // 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_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 - - /** - * 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. - * - * @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) - */ - private buildToolEmbed( - toolUse: { name: string; input?: unknown; startTime: number } | null, - toolResult: { output: string; isError: boolean }, - maxOutputChars?: number, - ): DiscordReplyEmbed { - const toolName = toolUse?.name ?? "Tool"; - const emoji = TOOL_EMOJIS[toolName] ?? "\u{1F527}"; // wrench fallback - const isError = toolResult.isError; - - // Build description from input summary - 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}\``; + // Remove acknowledgement reaction now that processing is complete + if (ackEmoji) { + try { + await event.removeReaction(ackEmoji); + } catch (reactionError) { + logger.warn(`Failed to remove ack reaction: ${(reactionError as Error).message}`); + } } - } - - // Build inline fields - const fields: DiscordReplyEmbedField[] = []; - - if (toolUse) { - const durationMs = Date.now() - toolUse.startTime; - fields.push({ - name: "Duration", - value: DiscordManager.formatDuration(durationMs), - inline: true, - }); - } - - 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 - 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... (${outputLength.toLocaleString()} chars total)`; + // Clean up downloaded attachment files if configured + if ( + attachmentDownloadedPaths.length > 0 && + attachmentConfig?.cleanup_after_processing !== false + ) { + try { + await DiscordManager.cleanupAttachments( + attachmentDownloadedPaths, + logger as ChatConnectorLogger, + ); + } catch (cleanupError) { + logger.warn(`Failed to cleanup attachments: ${(cleanupError as Error).message}`); + } } - fields.push({ - name: isError ? "Error" : "Result", - value: `\`\`\`\n${outputText}\n\`\`\``, - inline: false, - }); } - - return { - title: `${emoji} ${toolName}`, - description, - color: isError ? DiscordManager.EMBED_COLOR_ERROR : DiscordManager.EMBED_COLOR_DEFAULT, - fields, - }; } /** @@ -822,6 +1194,437 @@ 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 lastPrompt = this.lastPromptByChannel.get(key); + if (!lastPrompt) { + return { + success: false, + message: "No previous prompt found to retry in this channel.", + }; + } + return this.runChannelPrompt(qualifiedName, channelId, lastPrompt); + } + + // =========================================================================== + // Synthetic Event Helpers + // =========================================================================== + + 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, + context: { + messages: [], + prompt, + wasMentioned: false, + }, + metadata: { + guildId: + typeof channel.isDMBased === "function" && channel.isDMBased() + ? null + : typeof channel.guildId === "string" + ? channel.guildId + : null, + channelId, + messageId: `synthetic-${randomUUID()}`, + userId: "system", + username: "system", + 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 (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(`Synthetic typing indicator failed: ${(err as Error).message}`); + }); + typingInterval = setInterval(() => { + void textChannel.sendTyping?.().catch((err: unknown) => { + logger.debug(`Synthetic typing refresh failed: ${(err as Error).message}`); + }); + }, 8000); + } + return () => { + if (typingInterval) { + clearInterval(typingInterval); + typingInterval = null; + } + }; + }, + addReaction: async () => {}, + removeReaction: async () => {}, + } as DiscordMessageEvent; + } + + 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), + }; + } + + private async getChannelUsage( + qualifiedName: string, + channelId: string, + ): 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, + 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 "${matchedSkill.name}" 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 \`${matchedSkill.name}\` 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 discovered = new Map(); + + // 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 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 = [ + join(workingDir, ".claude", "skills"), + join(workingDir, ".codex", "skills"), + join(workingDir, "skills"), + ]; + + 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 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 }> | 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 undefined; + } + // [] → explicitly empty (no skills); [...] → those exact skills + 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; + 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 // =========================================================================== @@ -833,12 +1636,24 @@ 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: [ + buildErrorEmbed( + `${error.message}\n\nTry again or use \`/reset\` to start a new session.`, + agentName, + ), + ], + }; + } + return `**Error:** ${error.message}\n\nTry again or use \`/reset\` to start a new session.`; } /** @@ -867,4 +1682,185 @@ 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; + } + + // =========================================================================== + // 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 one collision-resistant directory per message processing run. + const messageDownloadDir = randomUUID(); + + 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, { signal: AbortSignal.timeout(30_000) }); + 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 for the agent to view via Read tool + if (!workingDir) { + skippedFiles.push({ + name: attachment.name, + reason: "no working_directory configured for binary attachments", + }); + continue; + } + 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 downloadDir = join(workingDir, config.download_dir, messageDownloadDir); + await mkdir(downloadDir, { recursive: true }); + const filePath = join(downloadDir, `${attachment.id}-${basename(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 parent = dirname(filePath); + if (parent !== filePath && parent !== ".") { + parentDirs.add(parent); + } + } 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/mention-handler.ts b/packages/discord/src/mention-handler.ts index e7cdb4be..39afb2bb 100644 --- a/packages/discord/src/mention-handler.ts +++ b/packages/discord/src/mention-handler.ts @@ -231,6 +231,31 @@ 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) { + 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; + } + } + return { authorId: message.author.id, authorName: message.author.displayName ?? message.author.username, diff --git a/packages/discord/src/message-normalizer.ts b/packages/discord/src/message-normalizer.ts new file mode 100644 index 00000000..794e2a47 --- /dev/null +++ b/packages/discord/src/message-normalizer.ts @@ -0,0 +1,186 @@ +/** + * 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, + 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 4cfe3d72..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; + }; } // ============================================================================= @@ -176,6 +186,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 +247,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 */ @@ -242,7 +275,7 @@ export interface DiscordReplyEmbedField { * A Discord embed for rich message formatting */ export interface DiscordReplyEmbed { - title: string; + title?: string; description?: string; color?: number; fields?: DiscordReplyEmbedField[]; @@ -251,10 +284,43 @@ 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 }>; +} + +// ============================================================================= +// 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; } // ============================================================================= @@ -337,6 +403,14 @@ 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; + /** 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; @@ -346,6 +420,18 @@ 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; + /** + * 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; + }>; }; /** diff --git a/packages/discord/src/voice-transcriber.ts b/packages/discord/src/voice-transcriber.ts new file mode 100644 index 00000000..0f5a651c --- /dev/null +++ b/packages/discord/src/voice-transcriber.ts @@ -0,0 +1,66 @@ +/** + * 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, + signal: AbortSignal.timeout(60_000), // 60s timeout for transcription API + }); + + 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 Record; + if (typeof result.text !== "string") { + throw new Error("OpenAI Whisper API returned an unexpected response (missing 'text' field)"); + } + return { text: result.text }; +} 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/slack/src/manager.ts b/packages/slack/src/manager.ts index a8679b64..15447658 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,18 +470,31 @@ 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, { 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 const result = await this.ctx.trigger(qualifiedName, undefined, { triggerType: "slack", - prompt: event.prompt, + prompt, resume: existingSessionId, injectedMcpServers, onMessage: async (message) => { @@ -487,13 +502,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 +515,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 @@ -727,6 +762,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; } /** 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; } 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