discoclaw 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.context/discord.md +3 -0
- package/.context/voice.md +1 -0
- package/.env.example.full +11 -0
- package/README.md +12 -0
- package/dist/config.js +16 -0
- package/dist/discord/action-categories.js +2 -0
- package/dist/discord/actions-spawn.js +118 -0
- package/dist/discord/actions-spawn.test.js +385 -0
- package/dist/discord/actions.js +41 -2
- package/dist/discord/message-coordinator.js +4 -0
- package/dist/discord/reaction-handler.js +2 -0
- package/dist/discord/update-command.js +1 -1
- package/dist/discord/update-command.test.js +1 -0
- package/dist/index.js +14 -0
- package/dist/npm-managed.js +1 -1
- package/dist/npm-managed.test.js +1 -1
- package/dist/voice/tts-deepgram.js +8 -0
- package/dist/voice/tts-deepgram.test.js +23 -0
- package/dist/voice/tts-factory.js +1 -0
- package/dist/voice/voice-style-prompt.js +2 -1
- package/dist/voice/voice-style-prompt.test.js +4 -0
- package/package.json +1 -1
package/.context/discord.md
CHANGED
|
@@ -86,12 +86,15 @@ Each action category has its own flag (only active when the master switch is `1`
|
|
|
86
86
|
| `DISCOCLAW_DISCORD_ACTIONS_MEMORY` | `1` | memoryRemember, memoryForget, memoryShow |
|
|
87
87
|
| `DISCOCLAW_DISCORD_ACTIONS_DEFER` | `1` | defer |
|
|
88
88
|
| `DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN` | `0` | generateImage |
|
|
89
|
+
| `DISCOCLAW_DISCORD_ACTIONS_VOICE` | `0` | voiceStatus, voiceJoin, voiceLeave, voiceSetVoice |
|
|
90
|
+
| `DISCOCLAW_DISCORD_ACTIONS_SPAWN` | `1` | spawnAgent |
|
|
89
91
|
| _(config — always on)_ | — | modelSet, modelShow |
|
|
90
92
|
|
|
91
93
|
Notes:
|
|
92
94
|
- `reactionPrompt` is gated by the MESSAGING flag — it is registered via `REACTION_PROMPT_ACTION_TYPES` only when `flags.messaging` is true (`src/discord/actions.ts:113`).
|
|
93
95
|
- Config actions (`modelSet`, `modelShow`) have no separate env flag. They are always enabled when the master switch is on, hardcoded in `src/index.ts`.
|
|
94
96
|
- `generateImage` supports two providers: **OpenAI** (models: `dall-e-3`, `gpt-image-1`) and **Gemini** (models: `imagen-4.0-generate-001`, `imagen-4.0-fast-generate-001`, `imagen-4.0-ultra-generate-001`). Provider is auto-detected from the model prefix (`dall-e-*`/`gpt-image-*` → openai, `imagen-*` → gemini) or set explicitly via the `provider` field. OpenAI provider uses `OPENAI_API_KEY` (required) and optional `OPENAI_BASE_URL`. Gemini provider uses `IMAGEGEN_GEMINI_API_KEY`. At least one key must be set when `DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=1`. Default model is auto-detected: if only `IMAGEGEN_GEMINI_API_KEY` is set, defaults to `imagen-4.0-generate-001`; otherwise defaults to `dall-e-3`. Override with `IMAGEGEN_DEFAULT_MODEL`.
|
|
97
|
+
- `spawnAgent` is enabled by default (`DISCOCLAW_DISCORD_ACTIONS_SPAWN=1`; set to 0 to disable). Spawned agents run fire-and-forget: each agent runs its prompt via the configured runtime and posts its output directly to the target channel. Multiple `spawnAgent` actions in a single response run in parallel (bounded by `DISCOCLAW_DISCORD_ACTIONS_SPAWN_MAX_CONCURRENT`, default 8). Spawn is disabled for bot-originated messages and excluded from cron flows to prevent recursive agent chains. Spawned agents run at recursion depth 1 and cannot themselves spawn further agents.
|
|
95
98
|
|
|
96
99
|
Auto-follow-up: When query actions (channelList, channelInfo, threadListArchived, forumTagList, readMessages, fetchMessage, listPins, memberInfo, roleInfo, searchMessages, eventList, taskList, taskShow, cronList, cronShow, planList, planShow, memoryShow, modelShow, forgeStatus) succeed, DiscoClaw automatically re-invokes Claude with the results. This allows Claude to reason about query results without requiring the user to send a follow-up message. Controlled by `DISCOCLAW_ACTION_FOLLOWUP_DEPTH` (default `3`, `0` disables). Mutation-only responses do not trigger follow-ups. Trivially short follow-up responses (<50 chars with no actions) are suppressed.
|
|
97
100
|
|
package/.context/voice.md
CHANGED
|
@@ -83,5 +83,6 @@ When `voiceEnabled=true`, the post-connect block in `src/index.ts` initializes t
|
|
|
83
83
|
| `DEEPGRAM_API_KEY` | — | Required for deepgram STT and TTS |
|
|
84
84
|
| `DEEPGRAM_STT_MODEL` | `nova-3-conversationalai` | Deepgram STT model name |
|
|
85
85
|
| `DEEPGRAM_TTS_VOICE` | `aura-2-asteria-en` | Deepgram TTS voice name |
|
|
86
|
+
| `DEEPGRAM_TTS_SPEED` | `1.0` | Deepgram TTS playback speed (range 0.5–1.5) |
|
|
86
87
|
| `CARTESIA_API_KEY` | — | Required for cartesia TTS |
|
|
87
88
|
| *(built-in)* | — | Telegraphic style instruction hardcoded into every voice AI invocation — front-loads the answer, strips preambles/markdown/filler, keeps responses short for TTS latency. Not an env var; not overridable by `DISCOCLAW_VOICE_SYSTEM_PROMPT`. |
|
package/.env.example.full
CHANGED
|
@@ -197,6 +197,14 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
|
|
|
197
197
|
# Maximum number of pending deferred invocations allowed at once (default: 5).
|
|
198
198
|
#DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT=5
|
|
199
199
|
|
|
200
|
+
# Allow the AI to spawn parallel sub-agents in target channels.
|
|
201
|
+
# Each spawned agent is a fire-and-forget invocation that posts output to the specified channel.
|
|
202
|
+
# Spawned agents run at recursion depth 1 and cannot themselves spawn further agents.
|
|
203
|
+
# Default: on. Set to 0 to disable.
|
|
204
|
+
#DISCOCLAW_DISCORD_ACTIONS_SPAWN=1
|
|
205
|
+
# Maximum number of spawn actions that may run concurrently (default: 8).
|
|
206
|
+
#DISCOCLAW_DISCORD_ACTIONS_SPAWN_MAX_CONCURRENT=8
|
|
207
|
+
|
|
200
208
|
# ============================================================
|
|
201
209
|
# OPTIONAL — uncomment sections you need
|
|
202
210
|
# ============================================================
|
|
@@ -537,5 +545,8 @@ DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=0
|
|
|
537
545
|
# Deepgram TTS voice for speech synthesis (default: aura-2-asteria-en).
|
|
538
546
|
# See https://developers.deepgram.com/docs/tts-models for available voices.
|
|
539
547
|
#DEEPGRAM_TTS_VOICE=aura-2-asteria-en
|
|
548
|
+
# Deepgram TTS playback speed (range: 0.5–1.5, default: 1.0).
|
|
549
|
+
# Values below 1.0 slow down speech; values above 1.0 speed it up.
|
|
550
|
+
#DEEPGRAM_TTS_SPEED=1.0
|
|
540
551
|
# API key for Cartesia Sonic-3 TTS. Required when DISCOCLAW_TTS_PROVIDER=cartesia.
|
|
541
552
|
#CARTESIA_API_KEY=
|
package/README.md
CHANGED
|
@@ -221,6 +221,18 @@ Full step-by-step guide: [docs/discord-bot-setup.md](docs/discord-bot-setup.md)
|
|
|
221
221
|
npm install -g discoclaw
|
|
222
222
|
```
|
|
223
223
|
|
|
224
|
+
> **Fedora 43+ / GCC 14+ — `@discordjs/opus` build failure**
|
|
225
|
+
>
|
|
226
|
+
> GCC 14 promotes `-Wincompatible-pointer-types` to a hard error by default. The upstream opus C source triggers this, causing `npm install` to fail with an error like:
|
|
227
|
+
> ```
|
|
228
|
+
> error: incompatible pointer types passing ...
|
|
229
|
+
> ```
|
|
230
|
+
> **Workaround** — set the flag before installing:
|
|
231
|
+
> ```bash
|
|
232
|
+
> CFLAGS="-Wno-error=incompatible-pointer-types" npm install -g discoclaw
|
|
233
|
+
> ```
|
|
234
|
+
> This is a known upstream issue in the `@discordjs/opus` native addon. It only requires the flag override at install time; runtime behavior is unaffected.
|
|
235
|
+
|
|
224
236
|
2. **Run the interactive setup wizard** (creates `.env` and scaffolds your workspace):
|
|
225
237
|
```bash
|
|
226
238
|
discoclaw init
|
package/dist/config.js
CHANGED
|
@@ -158,6 +158,8 @@ export function parseConfig(env) {
|
|
|
158
158
|
const discordActionsDefer = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_DEFER', true);
|
|
159
159
|
const discordActionsImagegen = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN', false);
|
|
160
160
|
const discordActionsVoice = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_VOICE', false);
|
|
161
|
+
const discordActionsSpawn = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_SPAWN', true);
|
|
162
|
+
const spawnMaxConcurrent = parsePositiveInt(env, 'DISCOCLAW_DISCORD_ACTIONS_SPAWN_MAX_CONCURRENT', 4);
|
|
161
163
|
const deferMaxDelaySeconds = parsePositiveNumber(env, 'DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS', DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS);
|
|
162
164
|
const deferMaxConcurrent = parsePositiveInt(env, 'DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT', DEFAULT_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT);
|
|
163
165
|
if (!discordActionsEnabled) {
|
|
@@ -176,6 +178,7 @@ export function parseConfig(env) {
|
|
|
176
178
|
{ name: 'DISCOCLAW_DISCORD_ACTIONS_DEFER', enabled: discordActionsDefer },
|
|
177
179
|
{ name: 'DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN', enabled: discordActionsImagegen },
|
|
178
180
|
{ name: 'DISCOCLAW_DISCORD_ACTIONS_VOICE', enabled: discordActionsVoice },
|
|
181
|
+
{ name: 'DISCOCLAW_DISCORD_ACTIONS_SPAWN', enabled: discordActionsSpawn },
|
|
179
182
|
]
|
|
180
183
|
.filter((entry) => (env[entry.name] ?? '').trim().length > 0 && entry.enabled)
|
|
181
184
|
.map((entry) => entry.name);
|
|
@@ -243,6 +246,16 @@ export function parseConfig(env) {
|
|
|
243
246
|
const deepgramApiKey = parseTrimmedString(env, 'DEEPGRAM_API_KEY');
|
|
244
247
|
const deepgramSttModel = parseTrimmedString(env, 'DEEPGRAM_STT_MODEL') ?? 'nova-3-general';
|
|
245
248
|
const deepgramTtsVoice = parseTrimmedString(env, 'DEEPGRAM_TTS_VOICE') ?? 'aura-2-asteria-en';
|
|
249
|
+
const deepgramTtsSpeed = (() => {
|
|
250
|
+
const raw = parseTrimmedString(env, 'DEEPGRAM_TTS_SPEED');
|
|
251
|
+
if (raw == null)
|
|
252
|
+
return undefined;
|
|
253
|
+
const n = parseFloat(raw);
|
|
254
|
+
if (!Number.isFinite(n) || n < 0.5 || n > 1.5) {
|
|
255
|
+
throw new Error(`DEEPGRAM_TTS_SPEED must be a number between 0.5 and 1.5, got "${raw}"`);
|
|
256
|
+
}
|
|
257
|
+
return n;
|
|
258
|
+
})();
|
|
246
259
|
const cartesiaApiKey = parseTrimmedString(env, 'CARTESIA_API_KEY');
|
|
247
260
|
const voiceModelRaw = parseTrimmedString(env, 'DISCOCLAW_VOICE_MODEL');
|
|
248
261
|
const voiceSystemPrompt = (() => {
|
|
@@ -351,8 +364,10 @@ export function parseConfig(env) {
|
|
|
351
364
|
discordActionsDefer,
|
|
352
365
|
discordActionsImagegen,
|
|
353
366
|
discordActionsVoice,
|
|
367
|
+
discordActionsSpawn,
|
|
354
368
|
deferMaxDelaySeconds,
|
|
355
369
|
deferMaxConcurrent,
|
|
370
|
+
spawnMaxConcurrent,
|
|
356
371
|
messageHistoryBudget: parseNonNegativeInt(env, 'DISCOCLAW_MESSAGE_HISTORY_BUDGET', 3000),
|
|
357
372
|
summaryEnabled: parseBoolean(env, 'DISCOCLAW_SUMMARY_ENABLED', true),
|
|
358
373
|
summaryModel: parseTrimmedString(env, 'DISCOCLAW_SUMMARY_MODEL') ?? fastModel,
|
|
@@ -398,6 +413,7 @@ export function parseConfig(env) {
|
|
|
398
413
|
deepgramApiKey,
|
|
399
414
|
deepgramSttModel,
|
|
400
415
|
deepgramTtsVoice,
|
|
416
|
+
deepgramTtsSpeed,
|
|
401
417
|
cartesiaApiKey,
|
|
402
418
|
forgeDrafterRuntime,
|
|
403
419
|
forgeAuditorRuntime,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { resolveChannel, findChannelRaw, describeChannelType } from './action-utils.js';
|
|
2
|
+
import { NO_MENTIONS } from './allowed-mentions.js';
|
|
3
|
+
import { splitDiscord } from './output-utils.js';
|
|
4
|
+
const SPAWN_TYPE_MAP = {
|
|
5
|
+
spawnAgent: true,
|
|
6
|
+
};
|
|
7
|
+
export const SPAWN_ACTION_TYPES = new Set(Object.keys(SPAWN_TYPE_MAP));
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Single executor
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
export async function executeSpawnAction(action, ctx, spawnCtx) {
|
|
12
|
+
if ((spawnCtx.depth ?? 0) >= 1) {
|
|
13
|
+
return { ok: false, error: 'spawnAgent blocked: recursion depth >= 1 (spawned agents cannot spawn further agents)' };
|
|
14
|
+
}
|
|
15
|
+
switch (action.type) {
|
|
16
|
+
case 'spawnAgent': {
|
|
17
|
+
if (!action.channel?.trim()) {
|
|
18
|
+
return { ok: false, error: 'spawnAgent requires a non-empty channel' };
|
|
19
|
+
}
|
|
20
|
+
if (!action.prompt?.trim()) {
|
|
21
|
+
return { ok: false, error: 'spawnAgent requires a non-empty prompt' };
|
|
22
|
+
}
|
|
23
|
+
// Resolve the target channel before the expensive runtime call.
|
|
24
|
+
const targetChannel = resolveChannel(ctx.guild, action.channel);
|
|
25
|
+
if (!targetChannel) {
|
|
26
|
+
const raw = findChannelRaw(ctx.guild, action.channel);
|
|
27
|
+
if (raw) {
|
|
28
|
+
const kind = describeChannelType(raw);
|
|
29
|
+
return { ok: false, error: `spawnAgent: "${action.channel}" is a ${kind} channel (use a text channel)` };
|
|
30
|
+
}
|
|
31
|
+
return { ok: false, error: `spawnAgent: channel "${action.channel}" not found` };
|
|
32
|
+
}
|
|
33
|
+
const label = action.label?.trim() || 'agent';
|
|
34
|
+
const timeoutMs = spawnCtx.timeoutMs ?? 120_000;
|
|
35
|
+
const model = action.model ?? spawnCtx.model;
|
|
36
|
+
try {
|
|
37
|
+
let text = '';
|
|
38
|
+
const stream = spawnCtx.runtime.invoke({
|
|
39
|
+
prompt: action.prompt,
|
|
40
|
+
model,
|
|
41
|
+
cwd: spawnCtx.cwd,
|
|
42
|
+
timeoutMs,
|
|
43
|
+
});
|
|
44
|
+
for await (const event of stream) {
|
|
45
|
+
if (event.type === 'text_delta') {
|
|
46
|
+
text += event.text;
|
|
47
|
+
}
|
|
48
|
+
else if (event.type === 'text_final') {
|
|
49
|
+
text = event.text;
|
|
50
|
+
}
|
|
51
|
+
else if (event.type === 'error') {
|
|
52
|
+
return { ok: false, error: `spawnAgent (${label}) failed: ${event.message}` };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const outputText = text.trim() || `Agent (${label}) completed with no output.`;
|
|
56
|
+
const chunks = splitDiscord(outputText);
|
|
57
|
+
for (const chunk of chunks) {
|
|
58
|
+
await targetChannel.send({ content: chunk, allowedMentions: NO_MENTIONS });
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
summary: `Agent (${label}) posted to #${targetChannel.name}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
67
|
+
return { ok: false, error: `spawnAgent (${label}) failed: ${msg}` };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Parallel coordinator
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
/**
|
|
76
|
+
* Execute multiple spawnAgent actions in parallel, bounded by maxConcurrent.
|
|
77
|
+
* Results are returned in the same order as the input actions.
|
|
78
|
+
*/
|
|
79
|
+
export async function executeSpawnActions(actions, ctx, spawnCtx) {
|
|
80
|
+
if (actions.length === 0)
|
|
81
|
+
return [];
|
|
82
|
+
const maxConcurrent = spawnCtx.maxConcurrent ?? 4;
|
|
83
|
+
const results = new Array(actions.length);
|
|
84
|
+
let i = 0;
|
|
85
|
+
while (i < actions.length) {
|
|
86
|
+
const batch = actions.slice(i, i + maxConcurrent);
|
|
87
|
+
const settled = await Promise.allSettled(batch.map((action) => executeSpawnAction(action, ctx, spawnCtx)));
|
|
88
|
+
for (let j = 0; j < settled.length; j++) {
|
|
89
|
+
const item = settled[j];
|
|
90
|
+
results[i + j] = item.status === 'fulfilled'
|
|
91
|
+
? item.value
|
|
92
|
+
: { ok: false, error: item.reason instanceof Error ? item.reason.message : String(item.reason) };
|
|
93
|
+
}
|
|
94
|
+
i += maxConcurrent;
|
|
95
|
+
}
|
|
96
|
+
return results;
|
|
97
|
+
}
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Prompt section
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
export function spawnActionsPromptSection() {
|
|
102
|
+
return `### Spawn Agent
|
|
103
|
+
|
|
104
|
+
**spawnAgent** — Spawn a parallel sub-agent in a target channel:
|
|
105
|
+
\`\`\`
|
|
106
|
+
<discord-action>{"type":"spawnAgent","channel":"general","prompt":"List all open tasks and summarize their status","label":"task-summary"}</discord-action>
|
|
107
|
+
\`\`\`
|
|
108
|
+
- \`channel\` (required): Target channel name or ID where the spawned agent posts its output.
|
|
109
|
+
- \`prompt\` (required): The instruction to send to the sub-agent.
|
|
110
|
+
- \`model\` (optional): Model override for the spawned invocation.
|
|
111
|
+
- \`label\` (optional): A short human-readable label for the agent (used in error messages).
|
|
112
|
+
|
|
113
|
+
#### Spawn Guidelines
|
|
114
|
+
- Multiple spawnAgent actions in a single response are run in parallel for efficiency.
|
|
115
|
+
- Spawned agents run at recursion depth 1 and cannot themselves spawn further agents.
|
|
116
|
+
- The spawned agent runs fire-and-forget: it posts its output directly to the target channel.
|
|
117
|
+
- Keep prompts focused — each agent handles a single well-defined task.`;
|
|
118
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { ChannelType } from 'discord.js';
|
|
3
|
+
import { SPAWN_ACTION_TYPES, executeSpawnAction, executeSpawnActions, spawnActionsPromptSection, } from './actions-spawn.js';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Helpers
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
function makeMockChannel() {
|
|
8
|
+
return {
|
|
9
|
+
id: 'ch-general',
|
|
10
|
+
name: 'general',
|
|
11
|
+
type: ChannelType.GuildText,
|
|
12
|
+
send: vi.fn(async () => ({ id: 'sent-1' })),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function makeCtx(channel) {
|
|
16
|
+
const ch = channel ?? makeMockChannel();
|
|
17
|
+
return {
|
|
18
|
+
guild: {
|
|
19
|
+
channels: {
|
|
20
|
+
cache: {
|
|
21
|
+
get: (id) => id === ch.id ? ch : undefined,
|
|
22
|
+
find: (fn) => fn(ch) ? ch : undefined,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
client: {},
|
|
27
|
+
channelId: 'test-channel',
|
|
28
|
+
messageId: 'test-message',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function makeRuntime(events) {
|
|
32
|
+
return {
|
|
33
|
+
id: 'other',
|
|
34
|
+
capabilities: new Set(),
|
|
35
|
+
invoke: vi.fn(async function* () {
|
|
36
|
+
for (const event of events) {
|
|
37
|
+
yield event;
|
|
38
|
+
}
|
|
39
|
+
}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function makeSpawnCtx(overrides) {
|
|
43
|
+
return {
|
|
44
|
+
runtime: makeRuntime([{ type: 'text_delta', text: 'Agent output' }, { type: 'done' }]),
|
|
45
|
+
model: 'claude-opus-4',
|
|
46
|
+
cwd: '/tmp/workspace',
|
|
47
|
+
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Tests
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
describe('SPAWN_ACTION_TYPES', () => {
|
|
55
|
+
it('contains spawnAgent', () => {
|
|
56
|
+
expect(SPAWN_ACTION_TYPES.has('spawnAgent')).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
it('does not contain non-spawn types', () => {
|
|
59
|
+
expect(SPAWN_ACTION_TYPES.has('forgeCreate')).toBe(false);
|
|
60
|
+
expect(SPAWN_ACTION_TYPES.has('planRun')).toBe(false);
|
|
61
|
+
expect(SPAWN_ACTION_TYPES.has('cronCreate')).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe('executeSpawnAction', () => {
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
vi.clearAllMocks();
|
|
67
|
+
});
|
|
68
|
+
describe('spawnAgent', () => {
|
|
69
|
+
it('posts agent text output to target channel', async () => {
|
|
70
|
+
const channel = makeMockChannel();
|
|
71
|
+
const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Say hello' }, makeCtx(channel), makeSpawnCtx());
|
|
72
|
+
expect(result.ok).toBe(true);
|
|
73
|
+
expect(channel.send).toHaveBeenCalledWith(expect.objectContaining({ content: 'Agent output' }));
|
|
74
|
+
});
|
|
75
|
+
it('returns summary indicating agent posted to channel', async () => {
|
|
76
|
+
const channel = makeMockChannel();
|
|
77
|
+
const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Say hello' }, makeCtx(channel), makeSpawnCtx());
|
|
78
|
+
expect(result.ok).toBe(true);
|
|
79
|
+
if (result.ok) {
|
|
80
|
+
expect(result.summary).toContain('#general');
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
it('fails on empty channel', async () => {
|
|
84
|
+
const result = await executeSpawnAction({ type: 'spawnAgent', channel: '', prompt: 'Do something' }, makeCtx(), makeSpawnCtx());
|
|
85
|
+
expect(result.ok).toBe(false);
|
|
86
|
+
if (!result.ok)
|
|
87
|
+
expect(result.error).toContain('requires a non-empty channel');
|
|
88
|
+
});
|
|
89
|
+
it('fails on whitespace-only channel', async () => {
|
|
90
|
+
const result = await executeSpawnAction({ type: 'spawnAgent', channel: ' ', prompt: 'Do something' }, makeCtx(), makeSpawnCtx());
|
|
91
|
+
expect(result.ok).toBe(false);
|
|
92
|
+
if (!result.ok)
|
|
93
|
+
expect(result.error).toContain('requires a non-empty channel');
|
|
94
|
+
});
|
|
95
|
+
it('fails on empty prompt', async () => {
|
|
96
|
+
const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: '' }, makeCtx(), makeSpawnCtx());
|
|
97
|
+
expect(result.ok).toBe(false);
|
|
98
|
+
if (!result.ok)
|
|
99
|
+
expect(result.error).toContain('requires a non-empty prompt');
|
|
100
|
+
});
|
|
101
|
+
it('fails on whitespace-only prompt', async () => {
|
|
102
|
+
const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: ' ' }, makeCtx(), makeSpawnCtx());
|
|
103
|
+
expect(result.ok).toBe(false);
|
|
104
|
+
if (!result.ok)
|
|
105
|
+
expect(result.error).toContain('requires a non-empty prompt');
|
|
106
|
+
});
|
|
107
|
+
it('fails when channel is not found', async () => {
|
|
108
|
+
const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'nonexistent', prompt: 'Do something' }, makeCtx(), makeSpawnCtx());
|
|
109
|
+
expect(result.ok).toBe(false);
|
|
110
|
+
if (!result.ok)
|
|
111
|
+
expect(result.error).toContain('not found');
|
|
112
|
+
});
|
|
113
|
+
it('fails when channel is a non-text type', async () => {
|
|
114
|
+
const forumChannel = { id: 'ch-forum', name: 'ideas', type: ChannelType.GuildForum, send: vi.fn() };
|
|
115
|
+
const ctx = {
|
|
116
|
+
guild: {
|
|
117
|
+
channels: {
|
|
118
|
+
cache: {
|
|
119
|
+
get: (id) => id === forumChannel.id ? forumChannel : undefined,
|
|
120
|
+
find: (fn) => fn(forumChannel) ? forumChannel : undefined,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
client: {},
|
|
125
|
+
channelId: 'test-channel',
|
|
126
|
+
messageId: 'test-message',
|
|
127
|
+
};
|
|
128
|
+
const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'ideas', prompt: 'Do something' }, ctx, makeSpawnCtx());
|
|
129
|
+
expect(result.ok).toBe(false);
|
|
130
|
+
if (!result.ok) {
|
|
131
|
+
expect(result.error).toContain('forum');
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
it('blocks at recursion depth >= 1', async () => {
|
|
135
|
+
const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Do something' }, makeCtx(), makeSpawnCtx({ depth: 1 }));
|
|
136
|
+
expect(result.ok).toBe(false);
|
|
137
|
+
if (!result.ok)
|
|
138
|
+
expect(result.error).toContain('recursion depth');
|
|
139
|
+
});
|
|
140
|
+
it('uses label in error messages on runtime error event', async () => {
|
|
141
|
+
const runtime = makeRuntime([{ type: 'error', message: 'Runtime error' }]);
|
|
142
|
+
const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Do something', label: 'my-agent' }, makeCtx(), makeSpawnCtx({ runtime }));
|
|
143
|
+
expect(result.ok).toBe(false);
|
|
144
|
+
if (!result.ok) {
|
|
145
|
+
expect(result.error).toContain('my-agent');
|
|
146
|
+
expect(result.error).toContain('Runtime error');
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
it('prefers text_final over accumulated text_delta and posts to channel', async () => {
|
|
150
|
+
const runtime = makeRuntime([
|
|
151
|
+
{ type: 'text_delta', text: 'partial ' },
|
|
152
|
+
{ type: 'text_delta', text: 'output' },
|
|
153
|
+
{ type: 'text_final', text: 'Final output' },
|
|
154
|
+
{ type: 'done' },
|
|
155
|
+
]);
|
|
156
|
+
const channel = makeMockChannel();
|
|
157
|
+
const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Do something' }, makeCtx(channel), makeSpawnCtx({ runtime }));
|
|
158
|
+
expect(result.ok).toBe(true);
|
|
159
|
+
expect(channel.send).toHaveBeenCalledWith(expect.objectContaining({ content: 'Final output' }));
|
|
160
|
+
});
|
|
161
|
+
it('accumulates multiple text_delta events and posts combined text to channel', async () => {
|
|
162
|
+
const runtime = makeRuntime([
|
|
163
|
+
{ type: 'text_delta', text: 'Hello ' },
|
|
164
|
+
{ type: 'text_delta', text: 'world' },
|
|
165
|
+
{ type: 'done' },
|
|
166
|
+
]);
|
|
167
|
+
const channel = makeMockChannel();
|
|
168
|
+
const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Greet' }, makeCtx(channel), makeSpawnCtx({ runtime }));
|
|
169
|
+
expect(result.ok).toBe(true);
|
|
170
|
+
expect(channel.send).toHaveBeenCalledWith(expect.objectContaining({ content: 'Hello world' }));
|
|
171
|
+
});
|
|
172
|
+
it('handles runtime throw as error result', async () => {
|
|
173
|
+
const runtime = {
|
|
174
|
+
id: 'other',
|
|
175
|
+
capabilities: new Set(),
|
|
176
|
+
invoke: vi.fn(async function* () {
|
|
177
|
+
throw new Error('connection failed');
|
|
178
|
+
}),
|
|
179
|
+
};
|
|
180
|
+
const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Do something' }, makeCtx(), makeSpawnCtx({ runtime }));
|
|
181
|
+
expect(result.ok).toBe(false);
|
|
182
|
+
if (!result.ok)
|
|
183
|
+
expect(result.error).toContain('connection failed');
|
|
184
|
+
});
|
|
185
|
+
it('posts fallback message with label to channel when agent outputs nothing', async () => {
|
|
186
|
+
const runtime = makeRuntime([{ type: 'done' }]);
|
|
187
|
+
const channel = makeMockChannel();
|
|
188
|
+
const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Do something', label: 'silent-agent' }, makeCtx(channel), makeSpawnCtx({ runtime }));
|
|
189
|
+
expect(result.ok).toBe(true);
|
|
190
|
+
expect(channel.send).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('silent-agent') }));
|
|
191
|
+
expect(channel.send).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('no output') }));
|
|
192
|
+
});
|
|
193
|
+
it('uses "agent" as default label in fallback message', async () => {
|
|
194
|
+
const runtime = makeRuntime([{ type: 'done' }]);
|
|
195
|
+
const channel = makeMockChannel();
|
|
196
|
+
const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Do something' }, makeCtx(channel), makeSpawnCtx({ runtime }));
|
|
197
|
+
expect(result.ok).toBe(true);
|
|
198
|
+
expect(channel.send).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('agent') }));
|
|
199
|
+
});
|
|
200
|
+
it('invokes runtime with correct model, cwd, and prompt', async () => {
|
|
201
|
+
const runtime = makeRuntime([{ type: 'done' }]);
|
|
202
|
+
const spawnCtx = makeSpawnCtx({ runtime, model: 'claude-opus-4-6', cwd: '/my/cwd' });
|
|
203
|
+
await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Do something specific' }, makeCtx(), spawnCtx);
|
|
204
|
+
expect(runtime.invoke).toHaveBeenCalledWith(expect.objectContaining({
|
|
205
|
+
model: 'claude-opus-4-6',
|
|
206
|
+
cwd: '/my/cwd',
|
|
207
|
+
prompt: 'Do something specific',
|
|
208
|
+
}));
|
|
209
|
+
});
|
|
210
|
+
it('action.model overrides spawnCtx.model', async () => {
|
|
211
|
+
const runtime = makeRuntime([{ type: 'done' }]);
|
|
212
|
+
const spawnCtx = makeSpawnCtx({ runtime, model: 'claude-haiku-4-5-20251001' });
|
|
213
|
+
await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Task', model: 'claude-opus-4-6' }, makeCtx(), spawnCtx);
|
|
214
|
+
expect(runtime.invoke).toHaveBeenCalledWith(expect.objectContaining({ model: 'claude-opus-4-6' }));
|
|
215
|
+
});
|
|
216
|
+
it('passes timeoutMs to runtime invocation', async () => {
|
|
217
|
+
const runtime = makeRuntime([{ type: 'done' }]);
|
|
218
|
+
const spawnCtx = makeSpawnCtx({ runtime, timeoutMs: 30_000 });
|
|
219
|
+
await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Quick task' }, makeCtx(), spawnCtx);
|
|
220
|
+
expect(runtime.invoke).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 30_000 }));
|
|
221
|
+
});
|
|
222
|
+
it('uses default timeout of 120_000 when not specified', async () => {
|
|
223
|
+
const runtime = makeRuntime([{ type: 'done' }]);
|
|
224
|
+
const spawnCtx = makeSpawnCtx({ runtime });
|
|
225
|
+
await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Task' }, makeCtx(), spawnCtx);
|
|
226
|
+
expect(runtime.invoke).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 120_000 }));
|
|
227
|
+
});
|
|
228
|
+
it('posts output without allowed mentions', async () => {
|
|
229
|
+
const channel = makeMockChannel();
|
|
230
|
+
const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Say hello' }, makeCtx(channel), makeSpawnCtx());
|
|
231
|
+
expect(result.ok).toBe(true);
|
|
232
|
+
expect(channel.send).toHaveBeenCalledWith(expect.objectContaining({ allowedMentions: expect.objectContaining({ parse: [] }) }));
|
|
233
|
+
});
|
|
234
|
+
it('handles channel.send error as error result', async () => {
|
|
235
|
+
const channel = { ...makeMockChannel(), send: vi.fn(async () => { throw new Error('Missing Access'); }) };
|
|
236
|
+
const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Do something' }, makeCtx(channel), makeSpawnCtx());
|
|
237
|
+
expect(result.ok).toBe(false);
|
|
238
|
+
if (!result.ok)
|
|
239
|
+
expect(result.error).toContain('Missing Access');
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
describe('executeSpawnActions', () => {
|
|
244
|
+
beforeEach(() => {
|
|
245
|
+
vi.clearAllMocks();
|
|
246
|
+
});
|
|
247
|
+
it('returns empty array for no actions', async () => {
|
|
248
|
+
const results = await executeSpawnActions([], makeCtx(), makeSpawnCtx());
|
|
249
|
+
expect(results).toEqual([]);
|
|
250
|
+
});
|
|
251
|
+
it('runs a single agent and returns its result', async () => {
|
|
252
|
+
const results = await executeSpawnActions([{ type: 'spawnAgent', channel: 'general', prompt: 'Single task' }], makeCtx(), makeSpawnCtx());
|
|
253
|
+
expect(results).toHaveLength(1);
|
|
254
|
+
expect(results[0].ok).toBe(true);
|
|
255
|
+
});
|
|
256
|
+
it('runs multiple agents and returns results in order', async () => {
|
|
257
|
+
let callCount = 0;
|
|
258
|
+
const runtime = {
|
|
259
|
+
id: 'other',
|
|
260
|
+
capabilities: new Set(),
|
|
261
|
+
invoke: vi.fn(async function* () {
|
|
262
|
+
const n = ++callCount;
|
|
263
|
+
yield { type: 'text_delta', text: `result-${n}` };
|
|
264
|
+
yield { type: 'done' };
|
|
265
|
+
}),
|
|
266
|
+
};
|
|
267
|
+
const spawnCtx = makeSpawnCtx({ runtime });
|
|
268
|
+
const results = await executeSpawnActions([
|
|
269
|
+
{ type: 'spawnAgent', channel: 'general', prompt: 'First task' },
|
|
270
|
+
{ type: 'spawnAgent', channel: 'general', prompt: 'Second task' },
|
|
271
|
+
{ type: 'spawnAgent', channel: 'general', prompt: 'Third task' },
|
|
272
|
+
], makeCtx(), spawnCtx);
|
|
273
|
+
expect(results).toHaveLength(3);
|
|
274
|
+
expect(results.every((r) => r.ok)).toBe(true);
|
|
275
|
+
});
|
|
276
|
+
it('respects maxConcurrent limit per batch', async () => {
|
|
277
|
+
let concurrentCount = 0;
|
|
278
|
+
let maxSeen = 0;
|
|
279
|
+
const runtime = {
|
|
280
|
+
id: 'other',
|
|
281
|
+
capabilities: new Set(),
|
|
282
|
+
invoke: vi.fn(async function* () {
|
|
283
|
+
concurrentCount++;
|
|
284
|
+
maxSeen = Math.max(maxSeen, concurrentCount);
|
|
285
|
+
yield { type: 'text_delta', text: 'ok' };
|
|
286
|
+
yield { type: 'done' };
|
|
287
|
+
concurrentCount--;
|
|
288
|
+
}),
|
|
289
|
+
};
|
|
290
|
+
await executeSpawnActions(Array.from({ length: 8 }, (_, i) => ({ type: 'spawnAgent', channel: 'general', prompt: `Task ${i}` })), makeCtx(), makeSpawnCtx({ runtime, maxConcurrent: 3 }));
|
|
291
|
+
expect(maxSeen).toBeLessThanOrEqual(3);
|
|
292
|
+
});
|
|
293
|
+
it('uses default maxConcurrent of 4', async () => {
|
|
294
|
+
let concurrentCount = 0;
|
|
295
|
+
let maxSeen = 0;
|
|
296
|
+
const runtime = {
|
|
297
|
+
id: 'other',
|
|
298
|
+
capabilities: new Set(),
|
|
299
|
+
invoke: vi.fn(async function* () {
|
|
300
|
+
concurrentCount++;
|
|
301
|
+
maxSeen = Math.max(maxSeen, concurrentCount);
|
|
302
|
+
yield { type: 'text_delta', text: 'ok' };
|
|
303
|
+
yield { type: 'done' };
|
|
304
|
+
concurrentCount--;
|
|
305
|
+
}),
|
|
306
|
+
};
|
|
307
|
+
await executeSpawnActions(Array.from({ length: 10 }, (_, i) => ({ type: 'spawnAgent', channel: 'general', prompt: `Task ${i}` })), makeCtx(), makeSpawnCtx({ runtime }));
|
|
308
|
+
expect(maxSeen).toBeLessThanOrEqual(4);
|
|
309
|
+
});
|
|
310
|
+
it('collects errors without aborting other agents', async () => {
|
|
311
|
+
let callIndex = 0;
|
|
312
|
+
const runtime = {
|
|
313
|
+
id: 'other',
|
|
314
|
+
capabilities: new Set(),
|
|
315
|
+
invoke: vi.fn(async function* () {
|
|
316
|
+
const i = callIndex++;
|
|
317
|
+
if (i === 1) {
|
|
318
|
+
yield { type: 'error', message: 'agent-1 failed' };
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
yield { type: 'text_delta', text: `result-${i}` };
|
|
322
|
+
yield { type: 'done' };
|
|
323
|
+
}
|
|
324
|
+
}),
|
|
325
|
+
};
|
|
326
|
+
const results = await executeSpawnActions([
|
|
327
|
+
{ type: 'spawnAgent', channel: 'general', prompt: 'First' },
|
|
328
|
+
{ type: 'spawnAgent', channel: 'general', prompt: 'Second' },
|
|
329
|
+
{ type: 'spawnAgent', channel: 'general', prompt: 'Third' },
|
|
330
|
+
], makeCtx(), makeSpawnCtx({ runtime }));
|
|
331
|
+
expect(results).toHaveLength(3);
|
|
332
|
+
expect(results[0].ok).toBe(true);
|
|
333
|
+
expect(results[1].ok).toBe(false);
|
|
334
|
+
if (!results[1].ok)
|
|
335
|
+
expect(results[1].error).toContain('agent-1 failed');
|
|
336
|
+
expect(results[2].ok).toBe(true);
|
|
337
|
+
});
|
|
338
|
+
it('all results are ok: false when depth >= 1 (recursion guard)', async () => {
|
|
339
|
+
const results = await executeSpawnActions([
|
|
340
|
+
{ type: 'spawnAgent', channel: 'general', prompt: 'First' },
|
|
341
|
+
{ type: 'spawnAgent', channel: 'general', prompt: 'Second' },
|
|
342
|
+
], makeCtx(), makeSpawnCtx({ depth: 1 }));
|
|
343
|
+
expect(results).toHaveLength(2);
|
|
344
|
+
expect(results.every((r) => !r.ok)).toBe(true);
|
|
345
|
+
for (const r of results) {
|
|
346
|
+
if (!r.ok)
|
|
347
|
+
expect(r.error).toContain('recursion depth');
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
describe('spawnActionsPromptSection', () => {
|
|
352
|
+
it('returns non-empty prompt section containing spawnAgent', () => {
|
|
353
|
+
const section = spawnActionsPromptSection();
|
|
354
|
+
expect(section).toContain('spawnAgent');
|
|
355
|
+
});
|
|
356
|
+
it('documents the channel field', () => {
|
|
357
|
+
const section = spawnActionsPromptSection();
|
|
358
|
+
expect(section).toContain('channel');
|
|
359
|
+
});
|
|
360
|
+
it('documents the prompt field', () => {
|
|
361
|
+
const section = spawnActionsPromptSection();
|
|
362
|
+
expect(section).toContain('prompt');
|
|
363
|
+
});
|
|
364
|
+
it('documents the model field', () => {
|
|
365
|
+
const section = spawnActionsPromptSection();
|
|
366
|
+
expect(section).toContain('model');
|
|
367
|
+
});
|
|
368
|
+
it('documents the label field', () => {
|
|
369
|
+
const section = spawnActionsPromptSection();
|
|
370
|
+
expect(section).toContain('label');
|
|
371
|
+
});
|
|
372
|
+
it('mentions parallel execution', () => {
|
|
373
|
+
const section = spawnActionsPromptSection();
|
|
374
|
+
expect(section).toContain('parallel');
|
|
375
|
+
});
|
|
376
|
+
it('mentions recursion depth guard', () => {
|
|
377
|
+
const section = spawnActionsPromptSection();
|
|
378
|
+
expect(section).toContain('recursion');
|
|
379
|
+
});
|
|
380
|
+
it('includes a usage example block', () => {
|
|
381
|
+
const section = spawnActionsPromptSection();
|
|
382
|
+
expect(section).toContain('<discord-action>');
|
|
383
|
+
expect(section).toContain('spawnAgent');
|
|
384
|
+
});
|
|
385
|
+
});
|
package/dist/discord/actions.js
CHANGED
|
@@ -14,6 +14,7 @@ import { CONFIG_ACTION_TYPES, executeConfigAction, configActionsPromptSection }
|
|
|
14
14
|
import { executeReactionPromptAction as executeReactionPrompt, REACTION_PROMPT_ACTION_TYPES, reactionPromptSection } from './reaction-prompts.js';
|
|
15
15
|
import { IMAGEGEN_ACTION_TYPES, executeImagegenAction, imagegenActionsPromptSection } from './actions-imagegen.js';
|
|
16
16
|
import { VOICE_ACTION_TYPES, executeVoiceAction, voiceActionsPromptSection } from './actions-voice.js';
|
|
17
|
+
import { SPAWN_ACTION_TYPES, executeSpawnActions, spawnActionsPromptSection } from './actions-spawn.js';
|
|
17
18
|
import { describeDestructiveConfirmationRequirement } from './destructive-confirmation.js';
|
|
18
19
|
// ---------------------------------------------------------------------------
|
|
19
20
|
// Valid types (union of all sub-module type sets)
|
|
@@ -68,6 +69,9 @@ function buildValidTypes(flags) {
|
|
|
68
69
|
if (flags.voice)
|
|
69
70
|
for (const t of VOICE_ACTION_TYPES)
|
|
70
71
|
types.add(t);
|
|
72
|
+
if (flags.spawn)
|
|
73
|
+
for (const t of SPAWN_ACTION_TYPES)
|
|
74
|
+
types.add(t);
|
|
71
75
|
return types;
|
|
72
76
|
}
|
|
73
77
|
function rewriteLegacyPlanCloseToTaskClose(parsed, flags) {
|
|
@@ -421,9 +425,37 @@ export function parseDiscordActions(text, flags) {
|
|
|
421
425
|
// ---------------------------------------------------------------------------
|
|
422
426
|
export async function executeDiscordActions(actions, ctx, log, subs) {
|
|
423
427
|
const effectiveSubs = subs ?? {};
|
|
428
|
+
// --- Spawn pre-pass: collect all spawnAgent actions and run in parallel ---
|
|
429
|
+
const spawnResultByIndex = new Map();
|
|
430
|
+
if (effectiveSubs.spawnCtx) {
|
|
431
|
+
const spawnActions = [];
|
|
432
|
+
const spawnIndices = [];
|
|
433
|
+
for (let i = 0; i < actions.length; i++) {
|
|
434
|
+
if (SPAWN_ACTION_TYPES.has(actions[i].type)) {
|
|
435
|
+
spawnActions.push(actions[i]);
|
|
436
|
+
spawnIndices.push(i);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (spawnActions.length > 0) {
|
|
440
|
+
const spawnResults = await executeSpawnActions(spawnActions, ctx, effectiveSubs.spawnCtx);
|
|
441
|
+
for (let i = 0; i < spawnIndices.length; i++) {
|
|
442
|
+
spawnResultByIndex.set(spawnIndices[i], spawnResults[i]);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
424
446
|
const results = [];
|
|
425
|
-
for (
|
|
447
|
+
for (let actionIdx = 0; actionIdx < actions.length; actionIdx++) {
|
|
448
|
+
const action = actions[actionIdx];
|
|
426
449
|
try {
|
|
450
|
+
// Spawn actions were executed in parallel in the pre-pass above.
|
|
451
|
+
if (spawnResultByIndex.has(actionIdx)) {
|
|
452
|
+
const result = spawnResultByIndex.get(actionIdx);
|
|
453
|
+
results.push(result);
|
|
454
|
+
if (result.ok) {
|
|
455
|
+
log?.info({ action: action.type, summary: result.summary }, `discord:action ${action.type}`);
|
|
456
|
+
}
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
427
459
|
let result;
|
|
428
460
|
const destructiveCheck = describeDestructiveConfirmationRequirement(action, ctx.confirmation);
|
|
429
461
|
if (!destructiveCheck.allow) {
|
|
@@ -520,6 +552,10 @@ export async function executeDiscordActions(actions, ctx, log, subs) {
|
|
|
520
552
|
result = await executeVoiceAction(action, ctx, effectiveSubs.voiceCtx);
|
|
521
553
|
}
|
|
522
554
|
}
|
|
555
|
+
else if (SPAWN_ACTION_TYPES.has(action.type)) {
|
|
556
|
+
// spawnCtx not configured — would have been handled in pre-pass otherwise.
|
|
557
|
+
result = { ok: false, error: 'Spawn subsystem not configured' };
|
|
558
|
+
}
|
|
523
559
|
else {
|
|
524
560
|
result = { ok: false, error: `Unknown action type: ${String(action.type ?? 'unknown')}` };
|
|
525
561
|
}
|
|
@@ -547,7 +583,7 @@ export async function executeDiscordActions(actions, ctx, log, subs) {
|
|
|
547
583
|
export function buildDisplayResultLines(actions, results) {
|
|
548
584
|
return results
|
|
549
585
|
.map((r, i) => {
|
|
550
|
-
if (r.ok && (actions[i]?.type === 'sendMessage' || actions[i]?.type === 'sendFile'))
|
|
586
|
+
if (r.ok && (actions[i]?.type === 'sendMessage' || actions[i]?.type === 'sendFile' || actions[i]?.type === 'spawnAgent'))
|
|
551
587
|
return null;
|
|
552
588
|
return r.ok ? `Done: ${r.summary}` : `Failed: ${r.error}`;
|
|
553
589
|
})
|
|
@@ -611,6 +647,9 @@ Setting DISCOCLAW_DISCORD_ACTIONS=1 publishes this standard guidance (even if on
|
|
|
611
647
|
if (flags.voice) {
|
|
612
648
|
sections.push(voiceActionsPromptSection());
|
|
613
649
|
}
|
|
650
|
+
if (flags.spawn) {
|
|
651
|
+
sections.push(spawnActionsPromptSection());
|
|
652
|
+
}
|
|
614
653
|
sections.push(`### Rules
|
|
615
654
|
- Only the action types listed above are supported.
|
|
616
655
|
- Never emit an action with empty, placeholder, or missing values for required parameters. If you don't have the value (e.g., no messageId for react), skip the action entirely.
|
|
@@ -341,6 +341,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
|
|
|
341
341
|
defer: !isDm && (params.discordActionsDefer ?? false),
|
|
342
342
|
imagegen: params.discordActionsImagegen ?? false,
|
|
343
343
|
voice: params.discordActionsVoice ?? false,
|
|
344
|
+
spawn: params.discordActionsSpawn ?? false,
|
|
344
345
|
};
|
|
345
346
|
if (isBotMessage) {
|
|
346
347
|
actionFlags.channels = false;
|
|
@@ -356,6 +357,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
|
|
|
356
357
|
actionFlags.tasks = false;
|
|
357
358
|
actionFlags.imagegen = false;
|
|
358
359
|
actionFlags.voice = false;
|
|
360
|
+
actionFlags.spawn = false;
|
|
359
361
|
actionFlags.polls = false;
|
|
360
362
|
actionFlags.messaging = true;
|
|
361
363
|
}
|
|
@@ -1719,6 +1721,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
|
|
|
1719
1721
|
configCtx: params.configCtx,
|
|
1720
1722
|
imagegenCtx: params.imagegenCtx,
|
|
1721
1723
|
voiceCtx: params.voiceCtx,
|
|
1724
|
+
spawnCtx: params.spawnCtx,
|
|
1722
1725
|
});
|
|
1723
1726
|
const displayLines = buildDisplayResultLines([confirmAction], actionResults);
|
|
1724
1727
|
const content = displayLines.length > 0
|
|
@@ -2247,6 +2250,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
|
|
|
2247
2250
|
configCtx: params.configCtx,
|
|
2248
2251
|
imagegenCtx: params.imagegenCtx,
|
|
2249
2252
|
voiceCtx: params.voiceCtx,
|
|
2253
|
+
spawnCtx: params.spawnCtx,
|
|
2250
2254
|
});
|
|
2251
2255
|
for (const result of actionResults) {
|
|
2252
2256
|
metrics.recordActionResult(result.ok);
|
|
@@ -322,6 +322,7 @@ function createReactionHandler(mode, params, queue, statusRef) {
|
|
|
322
322
|
defer: !isDm && (params.discordActionsDefer ?? false),
|
|
323
323
|
imagegen: params.discordActionsImagegen ?? false,
|
|
324
324
|
voice: params.discordActionsVoice ?? false,
|
|
325
|
+
spawn: params.discordActionsSpawn ?? false,
|
|
325
326
|
};
|
|
326
327
|
if (params.discordActionsEnabled && !isDm) {
|
|
327
328
|
prompt += '\n\n---\n' + discordActionsPromptSection(actionFlags, params.botDisplayName);
|
|
@@ -554,6 +555,7 @@ function createReactionHandler(mode, params, queue, statusRef) {
|
|
|
554
555
|
configCtx: params.configCtx,
|
|
555
556
|
imagegenCtx: params.imagegenCtx,
|
|
556
557
|
voiceCtx: params.voiceCtx,
|
|
558
|
+
spawnCtx: params.spawnCtx,
|
|
557
559
|
});
|
|
558
560
|
actionResults = results;
|
|
559
561
|
for (const result of results) {
|
|
@@ -97,7 +97,7 @@ export async function handleUpdateCommand(cmd, opts = {}) {
|
|
|
97
97
|
}
|
|
98
98
|
if (npmMode) {
|
|
99
99
|
progress('Installing latest version from npm...');
|
|
100
|
-
const install = await run('npm', ['install', '-g', 'discoclaw@latest'], { timeout: 120_000 });
|
|
100
|
+
const install = await run('npm', ['install', '-g', 'discoclaw@latest', '--loglevel=error'], { timeout: 120_000 });
|
|
101
101
|
if (install.exitCode !== 0) {
|
|
102
102
|
const detail = (install.stderr || install.stdout).trim().slice(0, 500);
|
|
103
103
|
return { reply: `\`npm install -g discoclaw@latest\` failed:\n\`\`\`\n${detail}\n\`\`\`` };
|
|
@@ -334,6 +334,7 @@ describe('handleUpdateCommand: npm-managed mode', () => {
|
|
|
334
334
|
const calls = execFile.mock.calls;
|
|
335
335
|
const installCall = calls.find(([cmd, args]) => cmd === 'npm' && args.includes('install') && args.includes('discoclaw@latest'));
|
|
336
336
|
expect(installCall).toBeDefined();
|
|
337
|
+
expect(installCall[1]).toContain('--loglevel=error');
|
|
337
338
|
});
|
|
338
339
|
it('apply reports error on install failure', async () => {
|
|
339
340
|
const { execFile } = await import('node:child_process');
|
package/dist/index.js
CHANGED
|
@@ -724,6 +724,7 @@ const botParams = {
|
|
|
724
724
|
discordActionsMemory: discordActionsMemory && durableMemoryEnabled,
|
|
725
725
|
discordActionsImagegen: cfg.discordActionsImagegen,
|
|
726
726
|
discordActionsVoice: cfg.discordActionsVoice && cfg.voiceEnabled,
|
|
727
|
+
discordActionsSpawn: cfg.discordActionsSpawn,
|
|
727
728
|
discordActionsConfig: discordActionsEnabled, // Always enabled when actions are on — model switching is a core capability.
|
|
728
729
|
discordActionsDefer: cfg.discordActionsDefer,
|
|
729
730
|
deferMaxDelaySeconds: cfg.deferMaxDelaySeconds,
|
|
@@ -735,6 +736,7 @@ const botParams = {
|
|
|
735
736
|
planCtx: undefined,
|
|
736
737
|
memoryCtx: undefined,
|
|
737
738
|
imagegenCtx: undefined,
|
|
739
|
+
spawnCtx: undefined,
|
|
738
740
|
voiceCtx: undefined,
|
|
739
741
|
voiceStatusCtx: undefined,
|
|
740
742
|
setTtsVoice: undefined,
|
|
@@ -1091,6 +1093,16 @@ if (taskCtx) {
|
|
|
1091
1093
|
};
|
|
1092
1094
|
log.info('imagegen:action context initialized');
|
|
1093
1095
|
}
|
|
1096
|
+
if (discordActionsEnabled && cfg.discordActionsSpawn) {
|
|
1097
|
+
botParams.spawnCtx = {
|
|
1098
|
+
runtime: limitedRuntime,
|
|
1099
|
+
model: runtimeModel,
|
|
1100
|
+
cwd: workspaceCwd,
|
|
1101
|
+
log,
|
|
1102
|
+
maxConcurrent: cfg.spawnMaxConcurrent,
|
|
1103
|
+
};
|
|
1104
|
+
log.info({ maxConcurrent: cfg.spawnMaxConcurrent }, 'spawn:action context initialized');
|
|
1105
|
+
}
|
|
1094
1106
|
if (cfg.voiceEnabled) {
|
|
1095
1107
|
const voiceLogChannelRef = cfg.voiceLogChannel ?? system?.voiceLogChannelId;
|
|
1096
1108
|
const transcriptMirror = voiceLogChannelRef
|
|
@@ -1269,6 +1281,7 @@ if (taskCtx) {
|
|
|
1269
1281
|
deepgramApiKey: cfg.deepgramApiKey,
|
|
1270
1282
|
deepgramSttModel: cfg.deepgramSttModel,
|
|
1271
1283
|
deepgramTtsVoice: overrides.ttsVoice ?? cfg.deepgramTtsVoice,
|
|
1284
|
+
deepgramTtsSpeed: cfg.deepgramTtsSpeed,
|
|
1272
1285
|
cartesiaApiKey: cfg.cartesiaApiKey,
|
|
1273
1286
|
openaiApiKey: cfg.openaiApiKey,
|
|
1274
1287
|
},
|
|
@@ -1354,6 +1367,7 @@ if (cronEnabled && effectiveCronForum) {
|
|
|
1354
1367
|
defer: false,
|
|
1355
1368
|
imagegen: Boolean(botParams.imagegenCtx), // Follows env flag (DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN + API key) — cron jobs may generate images if explicitly configured.
|
|
1356
1369
|
voice: Boolean(botParams.voiceCtx), // Follows env flag (DISCOCLAW_DISCORD_ACTIONS_VOICE + VOICE_ENABLED) — cron jobs may use voice if configured.
|
|
1370
|
+
spawn: false, // Spawn is excluded from cron flows to prevent recursive agent spawning from scheduled jobs.
|
|
1357
1371
|
};
|
|
1358
1372
|
const cronRunControl = new CronRunControl();
|
|
1359
1373
|
// Load cron tag map (strict, but fallback to empty on first run)
|
package/dist/npm-managed.js
CHANGED
|
@@ -48,7 +48,7 @@ export async function getLatestNpmVersion() {
|
|
|
48
48
|
*/
|
|
49
49
|
export async function npmGlobalUpgrade() {
|
|
50
50
|
try {
|
|
51
|
-
const result = await execa('npm', ['install', '-g', 'discoclaw'], {
|
|
51
|
+
const result = await execa('npm', ['install', '-g', 'discoclaw', '--loglevel=error'], {
|
|
52
52
|
timeout: 120_000,
|
|
53
53
|
});
|
|
54
54
|
return {
|
package/dist/npm-managed.test.js
CHANGED
|
@@ -81,7 +81,7 @@ describe('npmGlobalUpgrade', () => {
|
|
|
81
81
|
expect(result.exitCode).toBe(0);
|
|
82
82
|
expect(result.stdout).toBe('added discoclaw@1.2.3');
|
|
83
83
|
expect(result.stderr).toBe('');
|
|
84
|
-
expect(mockExeca).toHaveBeenCalledWith('npm', ['install', '-g', 'discoclaw'], {
|
|
84
|
+
expect(mockExeca).toHaveBeenCalledWith('npm', ['install', '-g', 'discoclaw', '--loglevel=error'], {
|
|
85
85
|
timeout: 120_000,
|
|
86
86
|
});
|
|
87
87
|
});
|
|
@@ -12,12 +12,17 @@ export class DeepgramTtsProvider {
|
|
|
12
12
|
apiKey;
|
|
13
13
|
model;
|
|
14
14
|
sampleRate;
|
|
15
|
+
speed;
|
|
15
16
|
log;
|
|
16
17
|
fetchFn;
|
|
17
18
|
constructor(opts) {
|
|
19
|
+
if (opts.speed !== undefined && (opts.speed < 0.5 || opts.speed > 1.5)) {
|
|
20
|
+
throw new RangeError(`DeepgramTtsProvider: speed must be in range [0.5, 1.5], got ${opts.speed}`);
|
|
21
|
+
}
|
|
18
22
|
this.apiKey = opts.apiKey;
|
|
19
23
|
this.model = opts.model ?? DEFAULT_MODEL;
|
|
20
24
|
this.sampleRate = opts.sampleRate ?? DEFAULT_SAMPLE_RATE;
|
|
25
|
+
this.speed = opts.speed;
|
|
21
26
|
this.log = opts.log;
|
|
22
27
|
this.fetchFn = opts.fetchFn ?? globalThis.fetch;
|
|
23
28
|
}
|
|
@@ -37,6 +42,9 @@ export class DeepgramTtsProvider {
|
|
|
37
42
|
sample_rate: String(this.sampleRate),
|
|
38
43
|
container: 'none',
|
|
39
44
|
});
|
|
45
|
+
if (this.speed !== undefined) {
|
|
46
|
+
params.set('speed', String(this.speed));
|
|
47
|
+
}
|
|
40
48
|
const url = `${DEEPGRAM_SPEECH_URL}?${params.toString()}`;
|
|
41
49
|
this.log.info({ model: this.model, textLength: text.length }, 'Deepgram TTS: sending synthesis request');
|
|
42
50
|
const response = await this.fetchFn(url, {
|
|
@@ -34,6 +34,7 @@ function makeProvider(overrides = {}) {
|
|
|
34
34
|
apiKey: overrides.apiKey ?? 'test-key',
|
|
35
35
|
model: overrides.model,
|
|
36
36
|
sampleRate: overrides.sampleRate,
|
|
37
|
+
speed: overrides.speed,
|
|
37
38
|
log: overrides.log ?? createLogger(),
|
|
38
39
|
fetchFn: overrides.fetchFn ?? mockFetch(),
|
|
39
40
|
});
|
|
@@ -183,6 +184,28 @@ describe('DeepgramTtsProvider', () => {
|
|
|
183
184
|
expect(log.warn).not.toHaveBeenCalled();
|
|
184
185
|
});
|
|
185
186
|
});
|
|
187
|
+
describe('speed parameter', () => {
|
|
188
|
+
it('includes speed in the URL when set', async () => {
|
|
189
|
+
const fetchFn = mockFetch([new Uint8Array([1])]);
|
|
190
|
+
const provider = makeProvider({ fetchFn, speed: 1.2 });
|
|
191
|
+
await collectFrames(provider.synthesize('hello'));
|
|
192
|
+
const [url] = vi.mocked(fetchFn).mock.calls[0];
|
|
193
|
+
expect(url).toContain('speed=1.2');
|
|
194
|
+
});
|
|
195
|
+
it('omits speed from the URL when not set', async () => {
|
|
196
|
+
const fetchFn = mockFetch([new Uint8Array([1])]);
|
|
197
|
+
const provider = makeProvider({ fetchFn });
|
|
198
|
+
await collectFrames(provider.synthesize('hello'));
|
|
199
|
+
const [url] = vi.mocked(fetchFn).mock.calls[0];
|
|
200
|
+
expect(url).not.toContain('speed=');
|
|
201
|
+
});
|
|
202
|
+
it('throws RangeError when speed is below 0.5', () => {
|
|
203
|
+
expect(() => makeProvider({ speed: 0.4 })).toThrow(RangeError);
|
|
204
|
+
});
|
|
205
|
+
it('throws RangeError when speed is above 1.5', () => {
|
|
206
|
+
expect(() => makeProvider({ speed: 1.6 })).toThrow(RangeError);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
186
209
|
it('single large chunk yields one frame', async () => {
|
|
187
210
|
const big = new Uint8Array(16384);
|
|
188
211
|
big.fill(42);
|
|
@@ -10,4 +10,5 @@
|
|
|
10
10
|
export const VOICE_STYLE_INSTRUCTION = 'Telegraphic style: answer first, explain after only if needed. ' +
|
|
11
11
|
'No markdown (no bullets, headers, bold, code blocks). ' +
|
|
12
12
|
'No preambles ("Great question", "Sure", "Of course"). ' +
|
|
13
|
-
'No filler. Short sentences. One idea per sentence.'
|
|
13
|
+
'No filler. Short sentences. One idea per sentence. ' +
|
|
14
|
+
'Never read codes, IDs, or hashes verbatim — describe what they refer to instead.';
|
|
@@ -17,4 +17,8 @@ describe('VOICE_STYLE_INSTRUCTION', () => {
|
|
|
17
17
|
it('contains key term "no markdown"', () => {
|
|
18
18
|
expect(VOICE_STYLE_INSTRUCTION.toLowerCase()).toContain('no markdown');
|
|
19
19
|
});
|
|
20
|
+
it('contains key term "codes" or "IDs"', () => {
|
|
21
|
+
const lower = VOICE_STYLE_INSTRUCTION.toLowerCase();
|
|
22
|
+
expect(lower.includes('codes') || lower.includes('ids')).toBe(true);
|
|
23
|
+
});
|
|
20
24
|
});
|