opencode-swarm-plugin 0.34.0 → 0.36.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.
@@ -216,6 +216,315 @@ function buildDynamicSwarmState(state: SwarmState): string {
216
216
  return parts.join("\n");
217
217
  }
218
218
 
219
+ // ============================================================================
220
+ // SDK Message Scanning
221
+ // ============================================================================
222
+
223
+ /**
224
+ * Tool part with completed state containing input/output
225
+ */
226
+ interface ToolPart {
227
+ id: string;
228
+ sessionID: string;
229
+ messageID: string;
230
+ type: "tool";
231
+ callID: string;
232
+ tool: string;
233
+ state: ToolState;
234
+ }
235
+
236
+ /**
237
+ * Tool state (completed tools have input/output we need)
238
+ */
239
+ type ToolState =
240
+ | {
241
+ status: "completed";
242
+ input: { [key: string]: unknown };
243
+ output: string;
244
+ title: string;
245
+ metadata: { [key: string]: unknown };
246
+ time: { start: number; end: number };
247
+ }
248
+ | {
249
+ status: string;
250
+ [key: string]: unknown;
251
+ };
252
+
253
+ /**
254
+ * SDK Client type (minimal interface for scanSessionMessages)
255
+ *
256
+ * The actual SDK client uses a more complex Options-based API:
257
+ * client.session.messages({ path: { id: sessionID }, query: { limit } })
258
+ *
259
+ * We accept `unknown` and handle the type internally to avoid
260
+ * tight coupling to SDK internals.
261
+ */
262
+ export type OpencodeClient = unknown;
263
+
264
+ /**
265
+ * Scanned swarm state extracted from session messages
266
+ */
267
+ export interface ScannedSwarmState {
268
+ epicId?: string;
269
+ epicTitle?: string;
270
+ projectPath?: string;
271
+ agentName?: string;
272
+ subtasks: Map<
273
+ string,
274
+ { title: string; status: string; worker?: string; files?: string[] }
275
+ >;
276
+ lastAction?: { tool: string; args: unknown; timestamp: number };
277
+ }
278
+
279
+ /**
280
+ * Scan session messages for swarm state using SDK client
281
+ *
282
+ * Extracts swarm coordination state from actual tool calls:
283
+ * - swarm_spawn_subtask → subtask tracking
284
+ * - swarmmail_init → agent name, project path
285
+ * - hive_create_epic → epic ID and title
286
+ * - swarm_status → epic reference
287
+ * - swarm_complete → subtask completion
288
+ *
289
+ * @param client - OpenCode SDK client (undefined if not available)
290
+ * @param sessionID - Session to scan
291
+ * @param limit - Max messages to fetch (default 100)
292
+ * @returns Extracted swarm state
293
+ */
294
+ export async function scanSessionMessages(
295
+ client: OpencodeClient,
296
+ sessionID: string,
297
+ limit: number = 100,
298
+ ): Promise<ScannedSwarmState> {
299
+ const state: ScannedSwarmState = {
300
+ subtasks: new Map(),
301
+ };
302
+
303
+ if (!client) {
304
+ return state;
305
+ }
306
+
307
+ try {
308
+ // SDK client uses Options-based API: { path: { id }, query: { limit } }
309
+ const sdkClient = client as {
310
+ session: {
311
+ messages: (opts: {
312
+ path: { id: string };
313
+ query?: { limit?: number };
314
+ }) => Promise<{ data?: Array<{ info: unknown; parts: ToolPart[] }> }>;
315
+ };
316
+ };
317
+
318
+ const response = await sdkClient.session.messages({
319
+ path: { id: sessionID },
320
+ query: { limit },
321
+ });
322
+
323
+ const messages = response.data || [];
324
+
325
+ for (const message of messages) {
326
+ for (const part of message.parts) {
327
+ if (part.type !== "tool" || part.state.status !== "completed") {
328
+ continue;
329
+ }
330
+
331
+ const { tool, state: toolState } = part;
332
+ const { input, output, time } = toolState as Extract<
333
+ ToolState,
334
+ { status: "completed" }
335
+ >;
336
+
337
+ // Track last action
338
+ state.lastAction = {
339
+ tool,
340
+ args: input,
341
+ timestamp: time.end,
342
+ };
343
+
344
+ // Extract swarm state based on tool type
345
+ switch (tool) {
346
+ case "hive_create_epic": {
347
+ try {
348
+ const parsed = JSON.parse(output);
349
+ if (parsed.epic?.id) {
350
+ state.epicId = parsed.epic.id;
351
+ }
352
+ if (input.epic_title && typeof input.epic_title === "string") {
353
+ state.epicTitle = input.epic_title;
354
+ }
355
+ } catch {
356
+ // Invalid JSON, skip
357
+ }
358
+ break;
359
+ }
360
+
361
+ case "swarmmail_init": {
362
+ try {
363
+ const parsed = JSON.parse(output);
364
+ if (parsed.agent_name) {
365
+ state.agentName = parsed.agent_name;
366
+ }
367
+ if (parsed.project_key) {
368
+ state.projectPath = parsed.project_key;
369
+ }
370
+ } catch {
371
+ // Invalid JSON, skip
372
+ }
373
+ break;
374
+ }
375
+
376
+ case "swarm_spawn_subtask": {
377
+ const beadId = input.bead_id as string | undefined;
378
+ const epicId = input.epic_id as string | undefined;
379
+ const title = input.subtask_title as string | undefined;
380
+ const files = input.files as string[] | undefined;
381
+
382
+ if (beadId && title) {
383
+ let worker: string | undefined;
384
+ try {
385
+ const parsed = JSON.parse(output);
386
+ worker = parsed.worker;
387
+ } catch {
388
+ // No worker in output
389
+ }
390
+
391
+ state.subtasks.set(beadId, {
392
+ title,
393
+ status: "spawned",
394
+ worker,
395
+ files,
396
+ });
397
+
398
+ if (epicId && !state.epicId) {
399
+ state.epicId = epicId;
400
+ }
401
+ }
402
+ break;
403
+ }
404
+
405
+ case "swarm_complete": {
406
+ const beadId = input.bead_id as string | undefined;
407
+ if (beadId && state.subtasks.has(beadId)) {
408
+ const existing = state.subtasks.get(beadId)!;
409
+ state.subtasks.set(beadId, {
410
+ ...existing,
411
+ status: "completed",
412
+ });
413
+ }
414
+ break;
415
+ }
416
+
417
+ case "swarm_status": {
418
+ const epicId = input.epic_id as string | undefined;
419
+ if (epicId && !state.epicId) {
420
+ state.epicId = epicId;
421
+ }
422
+ const projectKey = input.project_key as string | undefined;
423
+ if (projectKey && !state.projectPath) {
424
+ state.projectPath = projectKey;
425
+ }
426
+ break;
427
+ }
428
+ }
429
+ }
430
+ }
431
+ } catch (error) {
432
+ getLog().debug(
433
+ {
434
+ error: error instanceof Error ? error.message : String(error),
435
+ },
436
+ "SDK message scanning failed",
437
+ );
438
+ // SDK not available or error fetching messages - return what we have
439
+ }
440
+
441
+ return state;
442
+ }
443
+
444
+ /**
445
+ * Build dynamic swarm state from scanned messages (more precise than hive detection)
446
+ */
447
+ function buildDynamicSwarmStateFromScanned(
448
+ scanned: ScannedSwarmState,
449
+ detected: SwarmState,
450
+ ): string {
451
+ const parts: string[] = [];
452
+
453
+ parts.push("## 🐝 Current Swarm State\n");
454
+
455
+ // Prefer scanned data over detected
456
+ const epicId = scanned.epicId || detected.epicId;
457
+ const epicTitle = scanned.epicTitle || detected.epicTitle;
458
+ const projectPath = scanned.projectPath || detected.projectPath;
459
+
460
+ if (epicId) {
461
+ parts.push(`**Epic:** ${epicId}${epicTitle ? ` - ${epicTitle}` : ""}`);
462
+ }
463
+
464
+ if (scanned.agentName) {
465
+ parts.push(`**Coordinator:** ${scanned.agentName}`);
466
+ }
467
+
468
+ parts.push(`**Project:** ${projectPath}`);
469
+
470
+ // Show detailed subtask info from scanned state
471
+ if (scanned.subtasks.size > 0) {
472
+ parts.push(`\n**Subtasks:**`);
473
+ for (const [id, subtask] of scanned.subtasks) {
474
+ const status = subtask.status === "completed" ? "✓" : `[${subtask.status}]`;
475
+ const worker = subtask.worker ? ` → ${subtask.worker}` : "";
476
+ const files = subtask.files?.length ? ` (${subtask.files.join(", ")})` : "";
477
+ parts.push(` - ${id}: ${subtask.title} ${status}${worker}${files}`);
478
+ }
479
+ } else if (detected.subtasks) {
480
+ // Fall back to counts from hive detection
481
+ const total =
482
+ detected.subtasks.closed +
483
+ detected.subtasks.in_progress +
484
+ detected.subtasks.open +
485
+ detected.subtasks.blocked;
486
+
487
+ if (total > 0) {
488
+ parts.push(`**Subtasks:**`);
489
+ if (detected.subtasks.closed > 0)
490
+ parts.push(` - ${detected.subtasks.closed} closed`);
491
+ if (detected.subtasks.in_progress > 0)
492
+ parts.push(` - ${detected.subtasks.in_progress} in_progress`);
493
+ if (detected.subtasks.open > 0)
494
+ parts.push(` - ${detected.subtasks.open} open`);
495
+ if (detected.subtasks.blocked > 0)
496
+ parts.push(` - ${detected.subtasks.blocked} blocked`);
497
+ }
498
+ }
499
+
500
+ // Show last action if available
501
+ if (scanned.lastAction) {
502
+ parts.push(`\n**Last Action:** \`${scanned.lastAction.tool}\``);
503
+ }
504
+
505
+ if (epicId) {
506
+ parts.push(`\n## 🎯 YOU ARE THE COORDINATOR`);
507
+ parts.push(``);
508
+ parts.push(
509
+ `**Primary role:** Orchestrate workers, review their output, unblock dependencies.`,
510
+ );
511
+ parts.push(`**Spawn workers** for implementation tasks - don't do them yourself.`);
512
+ parts.push(``);
513
+ parts.push(`**RESUME STEPS:**`);
514
+ parts.push(
515
+ `1. Check swarm status: \`swarm_status(epic_id="${epicId}", project_key="${projectPath}")\``,
516
+ );
517
+ parts.push(`2. Check inbox for worker messages: \`swarmmail_inbox(limit=5)\``);
518
+ parts.push(
519
+ `3. For in_progress subtasks: Review worker results with \`swarm_review\``,
520
+ );
521
+ parts.push(`4. For open subtasks: Spawn workers with \`swarm_spawn_subtask\``);
522
+ parts.push(`5. For blocked subtasks: Investigate and unblock`);
523
+ }
524
+
525
+ return parts.join("\n");
526
+ }
527
+
219
528
  // ============================================================================
220
529
  // Swarm Detection
221
530
  // ============================================================================
@@ -481,17 +790,21 @@ async function detectSwarm(): Promise<SwarmDetection> {
481
790
  * Philosophy: Err on the side of continuation. A false positive costs
482
791
  * a bit of context space. A false negative loses the swarm.
483
792
  *
793
+ * @param client - Optional OpenCode SDK client for scanning session messages.
794
+ * When provided, extracts PRECISE swarm state from actual tool calls.
795
+ * When undefined, falls back to hive/swarm-mail heuristic detection.
796
+ *
484
797
  * @example
485
798
  * ```typescript
486
799
  * import { createCompactionHook } from "opencode-swarm-plugin";
487
800
  *
488
- * export const SwarmPlugin: Plugin = async () => ({
801
+ * export const SwarmPlugin: Plugin = async (input) => ({
489
802
  * tool: { ... },
490
- * "experimental.session.compacting": createCompactionHook(),
803
+ * "experimental.session.compacting": createCompactionHook(input.client),
491
804
  * });
492
805
  * ```
493
806
  */
494
- export function createCompactionHook() {
807
+ export function createCompactionHook(client?: OpencodeClient) {
495
808
  return async (
496
809
  input: { sessionID: string },
497
810
  output: { context: string[] },
@@ -502,41 +815,73 @@ export function createCompactionHook() {
502
815
  {
503
816
  session_id: input.sessionID,
504
817
  trigger: "session_compaction",
818
+ has_sdk_client: !!client,
505
819
  },
506
820
  "compaction started",
507
821
  );
508
822
 
509
823
  try {
824
+ // Scan session messages for precise swarm state (if client available)
825
+ const scannedState = await scanSessionMessages(client, input.sessionID);
826
+
827
+ // Also run heuristic detection from hive/swarm-mail
510
828
  const detection = await detectSwarm();
511
829
 
830
+ // Boost confidence if we found swarm evidence in session messages
831
+ let effectiveConfidence = detection.confidence;
832
+ if (scannedState.epicId || scannedState.subtasks.size > 0) {
833
+ // Session messages show swarm activity - this is HIGH confidence
834
+ if (effectiveConfidence === "none" || effectiveConfidence === "low") {
835
+ effectiveConfidence = "medium";
836
+ detection.reasons.push("swarm tool calls found in session");
837
+ }
838
+ if (scannedState.subtasks.size > 0) {
839
+ effectiveConfidence = "high";
840
+ detection.reasons.push(`${scannedState.subtasks.size} subtasks spawned`);
841
+ }
842
+ }
843
+
512
844
  if (
513
- detection.confidence === "high" ||
514
- detection.confidence === "medium"
845
+ effectiveConfidence === "high" ||
846
+ effectiveConfidence === "medium"
515
847
  ) {
516
848
  // Definite or probable swarm - inject full context
517
849
  const header = `[Swarm detected: ${detection.reasons.join(", ")}]\n\n`;
518
-
519
- // Build dynamic state section if we have specific data
850
+
851
+ // Build dynamic state section - prefer scanned state (ground truth) over detected
520
852
  let dynamicState = "";
521
- if (detection.state && detection.state.epicId) {
853
+ if (scannedState.epicId || scannedState.subtasks.size > 0) {
854
+ // Use scanned state (more precise)
855
+ dynamicState =
856
+ buildDynamicSwarmStateFromScanned(
857
+ scannedState,
858
+ detection.state || {
859
+ projectPath: scannedState.projectPath || process.cwd(),
860
+ subtasks: { closed: 0, in_progress: 0, open: 0, blocked: 0 },
861
+ },
862
+ ) + "\n\n";
863
+ } else if (detection.state && detection.state.epicId) {
864
+ // Fall back to hive-detected state
522
865
  dynamicState = buildDynamicSwarmState(detection.state) + "\n\n";
523
866
  }
524
-
867
+
525
868
  const contextContent = header + dynamicState + SWARM_COMPACTION_CONTEXT;
526
869
  output.context.push(contextContent);
527
870
 
528
871
  getLog().info(
529
872
  {
530
- confidence: detection.confidence,
873
+ confidence: effectiveConfidence,
531
874
  context_length: contextContent.length,
532
875
  context_type: "full",
533
876
  reasons: detection.reasons,
534
877
  has_dynamic_state: !!dynamicState,
535
- epic_id: detection.state?.epicId,
878
+ epic_id: scannedState.epicId || detection.state?.epicId,
879
+ scanned_subtasks: scannedState.subtasks.size,
880
+ scanned_agent: scannedState.agentName,
536
881
  },
537
882
  "injected swarm context",
538
883
  );
539
- } else if (detection.confidence === "low") {
884
+ } else if (effectiveConfidence === "low") {
540
885
  // Possible swarm - inject fallback detection prompt
541
886
  const header = `[Possible swarm: ${detection.reasons.join(", ")}]\n\n`;
542
887
  const contextContent = header + SWARM_DETECTION_FALLBACK;
@@ -544,7 +889,7 @@ export function createCompactionHook() {
544
889
 
545
890
  getLog().info(
546
891
  {
547
- confidence: detection.confidence,
892
+ confidence: effectiveConfidence,
548
893
  context_length: contextContent.length,
549
894
  context_type: "fallback",
550
895
  reasons: detection.reasons,
@@ -554,7 +899,7 @@ export function createCompactionHook() {
554
899
  } else {
555
900
  getLog().debug(
556
901
  {
557
- confidence: detection.confidence,
902
+ confidence: effectiveConfidence,
558
903
  context_type: "none",
559
904
  },
560
905
  "no swarm detected, skipping injection",
@@ -567,8 +912,8 @@ export function createCompactionHook() {
567
912
  {
568
913
  duration_ms: duration,
569
914
  success: true,
570
- detected: detection.detected,
571
- confidence: detection.confidence,
915
+ detected: detection.detected || scannedState.epicId !== undefined,
916
+ confidence: effectiveConfidence,
572
917
  context_injected: output.context.length > 0,
573
918
  },
574
919
  "compaction complete",
@@ -63,8 +63,8 @@ export const EvalRecordSchema = z.object({
63
63
  context: z.string().optional(),
64
64
  /** Strategy used for decomposition */
65
65
  strategy: z.enum(["file-based", "feature-based", "risk-based", "auto"]),
66
- /** Max subtasks requested */
67
- max_subtasks: z.number().int().min(1).max(10),
66
+ /** Number of subtasks generated */
67
+ subtask_count: z.number().int().min(1),
68
68
 
69
69
  // OUTPUT (the decomposition)
70
70
  /** Epic title */
@@ -238,7 +238,6 @@ export function captureDecomposition(params: {
238
238
  task: string;
239
239
  context?: string;
240
240
  strategy: "file-based" | "feature-based" | "risk-based" | "auto";
241
- maxSubtasks: number;
242
241
  epicTitle: string;
243
242
  epicDescription?: string;
244
243
  subtasks: Array<{
@@ -256,7 +255,7 @@ export function captureDecomposition(params: {
256
255
  task: params.task,
257
256
  context: params.context,
258
257
  strategy: params.strategy,
259
- max_subtasks: params.maxSubtasks,
258
+ subtask_count: params.subtasks.length,
260
259
  epic_title: params.epicTitle,
261
260
  epic_description: params.epicDescription,
262
261
  subtasks: params.subtasks,
@@ -409,7 +408,7 @@ export function exportForEvalite(projectPath: string): Array<{
409
408
  input: { task: string; context?: string };
410
409
  expected: {
411
410
  minSubtasks: number;
412
- maxSubtasks: number;
411
+ subtaskCount: number;
413
412
  requiredFiles?: string[];
414
413
  overallSuccess?: boolean;
415
414
  };
@@ -426,7 +425,7 @@ export function exportForEvalite(projectPath: string): Array<{
426
425
  },
427
426
  expected: {
428
427
  minSubtasks: 2,
429
- maxSubtasks: record.max_subtasks,
428
+ subtaskCount: record.subtask_count,
430
429
  requiredFiles: record.subtasks.flatMap((s) => s.files),
431
430
  overallSuccess: record.overall_success,
432
431
  },
package/src/index.ts CHANGED
@@ -58,6 +58,7 @@ import {
58
58
  analyzeTodoWrite,
59
59
  shouldAnalyzeTool,
60
60
  } from "./planning-guardrails";
61
+ import { createCompactionHook } from "./compaction-hook";
61
62
 
62
63
  /**
63
64
  * OpenCode Swarm Plugin
@@ -80,7 +81,7 @@ import {
80
81
  export const SwarmPlugin: Plugin = async (
81
82
  input: PluginInput,
82
83
  ): Promise<Hooks> => {
83
- const { $, directory } = input;
84
+ const { $, directory, client } = input;
84
85
 
85
86
  // Set the working directory for hive commands
86
87
  // This ensures hive operations run in the project directory, not ~/.config/opencode
@@ -261,6 +262,25 @@ export const SwarmPlugin: Plugin = async (
261
262
  // Auto-sync was removed because bd CLI is deprecated
262
263
  // The hive_sync tool handles flushing to JSONL and git commit/push
263
264
  },
265
+
266
+ /**
267
+ * Compaction hook for swarm context preservation
268
+ *
269
+ * When OpenCode compacts session context, this hook injects swarm state
270
+ * to ensure coordinators can resume orchestration seamlessly.
271
+ *
272
+ * Uses SDK client to scan actual session messages for precise swarm state
273
+ * (epic IDs, subtask status, agent names) rather than relying solely on
274
+ * heuristic detection from hive/swarm-mail.
275
+ *
276
+ * Note: This hook is experimental and may not be in the published Hooks type yet.
277
+ */
278
+ "experimental.session.compacting": createCompactionHook(client),
279
+ } as Hooks & {
280
+ "experimental.session.compacting"?: (
281
+ input: { sessionID: string },
282
+ output: { context: string[] },
283
+ ) => Promise<void>;
264
284
  };
265
285
  };
266
286
 
@@ -976,7 +976,6 @@ describe("Swarm Tool Integrations", () => {
976
976
  const result = await swarm_decompose.execute(
977
977
  {
978
978
  task: "Add user authentication",
979
- max_subtasks: 3,
980
979
  query_cass: true,
981
980
  },
982
981
  mockContext,
@@ -992,7 +991,6 @@ describe("Swarm Tool Integrations", () => {
992
991
  const result = await swarm_decompose.execute(
993
992
  {
994
993
  task: "Add user authentication",
995
- max_subtasks: 3,
996
994
  query_cass: false,
997
995
  },
998
996
  mockContext,
@@ -87,7 +87,6 @@ export type TaskDecomposition = z.infer<typeof TaskDecompositionSchema>;
87
87
  */
88
88
  export const DecomposeArgsSchema = z.object({
89
89
  task: z.string().min(1),
90
- max_subtasks: z.number().int().min(1).default(5),
91
90
  context: z.string().optional(),
92
91
  });
93
92
  export type DecomposeArgs = z.infer<typeof DecomposeArgsSchema>;
@@ -434,12 +434,6 @@ export const swarm_decompose = tool({
434
434
  "Generate decomposition prompt for breaking task into parallelizable subtasks. Optionally queries CASS for similar past tasks.",
435
435
  args: {
436
436
  task: tool.schema.string().min(1).describe("Task description to decompose"),
437
- max_subtasks: tool.schema
438
- .number()
439
- .int()
440
- .min(1)
441
- .optional()
442
- .describe("Suggested max subtasks (optional - LLM decides if not specified)"),
443
437
  context: tool.schema
444
438
  .string()
445
439
  .optional()
@@ -503,7 +497,6 @@ export const swarm_decompose = tool({
503
497
  : "## Additional Context\n(none provided)";
504
498
 
505
499
  const prompt = DECOMPOSITION_PROMPT.replace("{task}", args.task)
506
- .replace("{max_subtasks}", (args.max_subtasks ?? 5).toString())
507
500
  .replace("{context_section}", contextSection);
508
501
 
509
502
  // Return the prompt and schema info for the caller
@@ -697,12 +690,6 @@ export const swarm_delegate_planning = tool({
697
690
  .string()
698
691
  .optional()
699
692
  .describe("Additional context to include"),
700
- max_subtasks: tool.schema
701
- .number()
702
- .int()
703
- .min(1)
704
- .optional()
705
- .describe("Suggested max subtasks (optional - LLM decides if not specified)"),
706
693
  strategy: tool.schema
707
694
  .enum(["auto", "file-based", "feature-based", "risk-based"])
708
695
  .optional()
@@ -804,8 +791,7 @@ export const swarm_delegate_planning = tool({
804
791
  .replace("{strategy_guidelines}", strategyGuidelines)
805
792
  .replace("{context_section}", contextSection)
806
793
  .replace("{cass_history}", cassContext || "")
807
- .replace("{skills_context}", skillsContext || "")
808
- .replace("{max_subtasks}", (args.max_subtasks ?? 5).toString());
794
+ .replace("{skills_context}", skillsContext || "");
809
795
 
810
796
  // Add strict JSON-only instructions for the subagent
811
797
  const subagentInstructions = `
@@ -1393,12 +1393,6 @@ export const swarm_plan_prompt = tool({
1393
1393
  .enum(["file-based", "feature-based", "risk-based", "auto"])
1394
1394
  .optional()
1395
1395
  .describe("Decomposition strategy (default: auto-detect)"),
1396
- max_subtasks: tool.schema
1397
- .number()
1398
- .int()
1399
- .min(1)
1400
- .optional()
1401
- .describe("Suggested max subtasks (optional - LLM decides if not specified)"),
1402
1396
  context: tool.schema
1403
1397
  .string()
1404
1398
  .optional()
@@ -1482,8 +1476,7 @@ export const swarm_plan_prompt = tool({
1482
1476
  .replace("{strategy_guidelines}", strategyGuidelines)
1483
1477
  .replace("{context_section}", contextSection)
1484
1478
  .replace("{cass_history}", "") // Empty for now
1485
- .replace("{skills_context}", skillsContext || "")
1486
- .replace("{max_subtasks}", (args.max_subtasks ?? 5).toString());
1479
+ .replace("{skills_context}", skillsContext || "");
1487
1480
 
1488
1481
  return JSON.stringify(
1489
1482
  {