zeitlich 0.2.17 → 0.2.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zeitlich",
3
- "version": "0.2.17",
3
+ "version": "0.2.19",
4
4
  "description": "[EXPERIMENTAL] An opinionated AI agent implementation for Temporal",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -40,7 +40,7 @@ export function defineSubagent<
40
40
  overrides?: {
41
41
  context?: TContext;
42
42
  hooks?: SubagentHooks<SubagentArgs, z.infer<TResult>>;
43
- enabled?: boolean;
43
+ enabled?: boolean | (() => boolean);
44
44
  taskQueue?: string;
45
45
  allowThreadContinuation?: boolean;
46
46
  sandbox?: "inherit" | "own";
@@ -13,9 +13,9 @@ import { createSubagentHandler } from "./handler";
13
13
  * Builds a fully wired tool entry for the Subagent tool,
14
14
  * including per-subagent hook delegation.
15
15
  *
16
- * Uses getters for `enabled`, `description`, and `schema` so that
17
- * dynamic changes to SubagentConfig.enabled are re-evaluated each
18
- * time getToolDefinitions() is called.
16
+ * Lazily evaluates `enabled` (supports `boolean | () => boolean`)
17
+ * so that `description` and `schema` reflect the current set of
18
+ * active subagents each time getToolDefinitions() is called.
19
19
  *
20
20
  * Returns null if no subagents are configured.
21
21
  */
@@ -24,7 +24,10 @@ export function buildSubagentRegistration(
24
24
  ): ToolMap[string] | null {
25
25
  if (subagents.length === 0) return null;
26
26
 
27
- const getEnabled = (): SubagentConfig[] => subagents.filter((s) => s.enabled ?? true);
27
+ const getEnabled = (): SubagentConfig[] =>
28
+ subagents.filter((s) =>
29
+ typeof s.enabled === "function" ? s.enabled() : (s.enabled ?? true),
30
+ );
28
31
 
29
32
  const subagentHooksMap = new Map<string, SubagentHooks>();
30
33
  for (const s of subagents) {
@@ -36,15 +39,9 @@ export function buildSubagentRegistration(
36
39
 
37
40
  return {
38
41
  name: SUBAGENT_TOOL_NAME,
39
- get enabled(): boolean {
40
- return getEnabled().length > 0;
41
- },
42
- get description(): string {
43
- return createSubagentTool(getEnabled()).description;
44
- },
45
- get schema(): z.ZodObject<z.ZodRawShape> {
46
- return createSubagentTool(getEnabled()).schema;
47
- },
42
+ enabled: (): boolean => getEnabled().length > 0,
43
+ description: (): string => createSubagentTool(getEnabled()).description,
44
+ schema: (): z.ZodObject<z.ZodRawShape> => createSubagentTool(getEnabled()).schema,
48
45
  handler: createSubagentHandler(subagents),
49
46
  ...(subagentHooksMap.size > 0 && {
50
47
  hooks: {
@@ -5,19 +5,21 @@ vi.mock("@temporalio/workflow", () => {
5
5
  let counter = 0;
6
6
  return {
7
7
  workflowInfo: () => ({ taskQueue: "default-queue" }),
8
- executeChild: vi.fn(async (_workflow: unknown, opts: { args: unknown[] }) => {
9
- const prompt = (opts.args as [string])[0];
10
- return {
11
- toolResponse: `Response to: ${prompt}`,
12
- data: { result: "child-data" },
13
- threadId: "child-thread-1",
14
- usage: { inputTokens: 100, outputTokens: 50 },
15
- };
16
- }),
8
+ executeChild: vi.fn(
9
+ async (_workflow: unknown, opts: { args: unknown[] }) => {
10
+ const prompt = (opts.args as [string])[0];
11
+ return {
12
+ toolResponse: `Response to: ${prompt}`,
13
+ data: { result: "child-data" },
14
+ threadId: "child-thread-1",
15
+ usage: { inputTokens: 100, outputTokens: 50 },
16
+ };
17
+ }
18
+ ),
17
19
  uuid4: () => {
18
20
  counter++;
19
21
  const bytes = Array.from({ length: 16 }, (_, i) =>
20
- ((counter * 31 + i * 7) & 0xff).toString(16).padStart(2, "0"),
22
+ ((counter * 31 + i * 7) & 0xff).toString(16).padStart(2, "0")
21
23
  ).join("");
22
24
  return `${bytes.slice(0, 8)}-${bytes.slice(8, 12)}-${bytes.slice(12, 16)}-${bytes.slice(16, 20)}-${bytes.slice(20, 32)}`;
23
25
  },
@@ -28,6 +30,7 @@ import { createSubagentTool, SUBAGENT_TOOL_NAME } from "./tool";
28
30
  import { createSubagentHandler } from "./handler";
29
31
  import { buildSubagentRegistration } from "./register";
30
32
  import { defineSubagentWorkflow } from "./workflow";
33
+ import { defineSubagent } from "./define";
31
34
  import type {
32
35
  SubagentConfig,
33
36
  SubagentSessionInput,
@@ -146,7 +149,7 @@ describe("createSubagentTool", () => {
146
149
 
147
150
  it("throws when no subagents are provided", () => {
148
151
  expect(() => createSubagentTool([])).toThrow(
149
- "createSubagentTool requires at least one subagent",
152
+ "createSubagentTool requires at least one subagent"
150
153
  );
151
154
  });
152
155
 
@@ -180,7 +183,7 @@ describe("createSubagentHandler", () => {
180
183
 
181
184
  const result = await handler(
182
185
  { subagent: "researcher", description: "test", prompt: "Find info" },
183
- { threadId: "parent-thread", toolCallId: "tc-1", toolName: "Subagent" },
186
+ { threadId: "parent-thread", toolCallId: "tc-1", toolName: "Subagent" }
184
187
  );
185
188
 
186
189
  expect(result.toolResponse).toContain("Response to: Find info");
@@ -193,8 +196,8 @@ describe("createSubagentHandler", () => {
193
196
  await expect(
194
197
  handler(
195
198
  { subagent: "nonexistent", description: "test", prompt: "test" },
196
- { threadId: "t", toolCallId: "tc", toolName: "Subagent" },
197
- ),
199
+ { threadId: "t", toolCallId: "tc", toolName: "Subagent" }
200
+ )
198
201
  ).rejects.toThrow("Unknown subagent: nonexistent");
199
202
  });
200
203
 
@@ -211,8 +214,8 @@ describe("createSubagentHandler", () => {
211
214
  await expect(
212
215
  handler(
213
216
  { subagent: "bad", description: "test", prompt: "test" },
214
- { threadId: "t", toolCallId: "tc", toolName: "Subagent" },
215
- ),
217
+ { threadId: "t", toolCallId: "tc", toolName: "Subagent" }
218
+ )
216
219
  ).rejects.toThrow(/researcher.*writer/);
217
220
  });
218
221
 
@@ -235,7 +238,7 @@ describe("createSubagentHandler", () => {
235
238
 
236
239
  const result = await handler(
237
240
  { subagent: "validated", description: "test", prompt: "test" },
238
- { threadId: "t", toolCallId: "tc", toolName: "Subagent" },
241
+ { threadId: "t", toolCallId: "tc", toolName: "Subagent" }
239
242
  );
240
243
 
241
244
  expect(result.toolResponse).toContain("invalid data");
@@ -261,7 +264,7 @@ describe("createSubagentHandler", () => {
261
264
 
262
265
  const result = await handler(
263
266
  { subagent: "cont", description: "test", prompt: "test" },
264
- { threadId: "t", toolCallId: "tc", toolName: "Subagent" },
267
+ { threadId: "t", toolCallId: "tc", toolName: "Subagent" }
265
268
  );
266
269
 
267
270
  expect(result.toolResponse).toContain("Thread ID: child-thread-99");
@@ -279,7 +282,7 @@ describe("createSubagentHandler", () => {
279
282
 
280
283
  const result = await handler(
281
284
  { subagent: "researcher", description: "test", prompt: "test" },
282
- { threadId: "t", toolCallId: "tc", toolName: "Subagent" },
285
+ { threadId: "t", toolCallId: "tc", toolName: "Subagent" }
283
286
  );
284
287
 
285
288
  expect(result.toolResponse).toContain("no response");
@@ -306,7 +309,12 @@ describe("createSubagentHandler", () => {
306
309
 
307
310
  await handler(
308
311
  { subagent: "inherit-agent", description: "test", prompt: "test" },
309
- { threadId: "t", toolCallId: "tc", toolName: "Subagent", sandboxId: "parent-sb" },
312
+ {
313
+ threadId: "t",
314
+ toolCallId: "tc",
315
+ toolName: "Subagent",
316
+ sandboxId: "parent-sb",
317
+ }
310
318
  );
311
319
 
312
320
  const lastCall = execMock.mock.calls[execMock.mock.calls.length - 1];
@@ -335,7 +343,12 @@ describe("createSubagentHandler", () => {
335
343
 
336
344
  await handler(
337
345
  { subagent: "own-agent", description: "test", prompt: "test" },
338
- { threadId: "t", toolCallId: "tc", toolName: "Subagent", sandboxId: "parent-sb" },
346
+ {
347
+ threadId: "t",
348
+ toolCallId: "tc",
349
+ toolName: "Subagent",
350
+ sandboxId: "parent-sb",
351
+ }
339
352
  );
340
353
 
341
354
  const lastCall = execMock.mock.calls[execMock.mock.calls.length - 1];
@@ -371,21 +384,23 @@ describe("buildSubagentRegistration", () => {
371
384
  }
372
385
  });
373
386
 
374
- it("enabled getter reflects dynamic subagent state", () => {
375
- const config: SubagentConfig = {
376
- agentName: "toggle",
377
- description: "Toggleable",
378
- workflow: "workflow",
379
- enabled: true,
380
- };
387
+ it("enabled function is re-evaluated dynamically", () => {
388
+ let flag = true;
389
+ const reg = buildSubagentRegistration([
390
+ {
391
+ agentName: "toggle",
392
+ description: "Toggleable",
393
+ workflow: "workflow",
394
+ enabled: () => flag,
395
+ },
396
+ ]);
381
397
 
382
- const reg = buildSubagentRegistration([config]);
383
398
  expect(reg).toBeDefined();
384
399
  if (!reg) return;
385
- expect(reg.enabled).toBe(true);
400
+ expect((reg.enabled as () => boolean)()).toBe(true);
386
401
 
387
- config.enabled = false;
388
- expect(reg.enabled).toBe(false);
402
+ flag = false;
403
+ expect((reg.enabled as () => boolean)()).toBe(false);
389
404
  });
390
405
 
391
406
  it("disabled when all subagents are disabled", () => {
@@ -400,7 +415,7 @@ describe("buildSubagentRegistration", () => {
400
415
 
401
416
  expect(reg).toBeDefined();
402
417
  if (reg) {
403
- expect(reg.enabled).toBe(false);
418
+ expect((reg.enabled as () => boolean)()).toBe(false);
404
419
  }
405
420
  });
406
421
 
@@ -442,28 +457,29 @@ describe("buildSubagentRegistration", () => {
442
457
  }
443
458
  });
444
459
 
445
- it("dynamic schema/description updates when subagents change enabled state", () => {
446
- const config1: SubagentConfig = {
447
- agentName: "a",
448
- description: "Agent A",
449
- workflow: "workflow",
450
- enabled: true,
451
- };
452
- const config2: SubagentConfig = {
453
- agentName: "b",
454
- description: "Agent B",
455
- workflow: "workflow",
456
- enabled: true,
457
- };
458
-
459
- const reg = buildSubagentRegistration([config1, config2]);
460
+ it("dynamic schema/description updates when enabled function changes", () => {
461
+ let bEnabled = true;
462
+ const reg = buildSubagentRegistration([
463
+ {
464
+ agentName: "a",
465
+ description: "Agent A",
466
+ workflow: "workflow",
467
+ enabled: true,
468
+ },
469
+ {
470
+ agentName: "b",
471
+ description: "Agent B",
472
+ workflow: "workflow",
473
+ enabled: () => bEnabled,
474
+ },
475
+ ]);
460
476
 
461
477
  expect(reg).toBeDefined();
462
478
  if (reg) {
463
479
  expect(reg.description).toContain("Agent A");
464
480
  expect(reg.description).toContain("Agent B");
465
481
 
466
- config2.enabled = false;
482
+ bEnabled = false;
467
483
 
468
484
  expect(reg.description).toContain("Agent A");
469
485
  expect(reg.description).not.toContain("Agent B");
@@ -471,6 +487,45 @@ describe("buildSubagentRegistration", () => {
471
487
  });
472
488
  });
473
489
 
490
+ // ---------------------------------------------------------------------------
491
+ // defineSubagent
492
+ // ---------------------------------------------------------------------------
493
+
494
+ describe("defineSubagent", () => {
495
+ const makeDef = (name: string) =>
496
+ defineSubagentWorkflow(
497
+ { name, description: `${name} agent` },
498
+ async () => ({ toolResponse: "ok", data: null, threadId: "t" })
499
+ );
500
+
501
+ it("enabled function is re-evaluated dynamically", () => {
502
+ let flag = true;
503
+ const config = defineSubagent(makeDef("dynamic"), {
504
+ enabled: () => flag,
505
+ });
506
+
507
+ const resolve = config.enabled as () => boolean;
508
+ expect(resolve()).toBe(true);
509
+ flag = false;
510
+ expect(resolve()).toBe(false);
511
+ });
512
+
513
+ it("enabled function works through buildSubagentRegistration", () => {
514
+ let flag = true;
515
+ const config = defineSubagent(makeDef("dynamic"), {
516
+ enabled: () => flag,
517
+ });
518
+
519
+ const reg = buildSubagentRegistration([config]);
520
+ expect(reg).toBeDefined();
521
+ if (!reg) return;
522
+ expect((reg.enabled as () => boolean)()).toBe(true);
523
+
524
+ flag = false;
525
+ expect((reg.enabled as () => boolean)()).toBe(false);
526
+ });
527
+ });
528
+
474
529
  // ---------------------------------------------------------------------------
475
530
  // defineSubagentWorkflow
476
531
  // ---------------------------------------------------------------------------
@@ -486,7 +541,7 @@ describe("defineSubagentWorkflow", () => {
486
541
  capturedPrompt = prompt;
487
542
  capturedSession = sessionInput;
488
543
  return { toolResponse: "ok", data: null, threadId: "t" };
489
- },
544
+ }
490
545
  );
491
546
 
492
547
  await workflow("go", { previousThreadId: "prev-42" });
@@ -506,7 +561,7 @@ describe("defineSubagentWorkflow", () => {
506
561
  async (_prompt, sessionInput) => {
507
562
  capturedSession = sessionInput;
508
563
  return { toolResponse: "ok", data: null, threadId: "t" };
509
- },
564
+ }
510
565
  );
511
566
 
512
567
  await workflow("go", { sandboxId: "sb-123" });
@@ -520,7 +575,7 @@ describe("defineSubagentWorkflow", () => {
520
575
  async (_prompt, _sessionInput, context) => {
521
576
  capturedContext = context;
522
577
  return { toolResponse: "ok", data: null, threadId: "t" };
523
- },
578
+ }
524
579
  );
525
580
 
526
581
  await workflow("go", {}, { key: "val" });
@@ -528,28 +583,18 @@ describe("defineSubagentWorkflow", () => {
528
583
  expect(capturedContext).toEqual({ key: "val" });
529
584
  });
530
585
 
531
- it("supports omitted context", async () => {
532
- let capturedContext: Record<string, unknown> | undefined;
533
- const workflow = defineSubagentWorkflow(
534
- { name: "test", description: "test agent" },
535
- async (_prompt, _sessionInput, context) => {
536
- capturedContext = context;
537
- return { toolResponse: "ok", data: null, threadId: "t" };
538
- },
539
- );
540
-
541
- await workflow("go", { sandboxId: "sb" });
542
- expect(capturedContext).toBeUndefined();
543
- });
544
-
545
586
  it("returns the handler response unchanged", async () => {
546
587
  const workflow = defineSubagentWorkflow(
547
- { name: "test", description: "test agent", resultSchema: z.object({ count: z.number() }) },
588
+ {
589
+ name: "test",
590
+ description: "test agent",
591
+ resultSchema: z.object({ count: z.number() }),
592
+ },
548
593
  async () => ({
549
594
  toolResponse: "result text",
550
595
  data: { count: 42 },
551
596
  threadId: "child-thread",
552
- }),
597
+ })
553
598
  );
554
599
 
555
600
  const result = await workflow("go", {});
@@ -562,8 +607,12 @@ describe("defineSubagentWorkflow", () => {
562
607
  it("attaches metadata to the returned workflow function", () => {
563
608
  const schema = z.object({ findings: z.string() });
564
609
  const workflow = defineSubagentWorkflow(
565
- { name: "researcher", description: "Researches topics", resultSchema: schema },
566
- async () => ({ toolResponse: "ok", data: null, threadId: "t" }),
610
+ {
611
+ name: "researcher",
612
+ description: "Researches topics",
613
+ resultSchema: schema,
614
+ },
615
+ async () => ({ toolResponse: "ok", data: null, threadId: "t" })
567
616
  );
568
617
 
569
618
  expect(workflow.agentName).toBe("researcher");
@@ -58,7 +58,7 @@ export interface SubagentConfig<TResult extends z.ZodType = z.ZodType> {
58
58
  /** Description shown to the parent agent explaining what this subagent does */
59
59
  description: string;
60
60
  /** Whether this subagent is available (default: true). Disabled subagents are excluded from the Subagent tool. */
61
- enabled?: boolean;
61
+ enabled?: boolean | (() => boolean);
62
62
  /** Temporal workflow function or type name (used with executeChild) */
63
63
  workflow: string | SubagentWorkflow<TResult>;
64
64
  /** Optional task queue - defaults to parent's queue if not specified */
@@ -63,8 +63,11 @@ export function createToolRouter<T extends ToolMap>(
63
63
  toolMap.set(tool.name, tool as T[keyof T]);
64
64
  }
65
65
 
66
- /** Check if a tool is enabled (defaults to true when not specified) */
67
- const isEnabled = (tool: ToolMap[string]): boolean => tool.enabled ?? true;
66
+ const resolve = <T,>(v: T | (() => T)): T =>
67
+ typeof v === "function" ? (v as () => T)() : v;
68
+
69
+ const isEnabled = (tool: ToolMap[string]): boolean =>
70
+ resolve(tool.enabled) ?? true;
68
71
 
69
72
  if (options.plugins) {
70
73
  for (const plugin of options.plugins) {
@@ -268,7 +271,7 @@ export function createToolRouter<T extends ToolMap>(
268
271
  throw new Error(`Tool ${toolCall.name} not found`);
269
272
  }
270
273
 
271
- const parsedArgs = tool.schema.parse(toolCall.args);
274
+ const parsedArgs = resolve(tool.schema).parse(toolCall.args);
272
275
 
273
276
  return {
274
277
  id: toolCall.id ?? "",
@@ -293,8 +296,8 @@ export function createToolRouter<T extends ToolMap>(
293
296
  .filter(([, tool]) => isEnabled(tool))
294
297
  .map(([name, tool]) => ({
295
298
  name,
296
- description: tool.description,
297
- schema: tool.schema,
299
+ description: resolve(tool.description),
300
+ schema: resolve(tool.schema),
298
301
  strict: tool.strict,
299
302
  max_uses: tool.max_uses,
300
303
  }));
@@ -41,7 +41,7 @@ export interface ToolWithHandler<
41
41
  strict?: boolean;
42
42
  max_uses?: number;
43
43
  /** Whether this tool is available to the agent (default: true). Disabled tools are excluded from definitions and rejected at parse time. */
44
- enabled?: boolean;
44
+ enabled?: boolean | (() => boolean);
45
45
  /** Per-tool lifecycle hooks (run in addition to global hooks) */
46
46
  hooks?: ToolHooks<z.infer<TSchema>, TResult>;
47
47
  }
@@ -58,13 +58,13 @@ export type ToolMap = Record<
58
58
  string,
59
59
  {
60
60
  name: string;
61
- description: string;
62
- schema: z.ZodType;
61
+ description: string | (() => string);
62
+ schema: z.ZodType | (() => z.ZodType);
63
63
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
64
64
  handler: ToolHandler<any, any, any>;
65
65
  strict?: boolean;
66
66
  max_uses?: number;
67
- enabled?: boolean;
67
+ enabled?: boolean | (() => boolean);
68
68
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
69
69
  hooks?: ToolHooks<any, any>;
70
70
  }