zeitlich 0.2.46 → 0.2.48
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/README.md +66 -6
- package/dist/{activities-CyeiqK_f.d.cts → activities-BlQR5gX4.d.cts} +3 -3
- package/dist/{activities-Bm4TLTid.d.ts → activities-DCaIPQBT.d.ts} +3 -3
- package/dist/adapters/thread/anthropic/index.cjs +105 -6
- package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
- package/dist/adapters/thread/anthropic/index.d.cts +48 -9
- package/dist/adapters/thread/anthropic/index.d.ts +48 -9
- package/dist/adapters/thread/anthropic/index.js +104 -7
- package/dist/adapters/thread/anthropic/index.js.map +1 -1
- package/dist/adapters/thread/anthropic/workflow.cjs +38 -22
- package/dist/adapters/thread/anthropic/workflow.cjs.map +1 -1
- package/dist/adapters/thread/anthropic/workflow.d.cts +5 -4
- package/dist/adapters/thread/anthropic/workflow.d.ts +5 -4
- package/dist/adapters/thread/anthropic/workflow.js +38 -22
- package/dist/adapters/thread/anthropic/workflow.js.map +1 -1
- package/dist/adapters/thread/google-genai/index.d.cts +6 -5
- package/dist/adapters/thread/google-genai/index.d.ts +6 -5
- package/dist/adapters/thread/google-genai/workflow.cjs +38 -22
- package/dist/adapters/thread/google-genai/workflow.cjs.map +1 -1
- package/dist/adapters/thread/google-genai/workflow.d.cts +7 -5
- package/dist/adapters/thread/google-genai/workflow.d.ts +7 -5
- package/dist/adapters/thread/google-genai/workflow.js +38 -22
- package/dist/adapters/thread/google-genai/workflow.js.map +1 -1
- package/dist/adapters/thread/langchain/index.d.cts +6 -5
- package/dist/adapters/thread/langchain/index.d.ts +6 -5
- package/dist/adapters/thread/langchain/workflow.cjs +38 -22
- package/dist/adapters/thread/langchain/workflow.cjs.map +1 -1
- package/dist/adapters/thread/langchain/workflow.d.cts +5 -4
- package/dist/adapters/thread/langchain/workflow.d.ts +5 -4
- package/dist/adapters/thread/langchain/workflow.js +38 -22
- package/dist/adapters/thread/langchain/workflow.js.map +1 -1
- package/dist/{cold-store-BC5L5Z8A.d.cts → cold-store-UL13Sstw.d.cts} +8 -11
- package/dist/{cold-store-CFHwemBJ.d.ts → cold-store-aD4TSKlU.d.ts} +8 -11
- package/dist/index.cjs +311 -99
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -9
- package/dist/index.d.ts +21 -9
- package/dist/index.js +312 -102
- package/dist/index.js.map +1 -1
- package/dist/proxy-BAty3CWM.d.cts +40 -0
- package/dist/proxy-mbnwBhHw.d.ts +40 -0
- package/dist/{thread-manager-DduoSkvJ.d.ts → thread-manager-CICj68PI.d.ts} +2 -2
- package/dist/{thread-manager-D33SUmZa.d.cts → thread-manager-DsXvJ5cJ.d.cts} +2 -2
- package/dist/{thread-manager-B-zy3xrs.d.ts → thread-manager-DtEtbUkp.d.ts} +2 -2
- package/dist/{thread-manager-9tezUcLW.d.cts → thread-manager-R6c3lnJy.d.cts} +2 -2
- package/dist/{types-oxt8GN97.d.cts → types-DDLPnxBh.d.cts} +1 -1
- package/dist/{types-L5bvbF-n.d.ts → types-DF4wzWQG.d.ts} +1 -1
- package/dist/{types-CnuN9T6t.d.cts → types-DWeyCTYK.d.cts} +47 -0
- package/dist/{types-CwN6_tAL.d.ts → types-DwBYd0ij.d.ts} +47 -0
- package/dist/{workflow-DIaIV7L2.d.cts → workflow-DVNPR7eX.d.cts} +17 -2
- package/dist/{workflow-B1TOcHbt.d.ts → workflow-DdaU7_j4.d.ts} +17 -2
- package/dist/workflow.cjs +80 -12
- package/dist/workflow.cjs.map +1 -1
- package/dist/workflow.d.cts +2 -2
- package/dist/workflow.d.ts +2 -2
- package/dist/workflow.js +80 -13
- package/dist/workflow.js.map +1 -1
- package/package.json +14 -8
- package/src/adapters/thread/anthropic/activities.ts +18 -11
- package/src/adapters/thread/anthropic/index.ts +8 -0
- package/src/adapters/thread/anthropic/model-invoker.test.ts +110 -0
- package/src/adapters/thread/anthropic/model-invoker.ts +26 -5
- package/src/adapters/thread/anthropic/prompt-cache.test.ts +134 -0
- package/src/adapters/thread/anthropic/prompt-cache.ts +163 -0
- package/src/adapters/thread/anthropic/proxy.ts +1 -0
- package/src/adapters/thread/google-genai/proxy.ts +1 -0
- package/src/adapters/thread/langchain/proxy.ts +1 -0
- package/src/index.ts +1 -1
- package/src/lib/lifecycle.ts +13 -1
- package/src/lib/session/session-edge-cases.integration.test.ts +44 -0
- package/src/lib/session/session.ts +15 -0
- package/src/lib/subagent/define.ts +1 -0
- package/src/lib/subagent/handler.ts +41 -6
- package/src/lib/subagent/subagent.integration.test.ts +178 -0
- package/src/lib/subagent/types.ts +16 -0
- package/src/lib/thread/cold-store.test.ts +33 -5
- package/src/lib/thread/cold-store.ts +50 -31
- package/src/lib/thread/proxy.ts +79 -29
- package/src/lib/tool-router/router-edge-cases.integration.test.ts +36 -0
- package/src/lib/tool-router/router.ts +21 -3
- package/src/lib/tool-router/types.ts +20 -0
- package/src/tools/edit/handler.test.ts +177 -0
- package/src/tools/edit/handler.ts +249 -47
- package/src/tools/edit/tool.ts +40 -0
- package/src/tools/task-create/handler.ts +1 -1
- package/src/tools/task-update/handler.ts +1 -1
- package/src/workflow.ts +2 -2
- package/dist/proxy-BxFyd6cg.d.cts +0 -24
- package/dist/proxy-Cskmj4Yx.d.ts +0 -24
package/src/lib/lifecycle.ts
CHANGED
|
@@ -11,11 +11,23 @@
|
|
|
11
11
|
* continue there. When the adapter has `onForkPrepareThread` and/or
|
|
12
12
|
* `onForkTransform` hooks configured, they are applied once to the forked
|
|
13
13
|
* thread before the session starts.
|
|
14
|
+
*
|
|
15
|
+
* The optional `truncateAfterFork.fromMessageId` directs the session to
|
|
16
|
+
* call `truncateThread` on the freshly forked thread immediately after
|
|
17
|
+
* the fork, dropping that message and everything after. Used by
|
|
18
|
+
* subagents that fork their parent's thread mid-tool-call to strip the
|
|
19
|
+
* orphan assistant `tool_use` block (the one whose `tool_result` will
|
|
20
|
+
* never arrive in the child's thread) so the first model call doesn't
|
|
21
|
+
* reject on an unmatched tool-use/tool-result pair.
|
|
14
22
|
*/
|
|
15
23
|
export type ThreadInit =
|
|
16
24
|
| { mode: "new"; threadId?: string }
|
|
17
25
|
| { mode: "continue"; threadId: string }
|
|
18
|
-
| {
|
|
26
|
+
| {
|
|
27
|
+
mode: "fork";
|
|
28
|
+
threadId: string;
|
|
29
|
+
truncateAfterFork?: { fromMessageId: string };
|
|
30
|
+
};
|
|
19
31
|
|
|
20
32
|
// ============================================================================
|
|
21
33
|
// Sandbox lifecycle
|
|
@@ -660,6 +660,50 @@ describe("createSession edge cases", () => {
|
|
|
660
660
|
const forkOp = forkOps[0];
|
|
661
661
|
if (!forkOp) throw new Error("expected fork op");
|
|
662
662
|
expect(forkOp.args[0]).toBe("original-thread");
|
|
663
|
+
|
|
664
|
+
// No truncateAfterFork directive ⇒ no truncate call.
|
|
665
|
+
const truncateOps = log.filter((l) => l.op === "truncateThread");
|
|
666
|
+
expect(truncateOps).toHaveLength(0);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it("fork thread mode truncates the forked thread at fromMessageId when truncateAfterFork is set", async () => {
|
|
670
|
+
const { ops, log } = createMockThreadOps();
|
|
671
|
+
|
|
672
|
+
const session = await createSession({
|
|
673
|
+
agentName: "TestAgent",
|
|
674
|
+
thread: {
|
|
675
|
+
mode: "fork",
|
|
676
|
+
threadId: "parent-thread",
|
|
677
|
+
truncateAfterFork: { fromMessageId: "parent-asst-msg-1" },
|
|
678
|
+
},
|
|
679
|
+
runAgent: createScriptedRunAgent([
|
|
680
|
+
{ message: "forked & continued", toolCalls: [] },
|
|
681
|
+
]),
|
|
682
|
+
threadOps: ops,
|
|
683
|
+
buildContextMessage: () => "continue",
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
const stateManager = createAgentStateManager({
|
|
687
|
+
initialState: { systemPrompt: "test" },
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
const result = await session.runSession({ stateManager });
|
|
691
|
+
|
|
692
|
+
expect(result.exitReason).toBe("completed");
|
|
693
|
+
const forkedThreadId = result.threadId;
|
|
694
|
+
expect(forkedThreadId).not.toBe("parent-thread");
|
|
695
|
+
|
|
696
|
+
// Order matters: fork must happen before truncate, otherwise the
|
|
697
|
+
// truncate would no-op against an empty thread.
|
|
698
|
+
const forkIdx = log.findIndex((l) => l.op === "forkThread");
|
|
699
|
+
const truncIdx = log.findIndex((l) => l.op === "truncateThread");
|
|
700
|
+
expect(forkIdx).toBeGreaterThanOrEqual(0);
|
|
701
|
+
expect(truncIdx).toBeGreaterThan(forkIdx);
|
|
702
|
+
|
|
703
|
+
const truncOp = log[truncIdx];
|
|
704
|
+
if (!truncOp) throw new Error("expected truncate op");
|
|
705
|
+
expect(truncOp.args[0]).toBe(forkedThreadId);
|
|
706
|
+
expect(truncOp.args[1]).toBe("parent-asst-msg-1");
|
|
663
707
|
});
|
|
664
708
|
|
|
665
709
|
// --- maxTurns of 1 ---
|
|
@@ -196,6 +196,7 @@ export async function createSession<
|
|
|
196
196
|
appendSystemMessage,
|
|
197
197
|
appendAgentMessage,
|
|
198
198
|
forkThread,
|
|
199
|
+
truncateThread,
|
|
199
200
|
loadThreadState,
|
|
200
201
|
saveThreadState,
|
|
201
202
|
hydrateThread,
|
|
@@ -399,6 +400,17 @@ export async function createSession<
|
|
|
399
400
|
// is already hot or when no cold tier is wired.
|
|
400
401
|
await hydrateThread(sourceThreadId, threadKey);
|
|
401
402
|
await forkThread(sourceThreadId, threadId, threadKey);
|
|
403
|
+
// If the caller asked to drop the tail of the forked thread
|
|
404
|
+
// (e.g. subagent forking its parent mid-tool-call needs to
|
|
405
|
+
// strip the orphan assistant `tool_use`), do it now — before
|
|
406
|
+
// any rehydration / state load so the truncated thread is
|
|
407
|
+
// what subsequent reads see.
|
|
408
|
+
const truncate = (
|
|
409
|
+
threadInit as { mode: "fork"; truncateAfterFork?: { fromMessageId: string } }
|
|
410
|
+
).truncateAfterFork;
|
|
411
|
+
if (truncate?.fromMessageId) {
|
|
412
|
+
await truncateThread(threadId, truncate.fromMessageId, threadKey);
|
|
413
|
+
}
|
|
402
414
|
const forkedSlice = await loadThreadState(threadId, threadKey);
|
|
403
415
|
if (forkedSlice) rehydrateFromSlice(forkedSlice);
|
|
404
416
|
} else if (threadMode === "continue") {
|
|
@@ -570,6 +582,9 @@ export async function createSession<
|
|
|
570
582
|
{
|
|
571
583
|
turn: currentTurn,
|
|
572
584
|
...(sandboxId !== undefined && { sandboxId }),
|
|
585
|
+
...(assistantId !== undefined && {
|
|
586
|
+
assistantMessageId: assistantId,
|
|
587
|
+
}),
|
|
573
588
|
}
|
|
574
589
|
);
|
|
575
590
|
|
|
@@ -237,16 +237,51 @@ export function createSubagentHandler<
|
|
|
237
237
|
|
|
238
238
|
const threadMode = config.thread ?? "new";
|
|
239
239
|
const allowsContinuation = threadMode !== "new";
|
|
240
|
-
|
|
241
|
-
|
|
240
|
+
// The parent agent's tool call wins. When `threadId` is omitted,
|
|
241
|
+
// `newThreadSource` decides what to fall back to: `"new"` (default)
|
|
242
|
+
// starts fresh, `"from-parent"` continues/forks the parent's own
|
|
243
|
+
// thread via `context.threadId`. Both paths still require
|
|
244
|
+
// `thread: "fork" | "continue"` — `thread: "new"` always starts
|
|
245
|
+
// fresh regardless of the source.
|
|
246
|
+
const newThreadSource = config.newThreadSource ?? "new";
|
|
247
|
+
const usingParentFallback =
|
|
248
|
+
allowsContinuation &&
|
|
249
|
+
!args.threadId &&
|
|
250
|
+
newThreadSource === "from-parent";
|
|
251
|
+
const continuationThreadId = !allowsContinuation
|
|
252
|
+
? undefined
|
|
253
|
+
: (args.threadId ?? (usingParentFallback ? context.threadId : undefined));
|
|
242
254
|
|
|
243
255
|
// --- Build thread init ---
|
|
244
256
|
let thread: ThreadInit | undefined;
|
|
245
257
|
if (continuationThreadId) {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
258
|
+
if (threadMode === "fork") {
|
|
259
|
+
// When falling back to the parent's thread for a fork, the
|
|
260
|
+
// parent is mid-tool-call: its assistant message containing the
|
|
261
|
+
// `Subagent` tool_use has already been persisted, but its
|
|
262
|
+
// matching tool_result hasn't. Forking verbatim would leave an
|
|
263
|
+
// orphan tool_use at the tail of the child thread, which most
|
|
264
|
+
// model APIs reject on the very next call. Have the child's
|
|
265
|
+
// session truncate that assistant message (and anything after)
|
|
266
|
+
// immediately after the fork so the first model call sees a
|
|
267
|
+
// well-formed history.
|
|
268
|
+
thread = {
|
|
269
|
+
mode: "fork",
|
|
270
|
+
threadId: continuationThreadId,
|
|
271
|
+
...(usingParentFallback && context.assistantMessageId
|
|
272
|
+
? {
|
|
273
|
+
truncateAfterFork: {
|
|
274
|
+
fromMessageId: context.assistantMessageId,
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
: {}),
|
|
278
|
+
};
|
|
279
|
+
} else {
|
|
280
|
+
thread = {
|
|
281
|
+
mode: "continue",
|
|
282
|
+
threadId: continuationThreadId,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
250
285
|
}
|
|
251
286
|
|
|
252
287
|
// --- Build sandbox init ---
|
|
@@ -635,6 +635,184 @@ describe("createSubagentHandler", () => {
|
|
|
635
635
|
expect(workflowInput.thread).toBeUndefined();
|
|
636
636
|
});
|
|
637
637
|
|
|
638
|
+
// --- newThreadSource: "from-parent" ---
|
|
639
|
+
|
|
640
|
+
it("uses the parent's threadId when fork + newThreadSource 'from-parent' and args.threadId is absent", async () => {
|
|
641
|
+
const { executeChild } = await import("@temporalio/workflow");
|
|
642
|
+
const execMock = executeChild as ReturnType<typeof vi.fn>;
|
|
643
|
+
|
|
644
|
+
const subagent: SubagentConfig = {
|
|
645
|
+
agentName: "parent-fork",
|
|
646
|
+
description: "Forks parent thread by default",
|
|
647
|
+
workflow: mockWorkflow(),
|
|
648
|
+
thread: "fork",
|
|
649
|
+
newThreadSource: "from-parent",
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
const { handler } = createSubagentHandler([subagent]);
|
|
653
|
+
|
|
654
|
+
await handler(
|
|
655
|
+
{ subagent: "parent-fork", description: "test", prompt: "test" },
|
|
656
|
+
{
|
|
657
|
+
threadId: "parent-t",
|
|
658
|
+
toolCallId: "tc",
|
|
659
|
+
toolName: "Subagent",
|
|
660
|
+
assistantMessageId: "parent-asst-msg-1",
|
|
661
|
+
}
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
const lastCall = execMock.mock.calls[execMock.mock.calls.length - 1];
|
|
665
|
+
if (!lastCall) throw new Error("expected executeChild call");
|
|
666
|
+
const workflowInput = lastCall[1].args[1] as SubagentWorkflowInput;
|
|
667
|
+
expect(workflowInput.thread).toEqual({
|
|
668
|
+
mode: "fork",
|
|
669
|
+
threadId: "parent-t",
|
|
670
|
+
truncateAfterFork: { fromMessageId: "parent-asst-msg-1" },
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it("omits truncateAfterFork on parent-fallback fork when assistantMessageId is absent from context", async () => {
|
|
675
|
+
const { executeChild } = await import("@temporalio/workflow");
|
|
676
|
+
const execMock = executeChild as ReturnType<typeof vi.fn>;
|
|
677
|
+
|
|
678
|
+
const subagent: SubagentConfig = {
|
|
679
|
+
agentName: "parent-fork-no-asst",
|
|
680
|
+
description: "Forks parent thread by default",
|
|
681
|
+
workflow: mockWorkflow(),
|
|
682
|
+
thread: "fork",
|
|
683
|
+
newThreadSource: "from-parent",
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
const { handler } = createSubagentHandler([subagent]);
|
|
687
|
+
|
|
688
|
+
await handler(
|
|
689
|
+
{ subagent: "parent-fork-no-asst", description: "test", prompt: "test" },
|
|
690
|
+
{ threadId: "parent-t", toolCallId: "tc", toolName: "Subagent" }
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
const lastCall = execMock.mock.calls[execMock.mock.calls.length - 1];
|
|
694
|
+
if (!lastCall) throw new Error("expected executeChild call");
|
|
695
|
+
const workflowInput = lastCall[1].args[1] as SubagentWorkflowInput;
|
|
696
|
+
expect(workflowInput.thread).toEqual({
|
|
697
|
+
mode: "fork",
|
|
698
|
+
threadId: "parent-t",
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
it("uses the parent's threadId when continue + newThreadSource 'from-parent' and args.threadId is absent", async () => {
|
|
703
|
+
const { executeChild } = await import("@temporalio/workflow");
|
|
704
|
+
const execMock = executeChild as ReturnType<typeof vi.fn>;
|
|
705
|
+
|
|
706
|
+
const subagent: SubagentConfig = {
|
|
707
|
+
agentName: "parent-continue",
|
|
708
|
+
description: "Continues parent thread by default",
|
|
709
|
+
workflow: mockWorkflow(),
|
|
710
|
+
thread: "continue",
|
|
711
|
+
newThreadSource: "from-parent",
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
const { handler } = createSubagentHandler([subagent]);
|
|
715
|
+
|
|
716
|
+
await handler(
|
|
717
|
+
{ subagent: "parent-continue", description: "test", prompt: "test" },
|
|
718
|
+
{ threadId: "parent-t", toolCallId: "tc", toolName: "Subagent" }
|
|
719
|
+
);
|
|
720
|
+
|
|
721
|
+
const lastCall = execMock.mock.calls[execMock.mock.calls.length - 1];
|
|
722
|
+
if (!lastCall) throw new Error("expected executeChild call");
|
|
723
|
+
const workflowInput = lastCall[1].args[1] as SubagentWorkflowInput;
|
|
724
|
+
expect(workflowInput.thread).toEqual({
|
|
725
|
+
mode: "continue",
|
|
726
|
+
threadId: "parent-t",
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it("prefers args.threadId over the parent source when both are available and skips truncateAfterFork", async () => {
|
|
731
|
+
const { executeChild } = await import("@temporalio/workflow");
|
|
732
|
+
const execMock = executeChild as ReturnType<typeof vi.fn>;
|
|
733
|
+
|
|
734
|
+
const subagent: SubagentConfig = {
|
|
735
|
+
agentName: "parent-fork-explicit",
|
|
736
|
+
description: "Forks parent thread by default",
|
|
737
|
+
workflow: mockWorkflow(),
|
|
738
|
+
thread: "fork",
|
|
739
|
+
newThreadSource: "from-parent",
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
const { handler } = createSubagentHandler([subagent]);
|
|
743
|
+
|
|
744
|
+
await handler(
|
|
745
|
+
{
|
|
746
|
+
subagent: "parent-fork-explicit",
|
|
747
|
+
description: "test",
|
|
748
|
+
prompt: "test",
|
|
749
|
+
threadId: "explicit-prev",
|
|
750
|
+
},
|
|
751
|
+
{
|
|
752
|
+
threadId: "parent-t",
|
|
753
|
+
toolCallId: "tc",
|
|
754
|
+
toolName: "Subagent",
|
|
755
|
+
assistantMessageId: "parent-asst-msg-1",
|
|
756
|
+
}
|
|
757
|
+
);
|
|
758
|
+
|
|
759
|
+
const lastCall = execMock.mock.calls[execMock.mock.calls.length - 1];
|
|
760
|
+
if (!lastCall) throw new Error("expected executeChild call");
|
|
761
|
+
const workflowInput = lastCall[1].args[1] as SubagentWorkflowInput;
|
|
762
|
+
expect(workflowInput.thread).toEqual({
|
|
763
|
+
mode: "fork",
|
|
764
|
+
threadId: "explicit-prev",
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it("ignores newThreadSource 'from-parent' when thread is new", async () => {
|
|
769
|
+
const { executeChild } = await import("@temporalio/workflow");
|
|
770
|
+
const execMock = executeChild as ReturnType<typeof vi.fn>;
|
|
771
|
+
|
|
772
|
+
const subagent: SubagentConfig = {
|
|
773
|
+
agentName: "new-with-source",
|
|
774
|
+
description: "Should still start fresh",
|
|
775
|
+
workflow: mockWorkflow(),
|
|
776
|
+
newThreadSource: "from-parent",
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
const { handler } = createSubagentHandler([subagent]);
|
|
780
|
+
|
|
781
|
+
await handler(
|
|
782
|
+
{ subagent: "new-with-source", description: "test", prompt: "test" },
|
|
783
|
+
{ threadId: "parent-t", toolCallId: "tc", toolName: "Subagent" }
|
|
784
|
+
);
|
|
785
|
+
|
|
786
|
+
const lastCall = execMock.mock.calls[execMock.mock.calls.length - 1];
|
|
787
|
+
if (!lastCall) throw new Error("expected executeChild call");
|
|
788
|
+
const workflowInput = lastCall[1].args[1] as SubagentWorkflowInput;
|
|
789
|
+
expect(workflowInput.thread).toBeUndefined();
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it("preserves prior behavior when fork is set with default newThreadSource and args.threadId is absent", async () => {
|
|
793
|
+
const { executeChild } = await import("@temporalio/workflow");
|
|
794
|
+
const execMock = executeChild as ReturnType<typeof vi.fn>;
|
|
795
|
+
|
|
796
|
+
const subagent: SubagentConfig = {
|
|
797
|
+
agentName: "fork-default-source",
|
|
798
|
+
description: "Fork with default new-thread source",
|
|
799
|
+
workflow: mockWorkflow(),
|
|
800
|
+
thread: "fork",
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
const { handler } = createSubagentHandler([subagent]);
|
|
804
|
+
|
|
805
|
+
await handler(
|
|
806
|
+
{ subagent: "fork-default-source", description: "test", prompt: "test" },
|
|
807
|
+
{ threadId: "parent-t", toolCallId: "tc", toolName: "Subagent" }
|
|
808
|
+
);
|
|
809
|
+
|
|
810
|
+
const lastCall = execMock.mock.calls[execMock.mock.calls.length - 1];
|
|
811
|
+
if (!lastCall) throw new Error("expected executeChild call");
|
|
812
|
+
const workflowInput = lastCall[1].args[1] as SubagentWorkflowInput;
|
|
813
|
+
expect(workflowInput.thread).toBeUndefined();
|
|
814
|
+
});
|
|
815
|
+
|
|
638
816
|
// --- Sandbox continuation ---
|
|
639
817
|
|
|
640
818
|
it("does not pass sandbox when thread is fork (own sandbox)", async () => {
|
|
@@ -632,6 +632,22 @@ export interface SubagentConfig<TResult extends z.ZodType = z.ZodType> {
|
|
|
632
632
|
* directly to the existing thread in-place.
|
|
633
633
|
*/
|
|
634
634
|
thread?: "new" | "fork" | "continue";
|
|
635
|
+
/**
|
|
636
|
+
* Where the subagent's thread comes from when the parent's tool call
|
|
637
|
+
* omits `threadId`. Only meaningful in combination with
|
|
638
|
+
* `thread: "fork"` or `"continue"`.
|
|
639
|
+
*
|
|
640
|
+
* - `"new"` (default) — start a fresh thread (the prior behavior).
|
|
641
|
+
* - `"from-parent"` — use the parent's own `threadId` (from
|
|
642
|
+
* `RouterContext`). With `thread: "fork"` the parent's conversation
|
|
643
|
+
* is copied into a new thread; with `thread: "continue"` the
|
|
644
|
+
* subagent appends to the parent's thread in-place.
|
|
645
|
+
*
|
|
646
|
+
* Has no effect when `thread` is `"new"` (or omitted). A `threadId`
|
|
647
|
+
* supplied by the parent agent always wins — `newThreadSource` only
|
|
648
|
+
* applies when none is provided.
|
|
649
|
+
*/
|
|
650
|
+
newThreadSource?: "new" | "from-parent";
|
|
635
651
|
/**
|
|
636
652
|
* Sandbox strategy for this subagent.
|
|
637
653
|
*
|
|
@@ -16,9 +16,9 @@ interface CommandInput {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Minimal
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* Minimal S3-shaped fake. `send()` dispatches by command constructor
|
|
20
|
+
* name; `config` provides the fields `@aws-sdk/lib-storage`'s
|
|
21
|
+
* single-part upload path inspects.
|
|
22
22
|
*/
|
|
23
23
|
function createFakeS3(): {
|
|
24
24
|
s3: S3LikeClient;
|
|
@@ -31,7 +31,12 @@ function createFakeS3(): {
|
|
|
31
31
|
const compositeKey = (bucket: string, key: string): string =>
|
|
32
32
|
`${bucket}/${key}`;
|
|
33
33
|
|
|
34
|
-
const s3
|
|
34
|
+
const s3 = {
|
|
35
|
+
config: {
|
|
36
|
+
requestHandler: undefined,
|
|
37
|
+
forcePathStyle: false,
|
|
38
|
+
endpoint: async (): Promise<URL> => new URL("https://fake.s3.local"),
|
|
39
|
+
},
|
|
35
40
|
async send<TInput, TOutput>(
|
|
36
41
|
command: { input: TInput } & object
|
|
37
42
|
): Promise<TOutput> {
|
|
@@ -68,7 +73,7 @@ function createFakeS3(): {
|
|
|
68
73
|
}
|
|
69
74
|
throw new Error(`unknown command: ${name}`);
|
|
70
75
|
},
|
|
71
|
-
};
|
|
76
|
+
} as unknown as S3LikeClient;
|
|
72
77
|
|
|
73
78
|
return { s3, store, calls };
|
|
74
79
|
}
|
|
@@ -190,4 +195,27 @@ describe("createS3ColdStore", () => {
|
|
|
190
195
|
});
|
|
191
196
|
expect(await cold.read("messages", "t-1")).toEqual(sampleSnapshot);
|
|
192
197
|
});
|
|
198
|
+
|
|
199
|
+
it("round-trips a large payload through the async gzip path", async () => {
|
|
200
|
+
// ~1 MB payload — regression guard that large payloads still
|
|
201
|
+
// encode/decode correctly through the promisified gzip path.
|
|
202
|
+
const big: ThreadSnapshot = {
|
|
203
|
+
v: 1,
|
|
204
|
+
messages: Array.from({ length: 500 }, (_, i) =>
|
|
205
|
+
JSON.stringify({
|
|
206
|
+
id: `m${i}`,
|
|
207
|
+
text: "x".repeat(2048),
|
|
208
|
+
})
|
|
209
|
+
),
|
|
210
|
+
state: null,
|
|
211
|
+
dedupIds: Array.from({ length: 500 }, (_, i) => `m${i}`),
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const cold = createS3ColdStore({
|
|
215
|
+
s3: fake.s3,
|
|
216
|
+
bucket: "test-bucket",
|
|
217
|
+
});
|
|
218
|
+
await cold.write("messages", "big", big);
|
|
219
|
+
expect(await cold.read("messages", "big")).toEqual(big);
|
|
220
|
+
});
|
|
193
221
|
});
|
|
@@ -18,9 +18,17 @@
|
|
|
18
18
|
* last-writer-wins; no compare-and-swap is required.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import {
|
|
21
|
+
import { gunzip as gunzipCb, gzip as gzipCb } from "node:zlib";
|
|
22
|
+
import { promisify } from "node:util";
|
|
22
23
|
import type { PersistedThreadState } from "../state/types";
|
|
23
|
-
import { DeleteObjectCommand, GetObjectCommand,
|
|
24
|
+
import { DeleteObjectCommand, GetObjectCommand, type S3Client } from "@aws-sdk/client-s3";
|
|
25
|
+
import { Upload } from "@aws-sdk/lib-storage";
|
|
26
|
+
import { getActivityContext } from "../activity";
|
|
27
|
+
|
|
28
|
+
// Async zlib so gzip/gunzip don't block the worker's event loop
|
|
29
|
+
// during compression of large snapshots.
|
|
30
|
+
const gzipAsync = promisify(gzipCb);
|
|
31
|
+
const gunzipAsync = promisify(gunzipCb);
|
|
24
32
|
|
|
25
33
|
/**
|
|
26
34
|
* Serialized form of a thread that can be written to and read from a
|
|
@@ -69,20 +77,16 @@ export interface ColdThreadStore {
|
|
|
69
77
|
}
|
|
70
78
|
|
|
71
79
|
/**
|
|
72
|
-
*
|
|
73
|
-
* `send(...)`
|
|
74
|
-
* `@aws-sdk/
|
|
75
|
-
*
|
|
80
|
+
* Alias for `@aws-sdk/client-s3`'s `S3Client`. The built-in store
|
|
81
|
+
* calls `send(...)` and accesses `client.config` (read by
|
|
82
|
+
* `@aws-sdk/lib-storage`'s `Upload`) — a duck-type with just `send`
|
|
83
|
+
* is not sufficient.
|
|
76
84
|
*/
|
|
77
|
-
export
|
|
78
|
-
send<TInput, TOutput>(
|
|
79
|
-
command: { input: TInput } & object
|
|
80
|
-
): Promise<TOutput>;
|
|
81
|
-
}
|
|
85
|
+
export type S3LikeClient = S3Client;
|
|
82
86
|
|
|
83
87
|
/** Configuration for the built-in S3 cold store. */
|
|
84
88
|
export interface S3ColdStoreConfig {
|
|
85
|
-
/** An `@aws-sdk/client-s3` `S3Client
|
|
89
|
+
/** An `@aws-sdk/client-s3` `S3Client`. */
|
|
86
90
|
s3: S3LikeClient;
|
|
87
91
|
/** S3 bucket that holds the archive. */
|
|
88
92
|
bucket: string;
|
|
@@ -126,10 +130,26 @@ function buildKey(
|
|
|
126
130
|
}
|
|
127
131
|
|
|
128
132
|
async function streamToBuffer(
|
|
129
|
-
body: unknown
|
|
133
|
+
body: unknown,
|
|
134
|
+
onChunk?: () => void
|
|
130
135
|
): Promise<Buffer> {
|
|
131
136
|
if (body == null) return Buffer.alloc(0);
|
|
132
137
|
if (body instanceof Uint8Array) return Buffer.from(body);
|
|
138
|
+
// Prefer async iteration so `onChunk` fires per chunk. Node S3
|
|
139
|
+
// bodies (`SdkStream<Readable>`) iterate; bulk-read fallbacks
|
|
140
|
+
// below cover browser body shapes.
|
|
141
|
+
if (
|
|
142
|
+
typeof (body as { [Symbol.asyncIterator]?: unknown })[
|
|
143
|
+
Symbol.asyncIterator
|
|
144
|
+
] === "function"
|
|
145
|
+
) {
|
|
146
|
+
const chunks: Buffer[] = [];
|
|
147
|
+
for await (const chunk of body as AsyncIterable<Buffer | Uint8Array>) {
|
|
148
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
149
|
+
onChunk?.();
|
|
150
|
+
}
|
|
151
|
+
return Buffer.concat(chunks);
|
|
152
|
+
}
|
|
133
153
|
if (typeof (body as { transformToByteArray?: () => Promise<Uint8Array> })
|
|
134
154
|
.transformToByteArray === "function") {
|
|
135
155
|
const bytes = await (
|
|
@@ -144,12 +164,7 @@ async function streamToBuffer(
|
|
|
144
164
|
).arrayBuffer();
|
|
145
165
|
return Buffer.from(ab);
|
|
146
166
|
}
|
|
147
|
-
|
|
148
|
-
const chunks: Buffer[] = [];
|
|
149
|
-
for await (const chunk of body as AsyncIterable<Buffer | Uint8Array>) {
|
|
150
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
151
|
-
}
|
|
152
|
-
return Buffer.concat(chunks);
|
|
167
|
+
return Buffer.alloc(0);
|
|
153
168
|
}
|
|
154
169
|
|
|
155
170
|
/**
|
|
@@ -191,9 +206,10 @@ export function createS3ColdStore(
|
|
|
191
206
|
const resp = (await s3.send(
|
|
192
207
|
new GetObjectCommand({ Bucket: bucket, Key })
|
|
193
208
|
)) as { Body?: unknown };
|
|
194
|
-
const
|
|
209
|
+
const { heartbeat } = getActivityContext();
|
|
210
|
+
const buf = await streamToBuffer(resp.Body, heartbeat);
|
|
195
211
|
const json = gzip
|
|
196
|
-
?
|
|
212
|
+
? (await gunzipAsync(buf)).toString("utf8")
|
|
197
213
|
: buf.toString("utf8");
|
|
198
214
|
return JSON.parse(json) as ThreadSnapshot;
|
|
199
215
|
} catch (err) {
|
|
@@ -209,16 +225,19 @@ export function createS3ColdStore(
|
|
|
209
225
|
): Promise<void> {
|
|
210
226
|
const Key = buildKey(prefix, threadKey, threadId, gzip);
|
|
211
227
|
const json = JSON.stringify(snapshot);
|
|
212
|
-
const body = gzip ?
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
);
|
|
228
|
+
const body = gzip ? await gzipAsync(Buffer.from(json, "utf8")) : json;
|
|
229
|
+
|
|
230
|
+
const upload = new Upload({
|
|
231
|
+
client: s3,
|
|
232
|
+
params: { Bucket: bucket, Key, Body: body, ContentType: contentType },
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Heartbeat per S3 part completion so a stalled upload trips
|
|
236
|
+
// `heartbeatTimeout` instead of `startToCloseTimeout`.
|
|
237
|
+
const { heartbeat } = getActivityContext();
|
|
238
|
+
if (heartbeat) upload.on("httpUploadProgress", heartbeat);
|
|
239
|
+
|
|
240
|
+
await upload.done();
|
|
222
241
|
},
|
|
223
242
|
|
|
224
243
|
async delete(
|