@desplega.ai/agent-swarm 1.93.0 → 1.94.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.
Files changed (67) hide show
  1. package/README.md +2 -2
  2. package/openapi.json +180 -1
  3. package/package.json +1 -1
  4. package/src/be/db.ts +63 -7
  5. package/src/be/migrations/090_model_tiers.sql +2 -0
  6. package/src/be/migrations/091_seed_swarm_operations_metrics.sql +12 -0
  7. package/src/be/migrations/092_metrics_dashboard_combobox_filters.sql +68 -0
  8. package/src/be/migrations/093_slack_message_tracking.sql +6 -0
  9. package/src/be/migrations/runner.ts +52 -0
  10. package/src/be/modelsdev-cache.json +2060 -198
  11. package/src/be/scripts/boot-reembed.ts +74 -0
  12. package/src/be/scripts/db.ts +19 -3
  13. package/src/be/seed/index.ts +1 -1
  14. package/src/be/seed/registry.ts +2 -2
  15. package/src/be/seed/runner.ts +5 -5
  16. package/src/be/seed/types.ts +6 -1
  17. package/src/be/seed-pricing.ts +1 -0
  18. package/src/be/seed-scripts/index.ts +3 -2
  19. package/src/commands/runner.ts +83 -13
  20. package/src/http/index.ts +13 -2
  21. package/src/http/metrics.ts +55 -6
  22. package/src/http/schedules.ts +16 -15
  23. package/src/http/script-runs.ts +7 -1
  24. package/src/http/scripts.ts +147 -1
  25. package/src/http/tasks.ts +7 -0
  26. package/src/model-tiers.ts +140 -0
  27. package/src/providers/claude-managed-models.ts +9 -0
  28. package/src/providers/opencode-adapter.ts +1 -0
  29. package/src/providers/pi-mono-adapter.ts +78 -6
  30. package/src/scheduler/scheduler.ts +22 -34
  31. package/src/server-user.ts +8 -2
  32. package/src/slack/responses.ts +39 -11
  33. package/src/slack/watcher.ts +121 -8
  34. package/src/tests/agents-list-model-display.test.ts +13 -0
  35. package/src/tests/aws-error-classifier.test.ts +148 -0
  36. package/src/tests/claude-managed-adapter.test.ts +12 -0
  37. package/src/tests/context-window.test.ts +7 -0
  38. package/src/tests/http-api-integration.test.ts +19 -0
  39. package/src/tests/metrics-http.test.ts +137 -3
  40. package/src/tests/migration-046-budgets.test.ts +33 -0
  41. package/src/tests/migration-runner-regressions.test.ts +69 -0
  42. package/src/tests/model-control.test.ts +162 -46
  43. package/src/tests/opencode-adapter.test.ts +9 -0
  44. package/src/tests/pi-mono-adapter.test.ts +319 -0
  45. package/src/tests/providers/pi-cost.test.ts +9 -0
  46. package/src/tests/runner-fallback-output.test.ts +50 -0
  47. package/src/tests/scripts-boot-reembed.test.ts +163 -0
  48. package/src/tests/scripts-embeddings.test.ts +90 -0
  49. package/src/tests/seed.test.ts +26 -1
  50. package/src/tests/session-costs-model-key-normalize.test.ts +2 -0
  51. package/src/tests/slack-watcher.test.ts +66 -0
  52. package/src/tests/workflow-agent-task.test.ts +5 -2
  53. package/src/tests/workflow-validation-port-routing.test.ts +181 -0
  54. package/src/tools/memory-get.ts +11 -0
  55. package/src/tools/memory-search.ts +18 -0
  56. package/src/tools/schedules/create-schedule.ts +71 -70
  57. package/src/tools/schedules/update-schedule.ts +43 -31
  58. package/src/tools/send-task.ts +16 -5
  59. package/src/tools/task-action.ts +11 -3
  60. package/src/types.ts +29 -0
  61. package/src/utils/aws-error-classifier.ts +97 -0
  62. package/src/utils/context-window.ts +2 -0
  63. package/src/utils/credentials.test.ts +68 -0
  64. package/src/utils/credentials.ts +44 -3
  65. package/src/utils/pretty-print.ts +25 -10
  66. package/src/workflows/engine.ts +3 -2
  67. package/src/workflows/executors/agent-task.ts +3 -1
@@ -1,7 +1,13 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
3
  import { closeDb, initDb } from "../be/db";
4
- import { getSeedState, runSeeder, type Seeder, type SeedItem } from "../be/seed";
4
+ import {
5
+ getSeedState,
6
+ runSeeder,
7
+ type Seeder,
8
+ type SeederRunOptions,
9
+ type SeedItem,
10
+ } from "../be/seed";
5
11
 
6
12
  const TEST_DB_PATH = "./test-seed.sqlite";
7
13
 
@@ -137,6 +143,25 @@ describe("seeder harness — versioning rule", () => {
137
143
  expect(upstream.get("y")).toBe("h-pre-existing");
138
144
  });
139
145
 
146
+ test("runner passes opts through to apply()", async () => {
147
+ const capturedOpts: (SeederRunOptions | undefined)[] = [];
148
+ const source = new Map([["a", "h-a1"]]);
149
+ const upstream = new Map<string, string>();
150
+ const seeder: Seeder<SeedItem> = {
151
+ kind: "opts-passthrough-test",
152
+ items: () => [...source.entries()].map(([key, contentHash]) => ({ key, contentHash })),
153
+ upstreamHash: (item) => upstream.get(item.key) ?? null,
154
+ apply: (item, _action, opts) => {
155
+ capturedOpts.push(opts);
156
+ upstream.set(item.key, item.contentHash);
157
+ },
158
+ };
159
+
160
+ await runSeeder(seeder, { quiet: true, scriptEmbeddingMode: "skip" });
161
+ expect(capturedOpts).toHaveLength(1);
162
+ expect(capturedOpts[0]?.scriptEmbeddingMode).toBe("skip");
163
+ });
164
+
140
165
  test("a throwing apply is captured per-item without aborting the run", async () => {
141
166
  const source = new Map([
142
167
  ["ok", "h-ok"],
@@ -134,6 +134,8 @@ describe("normalizeModelKey()", () => {
134
134
 
135
135
  test("is a no-op for canonical claude ids", () => {
136
136
  expect(normalizeModelKey("claude", "claude-opus-4-7")).toBe("claude-opus-4-7");
137
+ expect(normalizeModelKey("claude", "claude-fable-5")).toBe("claude-fable-5");
138
+ expect(normalizeModelKey("claude", "claude-mythos-5")).toBe("claude-mythos-5");
137
139
  });
138
140
 
139
141
  test("is idempotent", () => {
@@ -9,12 +9,16 @@ import {
9
9
  getChildTasks,
10
10
  getCompletedSlackTasks,
11
11
  getInProgressSlackTasks,
12
+ getTaskById,
12
13
  initDb,
13
14
  insertTaskAttachment,
15
+ setSlackMessageTracking,
14
16
  startTask,
17
+ updateTaskProgress,
15
18
  } from "../be/db";
16
19
  import {
17
20
  _getLastRenderedTree,
21
+ _getTaskMessages,
18
22
  _getTaskToTree,
19
23
  _getTreeLastUpdateTime,
20
24
  _getTreeMessages,
@@ -114,6 +118,68 @@ describe("watcher DB queries", () => {
114
118
  startTaskWatcher(60000);
115
119
  stopTaskWatcher();
116
120
  });
121
+
122
+ test("rehydrates tree message tracking from in-progress tasks after restart", () => {
123
+ const agent = createAgent({ name: "WatcherHydrateTreeAgent", isLead: false, status: "idle" });
124
+ const task = createTaskExtended("watcher hydrate tree test", {
125
+ agentId: agent.id,
126
+ source: "slack",
127
+ slackChannelId: "C_HYDRATE_TREE",
128
+ slackThreadTs: "1919191919.000001",
129
+ slackUserId: "U_HYDRATE_TREE",
130
+ });
131
+ startTask(task.id);
132
+
133
+ const messageTs = "1919191919.000002";
134
+ registerTreeMessage(task.id, "C_HYDRATE_TREE", "1919191919.000001", messageTs);
135
+
136
+ expect(getTaskById(task.id)!.slackTreeRootMessageTs).toBe(messageTs);
137
+
138
+ _getTreeMessages().clear();
139
+ _getTaskToTree().clear();
140
+ _getTaskMessages().clear();
141
+
142
+ startTaskWatcher(60000);
143
+ stopTaskWatcher();
144
+
145
+ const tree = _getTreeMessages().get(messageTs);
146
+ expect(tree).toBeDefined();
147
+ expect(tree!.channelId).toBe("C_HYDRATE_TREE");
148
+ expect(tree!.threadTs).toBe("1919191919.000001");
149
+ expect(tree!.rootTaskIds.has(task.id)).toBe(true);
150
+ expect(_getTaskToTree().get(task.id)).toBe(messageTs);
151
+ expect(_getTaskMessages().get(task.id)?.messageTs).toBe(messageTs);
152
+ });
153
+
154
+ test("rehydrates flat progress message tracking from in-progress tasks after restart", () => {
155
+ const agent = createAgent({ name: "WatcherHydrateFlatAgent", isLead: false, status: "idle" });
156
+ const task = createTaskExtended("watcher hydrate flat test", {
157
+ agentId: agent.id,
158
+ source: "slack",
159
+ slackChannelId: "C_HYDRATE_FLAT",
160
+ slackThreadTs: "2020202020.000001",
161
+ slackUserId: "U_HYDRATE_FLAT",
162
+ });
163
+ updateTaskProgress(task.id, "Halfway there");
164
+
165
+ const messageTs = "2020202020.000002";
166
+ setSlackMessageTracking(task.id, { slackProgressMessageTs: messageTs });
167
+
168
+ _getTreeMessages().clear();
169
+ _getTaskToTree().clear();
170
+ _getTaskMessages().clear();
171
+
172
+ startTaskWatcher(60000);
173
+ stopTaskWatcher();
174
+
175
+ expect(_getTreeMessages().has(messageTs)).toBe(false);
176
+ expect(_getTaskToTree().has(task.id)).toBe(false);
177
+ expect(_getTaskMessages().get(task.id)).toEqual({
178
+ channelId: "C_HYDRATE_FLAT",
179
+ threadTs: "2020202020.000001",
180
+ messageTs,
181
+ });
182
+ });
117
183
  });
118
184
 
119
185
  describe("getChildTasks", () => {
@@ -94,13 +94,14 @@ afterAll(async () => {
94
94
  // ─── Tests ───────────────────────────────────────────────────
95
95
 
96
96
  describe("AgentTaskExecutor — workspace scoping", () => {
97
- test("config schema accepts dir, vcsRepo, model, parentTaskId", () => {
97
+ test("config schema accepts dir, vcsRepo, model, modelTier, parentTaskId", () => {
98
98
  const executor = new AgentTaskExecutor(mockDeps);
99
99
  const config = {
100
100
  template: "Do something",
101
101
  dir: "/workspace/repos/my-project",
102
102
  vcsRepo: "org/repo",
103
103
  model: "sonnet",
104
+ modelTier: "smart",
104
105
  parentTaskId: "f1b14078-5df1-457d-88a2-33f1d3e621fd",
105
106
  };
106
107
  const parsed = executor.configSchema.safeParse(config);
@@ -109,6 +110,7 @@ describe("AgentTaskExecutor — workspace scoping", () => {
109
110
  expect(parsed.data.dir).toBe("/workspace/repos/my-project");
110
111
  expect(parsed.data.vcsRepo).toBe("org/repo");
111
112
  expect(parsed.data.model).toBe("sonnet");
113
+ expect(parsed.data.modelTier).toBe("smart");
112
114
  expect(parsed.data.parentTaskId).toBe("f1b14078-5df1-457d-88a2-33f1d3e621fd");
113
115
  }
114
116
  });
@@ -166,7 +168,8 @@ describe("AgentTaskExecutor — workspace scoping", () => {
166
168
  expect(task).toBeDefined();
167
169
  expect(task!.dir).toBe("/workspace/repos/agent-swarm");
168
170
  expect(task!.vcsRepo).toBe("desplega-ai/agent-swarm");
169
- expect(task!.model).toBe("sonnet");
171
+ expect(task!.model).toBeUndefined();
172
+ expect(task!.modelTier).toBe("regular");
170
173
  expect(task!.source).toBe("workflow");
171
174
  });
172
175
 
@@ -47,6 +47,29 @@ class ObjectOutputExecutor extends BaseExecutor<
47
47
  }
48
48
  }
49
49
 
50
+ /**
51
+ * Executor that mimics an agent-task review output shape.
52
+ * Used for regression coverage around aliased property-match fields.
53
+ */
54
+ class ReviewOutputExecutor extends BaseExecutor<
55
+ typeof ReviewOutputExecutor.schema,
56
+ typeof ReviewOutputExecutor.outSchema
57
+ > {
58
+ static readonly schema = z.object({ verdict: z.string() });
59
+ static readonly outSchema = z.object({ taskOutput: z.object({ verdict: z.string() }) });
60
+
61
+ readonly type = "review-output";
62
+ readonly mode = "instant" as const;
63
+ readonly configSchema = ReviewOutputExecutor.schema;
64
+ readonly outputSchema = ReviewOutputExecutor.outSchema;
65
+
66
+ protected async execute(
67
+ config: z.infer<typeof ReviewOutputExecutor.schema>,
68
+ ): Promise<ExecutorResult<z.infer<typeof ReviewOutputExecutor.outSchema>>> {
69
+ return { status: "success", output: { taskOutput: { verdict: config.verdict } } };
70
+ }
71
+ }
72
+
50
73
  /**
51
74
  * Terminal executor that just succeeds. Used for leaf nodes.
52
75
  */
@@ -77,6 +100,7 @@ const mockDeps: ExecutorDependencies = {
77
100
  function createTestRegistry(): ExecutorRegistry {
78
101
  const registry = new ExecutorRegistry();
79
102
  registry.register(new ObjectOutputExecutor(mockDeps));
103
+ registry.register(new ReviewOutputExecutor(mockDeps));
80
104
  registry.register(new NoopExecutor(mockDeps));
81
105
  registry.register(new PropertyMatchExecutor(mockDeps));
82
106
  return registry;
@@ -218,4 +242,161 @@ describe("Validation Port Routing", () => {
218
242
  const run = getWorkflowRun(runId);
219
243
  expect(run!.status).toBe("completed");
220
244
  });
245
+
246
+ test("property-match node resolves fields through node.inputs aliases", async () => {
247
+ const workflow = makeWorkflow({
248
+ nodes: [
249
+ {
250
+ id: "review-step",
251
+ type: "object-output",
252
+ config: { approved: true },
253
+ next: "check",
254
+ },
255
+ {
256
+ id: "check",
257
+ type: "property-match",
258
+ inputs: { review: "review-step" },
259
+ config: {
260
+ conditions: [{ field: "review.approved", op: "eq", value: true }],
261
+ },
262
+ next: { true: "on-pass", false: "on-fail" },
263
+ },
264
+ {
265
+ id: "on-pass",
266
+ type: "noop",
267
+ config: {},
268
+ },
269
+ {
270
+ id: "on-fail",
271
+ type: "noop",
272
+ config: {},
273
+ },
274
+ ],
275
+ });
276
+
277
+ const runId = await startWorkflowExecution(workflow, {}, registry);
278
+ const steps = getWorkflowRunStepsByRunId(runId);
279
+ const nodeIds = steps.map((s) => s.nodeId);
280
+
281
+ expect(nodeIds).toContain("check");
282
+ expect(nodeIds).toContain("on-pass");
283
+ expect(nodeIds).not.toContain("on-fail");
284
+ });
285
+
286
+ test("property-match node keeps raw context path fallback with node.inputs present", async () => {
287
+ const workflow = makeWorkflow({
288
+ nodes: [
289
+ {
290
+ id: "review-step",
291
+ type: "object-output",
292
+ config: { approved: true },
293
+ next: "check",
294
+ },
295
+ {
296
+ id: "check",
297
+ type: "property-match",
298
+ inputs: { review: "review-step" },
299
+ config: {
300
+ conditions: [{ field: "review-step.approved", op: "eq", value: true }],
301
+ },
302
+ next: { true: "on-pass", false: "on-fail" },
303
+ },
304
+ {
305
+ id: "on-pass",
306
+ type: "noop",
307
+ config: {},
308
+ },
309
+ {
310
+ id: "on-fail",
311
+ type: "noop",
312
+ config: {},
313
+ },
314
+ ],
315
+ });
316
+
317
+ const runId = await startWorkflowExecution(workflow, {}, registry);
318
+ const steps = getWorkflowRunStepsByRunId(runId);
319
+ const nodeIds = steps.map((s) => s.nodeId);
320
+
321
+ expect(nodeIds).toContain("on-pass");
322
+ expect(nodeIds).not.toContain("on-fail");
323
+ });
324
+
325
+ test("property-match handles DES-294 review.taskOutput.verdict alias shape", async () => {
326
+ const workflow = makeWorkflow({
327
+ nodes: [
328
+ {
329
+ id: "review-step",
330
+ type: "review-output",
331
+ config: { verdict: "continue" },
332
+ next: "halt-check",
333
+ },
334
+ {
335
+ id: "halt-check",
336
+ type: "property-match",
337
+ inputs: { review: "review-step" },
338
+ config: {
339
+ conditions: [{ field: "review.taskOutput.verdict", op: "eq", value: "continue" }],
340
+ },
341
+ next: { true: "continue-flow", false: "false-halt" },
342
+ },
343
+ {
344
+ id: "continue-flow",
345
+ type: "noop",
346
+ config: {},
347
+ },
348
+ {
349
+ id: "false-halt",
350
+ type: "noop",
351
+ config: {},
352
+ },
353
+ ],
354
+ });
355
+
356
+ const runId = await startWorkflowExecution(workflow, {}, registry);
357
+ const steps = getWorkflowRunStepsByRunId(runId);
358
+ const nodeIds = steps.map((s) => s.nodeId);
359
+
360
+ expect(nodeIds).toContain("continue-flow");
361
+ expect(nodeIds).not.toContain("false-halt");
362
+ });
363
+
364
+ test("property-match validation resolves fields through node.inputs aliases", async () => {
365
+ const workflow = makeWorkflow({
366
+ nodes: [
367
+ {
368
+ id: "check",
369
+ type: "object-output",
370
+ inputs: { review: "trigger.review" },
371
+ config: { approved: true },
372
+ next: "after-check",
373
+ validation: {
374
+ executor: "property-match",
375
+ config: {
376
+ conditions: [{ field: "review.taskOutput.verdict", op: "eq", value: "continue" }],
377
+ },
378
+ mustPass: true,
379
+ },
380
+ },
381
+ {
382
+ id: "after-check",
383
+ type: "noop",
384
+ config: {},
385
+ },
386
+ ],
387
+ });
388
+
389
+ const runId = await startWorkflowExecution(
390
+ workflow,
391
+ { review: { taskOutput: { verdict: "continue" } } },
392
+ registry,
393
+ );
394
+ const steps = getWorkflowRunStepsByRunId(runId);
395
+ const nodeIds = steps.map((s) => s.nodeId);
396
+ const run = getWorkflowRun(runId);
397
+
398
+ expect(run!.status).toBe("completed");
399
+ expect(nodeIds).toContain("check");
400
+ expect(nodeIds).toContain("after-check");
401
+ });
221
402
  });
@@ -2,8 +2,11 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import * as z from "zod";
3
3
  import { getMemoryStore } from "@/be/memory";
4
4
  import { createToolRegistrar } from "@/tools/utils";
5
+ import type { AgentMemorySource } from "@/types";
5
6
  import { AgentMemorySchema } from "@/types";
6
7
 
8
+ const NUDGE_ELIGIBLE_SOURCES: ReadonlySet<AgentMemorySource> = new Set(["manual", "file_index"]);
9
+
7
10
  export const registerMemoryGetTool = (server: McpServer) => {
8
11
  createToolRegistrar(server)(
9
12
  "memory-get",
@@ -21,6 +24,7 @@ export const registerMemoryGetTool = (server: McpServer) => {
21
24
  success: z.boolean(),
22
25
  message: z.string(),
23
26
  memory: AgentMemorySchema.optional(),
27
+ rateHint: z.string().optional(),
24
28
  }),
25
29
  },
26
30
  async ({ memoryId }, requestInfo, _meta) => {
@@ -37,6 +41,12 @@ export const registerMemoryGetTool = (server: McpServer) => {
37
41
  };
38
42
  }
39
43
 
44
+ const inTaskContext = !!requestInfo.sourceTaskId;
45
+ const rateHint =
46
+ inTaskContext && NUDGE_ELIGIBLE_SOURCES.has(memory.source as AgentMemorySource)
47
+ ? `memory_rate(id="${memory.id}", useful=true|false)`
48
+ : undefined;
49
+
40
50
  return {
41
51
  content: [
42
52
  {
@@ -49,6 +59,7 @@ export const registerMemoryGetTool = (server: McpServer) => {
49
59
  success: true,
50
60
  message: `Memory "${memory.name}" retrieved.`,
51
61
  memory,
62
+ rateHint,
52
63
  },
53
64
  };
54
65
  },
@@ -6,8 +6,15 @@ import { CANDIDATE_SET_MULTIPLIER } from "@/be/memory/constants";
6
6
  import { recordRetrievals } from "@/be/memory/raters/retrieval";
7
7
  import { rerank } from "@/be/memory/reranker";
8
8
  import { createToolRegistrar } from "@/tools/utils";
9
+ import type { AgentMemorySource } from "@/types";
9
10
  import { AgentMemoryScopeSchema, AgentMemorySourceSchema } from "@/types";
10
11
 
12
+ const NUDGE_ELIGIBLE_SOURCES: ReadonlySet<AgentMemorySource> = new Set(["manual", "file_index"]);
13
+
14
+ function rateHintFor(memoryId: string): string {
15
+ return `memory_rate(id="${memoryId}", useful=true|false)`;
16
+ }
17
+
11
18
  export const registerMemorySearchTool = (server: McpServer) => {
12
19
  createToolRegistrar(server)(
13
20
  "memory-search",
@@ -42,9 +49,11 @@ export const registerMemorySearchTool = (server: McpServer) => {
42
49
  scope: AgentMemoryScopeSchema,
43
50
  similarity: z.number().optional(),
44
51
  createdAt: z.string(),
52
+ rateHint: z.string().optional(),
45
53
  }),
46
54
  )
47
55
  .optional(),
56
+ _ratingNudge: z.string().optional(),
48
57
  }),
49
58
  },
50
59
  async ({ query, scope, limit, source }, requestInfo, _meta) => {
@@ -94,6 +103,7 @@ export const registerMemorySearchTool = (server: McpServer) => {
94
103
  }
95
104
  }
96
105
 
106
+ const inTaskContext = !!requestInfo.sourceTaskId;
97
107
  const mapped = ranked.map((r) => ({
98
108
  id: r.id,
99
109
  name: r.name,
@@ -102,8 +112,15 @@ export const registerMemorySearchTool = (server: McpServer) => {
102
112
  scope: r.scope,
103
113
  similarity: r.similarity,
104
114
  createdAt: r.createdAt,
115
+ ...(inTaskContext && NUDGE_ELIGIBLE_SOURCES.has(r.source as AgentMemorySource)
116
+ ? { rateHint: rateHintFor(r.id) }
117
+ : {}),
105
118
  }));
106
119
 
120
+ const nudgeCount = mapped.filter((r) => r.rateHint).length;
121
+ const _ratingNudge =
122
+ nudgeCount > 0 ? "Rate memories that help or mislead you with memory_rate." : undefined;
123
+
107
124
  return {
108
125
  content: [
109
126
  {
@@ -116,6 +133,7 @@ export const registerMemorySearchTool = (server: McpServer) => {
116
133
  success: true,
117
134
  message: `Found ${mapped.length} memories matching "${query}".`,
118
135
  results: mapped,
136
+ _ratingNudge,
119
137
  },
120
138
  };
121
139
  }
@@ -4,6 +4,71 @@ import * as z from "zod";
4
4
  import { createScheduledTask, getAgentById, getScheduledTaskByName } from "@/be/db";
5
5
  import { calculateNextRun } from "@/scheduler";
6
6
  import { createToolRegistrar } from "@/tools/utils";
7
+ import { ModelTierSchema, splitLegacyModelAlias } from "../../model-tiers";
8
+
9
+ export const createScheduleInputSchema = z.object({
10
+ name: z.string().min(1).max(100).describe("Unique name for the schedule (e.g., 'daily-cleanup')"),
11
+ taskTemplate: z.string().min(1).describe("The task description that will be created each time"),
12
+ scheduleType: z
13
+ .enum(["recurring", "one_time"])
14
+ .default("recurring")
15
+ .optional()
16
+ .describe("Schedule type: 'recurring' (default) or 'one_time'"),
17
+ cronExpression: z
18
+ .string()
19
+ .optional()
20
+ .describe("Cron expression for recurring schedules (e.g., '0 9 * * *')"),
21
+ intervalMs: z
22
+ .number()
23
+ .int()
24
+ .positive()
25
+ .optional()
26
+ .describe("Interval in milliseconds for recurring schedules (e.g., 3600000 for hourly)"),
27
+ delayMs: z
28
+ .number()
29
+ .int()
30
+ .positive()
31
+ .optional()
32
+ .describe("Delay in milliseconds for one-time schedules (e.g., 1800000 for 30 min)"),
33
+ runAt: z
34
+ .string()
35
+ .datetime()
36
+ .optional()
37
+ .describe("ISO datetime for one-time schedules (e.g., '2026-03-06T15:00:00Z')"),
38
+ description: z.string().optional().describe("Human-readable description of the schedule"),
39
+ taskType: z.string().max(50).optional().describe("Task type (e.g., 'maintenance', 'report')"),
40
+ tags: z.array(z.string()).optional().describe("Tags to apply to created tasks"),
41
+ priority: z
42
+ .number()
43
+ .int()
44
+ .min(0)
45
+ .max(100)
46
+ .default(50)
47
+ .optional()
48
+ .describe("Task priority 0-100 (default: 50)"),
49
+ targetAgentId: z
50
+ .string()
51
+ .uuid()
52
+ .optional()
53
+ .describe("Agent to assign tasks to (omit for task pool)"),
54
+ timezone: z.string().default("UTC").optional().describe("Timezone for cron schedules"),
55
+ enabled: z
56
+ .boolean()
57
+ .default(true)
58
+ .optional()
59
+ .describe("Whether the schedule is enabled (default: true)"),
60
+ model: z
61
+ .string()
62
+ .trim()
63
+ .min(1)
64
+ .optional()
65
+ .describe(
66
+ "Concrete model override for tasks created by this schedule. Interpreted by each assignee's harness/provider and does not switch providers. Prefer modelTier for portable intent.",
67
+ ),
68
+ modelTier: ModelTierSchema.optional().describe(
69
+ "Portable model tier for tasks created by this schedule: 'smol', 'regular', 'smart', or 'ultra'. Resolved by each assignee's harness/provider at run time.",
70
+ ),
71
+ });
7
72
 
8
73
  export const registerCreateScheduleTool = (server: McpServer) => {
9
74
  createToolRegistrar(server)(
@@ -13,75 +78,7 @@ export const registerCreateScheduleTool = (server: McpServer) => {
13
78
  annotations: { destructiveHint: false },
14
79
  description:
15
80
  "Create a new scheduled task. For recurring: provide cronExpression or intervalMs. For one-time: provide delayMs or runAt with scheduleType 'one_time'.",
16
- inputSchema: z.object({
17
- name: z
18
- .string()
19
- .min(1)
20
- .max(100)
21
- .describe("Unique name for the schedule (e.g., 'daily-cleanup')"),
22
- taskTemplate: z
23
- .string()
24
- .min(1)
25
- .describe("The task description that will be created each time"),
26
- scheduleType: z
27
- .enum(["recurring", "one_time"])
28
- .default("recurring")
29
- .optional()
30
- .describe("Schedule type: 'recurring' (default) or 'one_time'"),
31
- cronExpression: z
32
- .string()
33
- .optional()
34
- .describe("Cron expression for recurring schedules (e.g., '0 9 * * *')"),
35
- intervalMs: z
36
- .number()
37
- .int()
38
- .positive()
39
- .optional()
40
- .describe("Interval in milliseconds for recurring schedules (e.g., 3600000 for hourly)"),
41
- delayMs: z
42
- .number()
43
- .int()
44
- .positive()
45
- .optional()
46
- .describe("Delay in milliseconds for one-time schedules (e.g., 1800000 for 30 min)"),
47
- runAt: z
48
- .string()
49
- .datetime()
50
- .optional()
51
- .describe("ISO datetime for one-time schedules (e.g., '2026-03-06T15:00:00Z')"),
52
- description: z.string().optional().describe("Human-readable description of the schedule"),
53
- taskType: z
54
- .string()
55
- .max(50)
56
- .optional()
57
- .describe("Task type (e.g., 'maintenance', 'report')"),
58
- tags: z.array(z.string()).optional().describe("Tags to apply to created tasks"),
59
- priority: z
60
- .number()
61
- .int()
62
- .min(0)
63
- .max(100)
64
- .default(50)
65
- .optional()
66
- .describe("Task priority 0-100 (default: 50)"),
67
- targetAgentId: z
68
- .string()
69
- .uuid()
70
- .optional()
71
- .describe("Agent to assign tasks to (omit for task pool)"),
72
- timezone: z.string().default("UTC").optional().describe("Timezone for cron schedules"),
73
- enabled: z
74
- .boolean()
75
- .default(true)
76
- .optional()
77
- .describe("Whether the schedule is enabled (default: true)"),
78
- model: z
79
- .enum(["haiku", "sonnet", "opus", "fable"])
80
- .optional()
81
- .describe(
82
- "Model to use for tasks created by this schedule ('haiku', 'sonnet', 'opus', or 'fable'). If not set, uses agent/global config or defaults to 'opus'.",
83
- ),
84
- }),
81
+ inputSchema: createScheduleInputSchema,
85
82
  outputSchema: z.object({
86
83
  yourAgentId: z.string().uuid().optional(),
87
84
  success: z.boolean(),
@@ -104,6 +101,7 @@ export const registerCreateScheduleTool = (server: McpServer) => {
104
101
  createdByAgentId: z.string().optional(),
105
102
  timezone: z.string(),
106
103
  model: z.string().optional(),
104
+ modelTier: ModelTierSchema.optional(),
107
105
  scheduleType: z.string(),
108
106
  createdAt: z.string(),
109
107
  lastUpdatedAt: z.string(),
@@ -128,6 +126,7 @@ export const registerCreateScheduleTool = (server: McpServer) => {
128
126
  timezone,
129
127
  enabled,
130
128
  model,
129
+ modelTier,
131
130
  },
132
131
  requestInfo,
133
132
  _meta,
@@ -270,6 +269,7 @@ export const registerCreateScheduleTool = (server: McpServer) => {
270
269
  }
271
270
 
272
271
  try {
272
+ const normalizedModel = splitLegacyModelAlias({ model, modelTier });
273
273
  // Calculate initial nextRunAt
274
274
  let nextRunAt: string | undefined;
275
275
  if (enabled === false) {
@@ -299,7 +299,8 @@ export const registerCreateScheduleTool = (server: McpServer) => {
299
299
  enabled,
300
300
  nextRunAt,
301
301
  createdByAgentId: requestInfo.agentId,
302
- model,
302
+ model: normalizedModel.model,
303
+ modelTier: normalizedModel.modelTier,
303
304
  scheduleType: scheduleType ?? "recurring",
304
305
  });
305
306