zeitlich 0.2.47 → 0.2.49
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 +2 -0
- package/dist/{activities-CPwKoUlD.d.cts → activities-7OcT_vdR.d.cts} +3 -3
- package/dist/{activities-DlaBxNID.d.ts → activities-zG_FBoY2.d.ts} +3 -3
- package/dist/adapters/thread/anthropic/index.d.cts +5 -5
- package/dist/adapters/thread/anthropic/index.d.ts +5 -5
- package/dist/adapters/thread/anthropic/workflow.d.cts +5 -5
- package/dist/adapters/thread/anthropic/workflow.d.ts +5 -5
- package/dist/adapters/thread/google-genai/index.d.cts +5 -5
- package/dist/adapters/thread/google-genai/index.d.ts +5 -5
- package/dist/adapters/thread/google-genai/workflow.d.cts +6 -6
- package/dist/adapters/thread/google-genai/workflow.d.ts +6 -6
- package/dist/adapters/thread/langchain/index.d.cts +5 -5
- package/dist/adapters/thread/langchain/index.d.ts +5 -5
- package/dist/adapters/thread/langchain/workflow.d.cts +5 -5
- package/dist/adapters/thread/langchain/workflow.d.ts +5 -5
- package/dist/{cold-store-Z2wvK2cV.d.cts → cold-store-CkWoNtMh.d.cts} +1 -1
- package/dist/{cold-store-BDgJpwLI.d.ts → cold-store-DKMAO1Dd.d.ts} +1 -1
- package/dist/index.cjs +76 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -8
- package/dist/index.d.ts +8 -8
- package/dist/index.js +76 -10
- package/dist/index.js.map +1 -1
- package/dist/{proxy-CDh3Rsa7.d.cts → proxy-B7CWEV-T.d.cts} +1 -1
- package/dist/{proxy-Du8ggERu.d.ts → proxy-ByFHMVRX.d.ts} +1 -1
- package/dist/{thread-manager-DtHYws2F.d.ts → thread-manager-7AW4rhfu.d.ts} +2 -2
- package/dist/{thread-manager-D8zKNFZ9.d.cts → thread-manager-B9rtMEVn.d.cts} +2 -2
- package/dist/{thread-manager-BjoYYXgd.d.cts → thread-manager-Cibe0X5m.d.cts} +2 -2
- package/dist/{thread-manager-Dw96FKH1.d.ts → thread-manager-nK-WcFzM.d.ts} +2 -2
- package/dist/{types-BMJrsHo0.d.cts → types-BR-k7h0e.d.cts} +1 -1
- package/dist/{types-CtdOquo3.d.ts → types-DO4Tkwxo.d.ts} +1 -1
- package/dist/{types-qQVZfhoT.d.ts → types-DeVNWqlb.d.ts} +54 -0
- package/dist/{types-DNEl5uxQ.d.cts → types-XUUFvrJ9.d.cts} +54 -0
- package/dist/{workflow-BH9ImDGq.d.cts → workflow-KbGsxpfh.d.cts} +1 -1
- package/dist/{workflow-Cdw3-RNB.d.ts → workflow-uhOIj9D-.d.ts} +1 -1
- package/dist/workflow.cjs +76 -10
- 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 +76 -10
- package/dist/workflow.js.map +1 -1
- package/package.json +6 -6
- 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 +26 -0
- package/src/lib/subagent/handler.ts +55 -6
- package/src/lib/subagent/subagent.integration.test.ts +239 -2
- package/src/lib/tool-router/router-edge-cases.integration.test.ts +36 -0
- package/src/lib/tool-router/router.ts +29 -3
- package/src/lib/tool-router/types.ts +43 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zeitlich",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.49",
|
|
4
4
|
"description": "[EXPERIMENTAL] An opinionated AI agent implementation for Temporal",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -208,11 +208,11 @@
|
|
|
208
208
|
"@e2b/code-interpreter": "^2.3.3",
|
|
209
209
|
"@eslint/js": "^10.0.1",
|
|
210
210
|
"@google/genai": "^1.44.0",
|
|
211
|
-
"@langchain/core": "^1.1.
|
|
212
|
-
"@temporalio/common": "^1.17.
|
|
213
|
-
"@temporalio/envconfig": "^1.17.
|
|
214
|
-
"@temporalio/worker": "^1.17.
|
|
215
|
-
"@temporalio/workflow": "^1.17.
|
|
211
|
+
"@langchain/core": "^1.1.48",
|
|
212
|
+
"@temporalio/common": "^1.17.2",
|
|
213
|
+
"@temporalio/envconfig": "^1.17.2",
|
|
214
|
+
"@temporalio/worker": "^1.17.2",
|
|
215
|
+
"@temporalio/workflow": "^1.17.2",
|
|
216
216
|
"@types/node": "^25.3.3",
|
|
217
217
|
"eslint": "^10.0.2",
|
|
218
218
|
"husky": "^9.1.7",
|
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,20 @@ export async function createSession<
|
|
|
570
582
|
{
|
|
571
583
|
turn: currentTurn,
|
|
572
584
|
...(sandboxId !== undefined && { sandboxId }),
|
|
585
|
+
...(assistantId !== undefined && {
|
|
586
|
+
assistantMessageId: assistantId,
|
|
587
|
+
}),
|
|
588
|
+
// Hand handlers a way to persist the parent's slice
|
|
589
|
+
// mid-loop (subagents that fork or continue the parent's
|
|
590
|
+
// thread need this — otherwise the child loads a stale
|
|
591
|
+
// snapshot from the prior session, since `saveThreadState`
|
|
592
|
+
// would otherwise only run in the `finally` below).
|
|
593
|
+
persistThreadState: () =>
|
|
594
|
+
saveThreadState(
|
|
595
|
+
threadId,
|
|
596
|
+
stateManager.getPersistedSlice(),
|
|
597
|
+
threadKey
|
|
598
|
+
),
|
|
573
599
|
}
|
|
574
600
|
);
|
|
575
601
|
|
|
@@ -244,18 +244,44 @@ export function createSubagentHandler<
|
|
|
244
244
|
// `thread: "fork" | "continue"` — `thread: "new"` always starts
|
|
245
245
|
// fresh regardless of the source.
|
|
246
246
|
const newThreadSource = config.newThreadSource ?? "new";
|
|
247
|
+
const usingParentFallback =
|
|
248
|
+
allowsContinuation &&
|
|
249
|
+
!args.threadId &&
|
|
250
|
+
newThreadSource === "from-parent";
|
|
247
251
|
const continuationThreadId = !allowsContinuation
|
|
248
252
|
? undefined
|
|
249
|
-
: (args.threadId ??
|
|
250
|
-
(newThreadSource === "from-parent" ? context.threadId : undefined));
|
|
253
|
+
: (args.threadId ?? (usingParentFallback ? context.threadId : undefined));
|
|
251
254
|
|
|
252
255
|
// --- Build thread init ---
|
|
253
256
|
let thread: ThreadInit | undefined;
|
|
254
257
|
if (continuationThreadId) {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
+
}
|
|
259
285
|
}
|
|
260
286
|
|
|
261
287
|
// --- Build sandbox init ---
|
|
@@ -413,6 +439,29 @@ export function createSubagentHandler<
|
|
|
413
439
|
snapshotBaseCreatorAgent.set(childWorkflowId, config.agentName);
|
|
414
440
|
}
|
|
415
441
|
|
|
442
|
+
// The parent's `PersistedThreadState` slice (`tasks` + custom
|
|
443
|
+
// state) only lands in storage in the session's `finally`. When
|
|
444
|
+
// the child reads from the parent's thread (`from-parent`
|
|
445
|
+
// fallback or an explicit args.threadId pointing at the parent),
|
|
446
|
+
// that `loadThreadState` would otherwise see the prior session's
|
|
447
|
+
// snapshot. Flush the live slice now via the session-supplied
|
|
448
|
+
// callback so the child sees the parent's current state.
|
|
449
|
+
if (
|
|
450
|
+
continuationThreadId &&
|
|
451
|
+
continuationThreadId === context.threadId &&
|
|
452
|
+
context.persistThreadState
|
|
453
|
+
) {
|
|
454
|
+
try {
|
|
455
|
+
await context.persistThreadState();
|
|
456
|
+
} catch (err) {
|
|
457
|
+
log.warn("failed to persist parent thread state for subagent", {
|
|
458
|
+
subagent: config.agentName,
|
|
459
|
+
childWorkflowId,
|
|
460
|
+
error: err instanceof Error ? err.message : String(err),
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
416
465
|
log.info("subagent spawned", {
|
|
417
466
|
subagent: config.agentName,
|
|
418
467
|
childWorkflowId,
|
|
@@ -653,6 +653,40 @@ describe("createSubagentHandler", () => {
|
|
|
653
653
|
|
|
654
654
|
await handler(
|
|
655
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" },
|
|
656
690
|
{ threadId: "parent-t", toolCallId: "tc", toolName: "Subagent" }
|
|
657
691
|
);
|
|
658
692
|
|
|
@@ -693,7 +727,7 @@ describe("createSubagentHandler", () => {
|
|
|
693
727
|
});
|
|
694
728
|
});
|
|
695
729
|
|
|
696
|
-
it("prefers args.threadId over the parent source when both are available", async () => {
|
|
730
|
+
it("prefers args.threadId over the parent source when both are available and skips truncateAfterFork", async () => {
|
|
697
731
|
const { executeChild } = await import("@temporalio/workflow");
|
|
698
732
|
const execMock = executeChild as ReturnType<typeof vi.fn>;
|
|
699
733
|
|
|
@@ -714,7 +748,12 @@ describe("createSubagentHandler", () => {
|
|
|
714
748
|
prompt: "test",
|
|
715
749
|
threadId: "explicit-prev",
|
|
716
750
|
},
|
|
717
|
-
{
|
|
751
|
+
{
|
|
752
|
+
threadId: "parent-t",
|
|
753
|
+
toolCallId: "tc",
|
|
754
|
+
toolName: "Subagent",
|
|
755
|
+
assistantMessageId: "parent-asst-msg-1",
|
|
756
|
+
}
|
|
718
757
|
);
|
|
719
758
|
|
|
720
759
|
const lastCall = execMock.mock.calls[execMock.mock.calls.length - 1];
|
|
@@ -774,6 +813,204 @@ describe("createSubagentHandler", () => {
|
|
|
774
813
|
expect(workflowInput.thread).toBeUndefined();
|
|
775
814
|
});
|
|
776
815
|
|
|
816
|
+
// --- persistThreadState: parent slice flush before child loads ---
|
|
817
|
+
|
|
818
|
+
it("calls persistThreadState before executeChild when forking parent's thread", async () => {
|
|
819
|
+
const { executeChild } = await import("@temporalio/workflow");
|
|
820
|
+
const execMock = executeChild as ReturnType<typeof vi.fn>;
|
|
821
|
+
const persistThreadState = vi.fn(async () => undefined);
|
|
822
|
+
|
|
823
|
+
const subagent: SubagentConfig = {
|
|
824
|
+
agentName: "parent-fork-persist",
|
|
825
|
+
description: "Forks parent thread",
|
|
826
|
+
workflow: mockWorkflow(),
|
|
827
|
+
thread: "fork",
|
|
828
|
+
newThreadSource: "from-parent",
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
const { handler } = createSubagentHandler([subagent]);
|
|
832
|
+
|
|
833
|
+
await handler(
|
|
834
|
+
{
|
|
835
|
+
subagent: "parent-fork-persist",
|
|
836
|
+
description: "test",
|
|
837
|
+
prompt: "test",
|
|
838
|
+
},
|
|
839
|
+
{
|
|
840
|
+
threadId: "parent-t",
|
|
841
|
+
toolCallId: "tc",
|
|
842
|
+
toolName: "Subagent",
|
|
843
|
+
persistThreadState,
|
|
844
|
+
}
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
expect(persistThreadState).toHaveBeenCalledTimes(1);
|
|
848
|
+
const lastExecOrder =
|
|
849
|
+
execMock.mock.invocationCallOrder[
|
|
850
|
+
execMock.mock.invocationCallOrder.length - 1
|
|
851
|
+
] ?? Infinity;
|
|
852
|
+
expect(persistThreadState.mock.invocationCallOrder[0]).toBeLessThan(
|
|
853
|
+
lastExecOrder
|
|
854
|
+
);
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it("calls persistThreadState before executeChild when continuing parent's thread", async () => {
|
|
858
|
+
const { executeChild } = await import("@temporalio/workflow");
|
|
859
|
+
const execMock = executeChild as ReturnType<typeof vi.fn>;
|
|
860
|
+
const persistThreadState = vi.fn(async () => undefined);
|
|
861
|
+
|
|
862
|
+
const subagent: SubagentConfig = {
|
|
863
|
+
agentName: "parent-continue-persist",
|
|
864
|
+
description: "Continues parent thread",
|
|
865
|
+
workflow: mockWorkflow(),
|
|
866
|
+
thread: "continue",
|
|
867
|
+
newThreadSource: "from-parent",
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
const { handler } = createSubagentHandler([subagent]);
|
|
871
|
+
|
|
872
|
+
await handler(
|
|
873
|
+
{
|
|
874
|
+
subagent: "parent-continue-persist",
|
|
875
|
+
description: "test",
|
|
876
|
+
prompt: "test",
|
|
877
|
+
},
|
|
878
|
+
{
|
|
879
|
+
threadId: "parent-t",
|
|
880
|
+
toolCallId: "tc",
|
|
881
|
+
toolName: "Subagent",
|
|
882
|
+
persistThreadState,
|
|
883
|
+
}
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
expect(persistThreadState).toHaveBeenCalledTimes(1);
|
|
887
|
+
const lastExecOrder =
|
|
888
|
+
execMock.mock.invocationCallOrder[
|
|
889
|
+
execMock.mock.invocationCallOrder.length - 1
|
|
890
|
+
] ?? Infinity;
|
|
891
|
+
expect(persistThreadState.mock.invocationCallOrder[0]).toBeLessThan(
|
|
892
|
+
lastExecOrder
|
|
893
|
+
);
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it("calls persistThreadState when args.threadId points at the parent's thread", async () => {
|
|
897
|
+
const persistThreadState = vi.fn(async () => undefined);
|
|
898
|
+
|
|
899
|
+
const subagent: SubagentConfig = {
|
|
900
|
+
agentName: "explicit-parent",
|
|
901
|
+
description: "Explicit parent threadId",
|
|
902
|
+
workflow: mockWorkflow(),
|
|
903
|
+
thread: "continue",
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
const { handler } = createSubagentHandler([subagent]);
|
|
907
|
+
|
|
908
|
+
await handler(
|
|
909
|
+
{
|
|
910
|
+
subagent: "explicit-parent",
|
|
911
|
+
description: "test",
|
|
912
|
+
prompt: "test",
|
|
913
|
+
threadId: "parent-t",
|
|
914
|
+
},
|
|
915
|
+
{
|
|
916
|
+
threadId: "parent-t",
|
|
917
|
+
toolCallId: "tc",
|
|
918
|
+
toolName: "Subagent",
|
|
919
|
+
persistThreadState,
|
|
920
|
+
}
|
|
921
|
+
);
|
|
922
|
+
|
|
923
|
+
expect(persistThreadState).toHaveBeenCalledTimes(1);
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it("does not call persistThreadState when child thread is independent of the parent", async () => {
|
|
927
|
+
const persistThreadState = vi.fn(async () => undefined);
|
|
928
|
+
|
|
929
|
+
const subagent: SubagentConfig = {
|
|
930
|
+
agentName: "independent-thread",
|
|
931
|
+
description: "Continues a sibling thread",
|
|
932
|
+
workflow: mockWorkflow(),
|
|
933
|
+
thread: "continue",
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
const { handler } = createSubagentHandler([subagent]);
|
|
937
|
+
|
|
938
|
+
await handler(
|
|
939
|
+
{
|
|
940
|
+
subagent: "independent-thread",
|
|
941
|
+
description: "test",
|
|
942
|
+
prompt: "test",
|
|
943
|
+
threadId: "sibling-thread",
|
|
944
|
+
},
|
|
945
|
+
{
|
|
946
|
+
threadId: "parent-t",
|
|
947
|
+
toolCallId: "tc",
|
|
948
|
+
toolName: "Subagent",
|
|
949
|
+
persistThreadState,
|
|
950
|
+
}
|
|
951
|
+
);
|
|
952
|
+
|
|
953
|
+
expect(persistThreadState).not.toHaveBeenCalled();
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
it("does not call persistThreadState when the child starts a fresh thread", async () => {
|
|
957
|
+
const persistThreadState = vi.fn(async () => undefined);
|
|
958
|
+
|
|
959
|
+
const subagent: SubagentConfig = {
|
|
960
|
+
agentName: "fresh-thread",
|
|
961
|
+
description: "Always starts fresh",
|
|
962
|
+
workflow: mockWorkflow(),
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
const { handler } = createSubagentHandler([subagent]);
|
|
966
|
+
|
|
967
|
+
await handler(
|
|
968
|
+
{ subagent: "fresh-thread", description: "test", prompt: "test" },
|
|
969
|
+
{
|
|
970
|
+
threadId: "parent-t",
|
|
971
|
+
toolCallId: "tc",
|
|
972
|
+
toolName: "Subagent",
|
|
973
|
+
persistThreadState,
|
|
974
|
+
}
|
|
975
|
+
);
|
|
976
|
+
|
|
977
|
+
expect(persistThreadState).not.toHaveBeenCalled();
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
it("still spawns the child when persistThreadState throws", async () => {
|
|
981
|
+
const { executeChild } = await import("@temporalio/workflow");
|
|
982
|
+
const execMock = executeChild as ReturnType<typeof vi.fn>;
|
|
983
|
+
const persistThreadState = vi.fn(async () => {
|
|
984
|
+
throw new Error("redis down");
|
|
985
|
+
});
|
|
986
|
+
const callsBefore = execMock.mock.calls.length;
|
|
987
|
+
|
|
988
|
+
const subagent: SubagentConfig = {
|
|
989
|
+
agentName: "persist-fails",
|
|
990
|
+
description: "Persist failure should not block child",
|
|
991
|
+
workflow: mockWorkflow(),
|
|
992
|
+
thread: "fork",
|
|
993
|
+
newThreadSource: "from-parent",
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
const { handler } = createSubagentHandler([subagent]);
|
|
997
|
+
|
|
998
|
+
await expect(
|
|
999
|
+
handler(
|
|
1000
|
+
{ subagent: "persist-fails", description: "test", prompt: "test" },
|
|
1001
|
+
{
|
|
1002
|
+
threadId: "parent-t",
|
|
1003
|
+
toolCallId: "tc",
|
|
1004
|
+
toolName: "Subagent",
|
|
1005
|
+
persistThreadState,
|
|
1006
|
+
}
|
|
1007
|
+
)
|
|
1008
|
+
).resolves.toBeDefined();
|
|
1009
|
+
|
|
1010
|
+
expect(persistThreadState).toHaveBeenCalledTimes(1);
|
|
1011
|
+
expect(execMock.mock.calls.length).toBe(callsBefore + 1);
|
|
1012
|
+
});
|
|
1013
|
+
|
|
777
1014
|
// --- Sandbox continuation ---
|
|
778
1015
|
|
|
779
1016
|
it("does not pass sandbox when thread is fork (own sandbox)", async () => {
|
|
@@ -134,6 +134,42 @@ describe("createToolRouter edge cases", () => {
|
|
|
134
134
|
expect(appendSpy.calls).toHaveLength(0);
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
+
// --- assistantMessageId propagation into RouterContext ---
|
|
138
|
+
|
|
139
|
+
it("forwards assistantMessageId from ProcessToolCallsContext into RouterContext", async () => {
|
|
140
|
+
let capturedAssistantMessageId: string | undefined;
|
|
141
|
+
const captureTool = defineTool({
|
|
142
|
+
name: "Capture" as const,
|
|
143
|
+
description: "captures router context",
|
|
144
|
+
schema: z.object({}),
|
|
145
|
+
handler: async (
|
|
146
|
+
_args: Record<string, never>,
|
|
147
|
+
ctx: { assistantMessageId?: string }
|
|
148
|
+
): Promise<ToolHandlerResponse<null>> => {
|
|
149
|
+
capturedAssistantMessageId = ctx.assistantMessageId;
|
|
150
|
+
return { toolResponse: "ok", data: null };
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const router = createToolRouter({
|
|
155
|
+
tools: { Capture: captureTool } as const,
|
|
156
|
+
threadId: "t-1",
|
|
157
|
+
appendToolResult: appendSpy.fn,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const parsed = router.parseToolCall({
|
|
161
|
+
id: "tc-1",
|
|
162
|
+
name: "Capture",
|
|
163
|
+
args: {},
|
|
164
|
+
});
|
|
165
|
+
await router.processToolCalls([parsed], {
|
|
166
|
+
turn: 1,
|
|
167
|
+
assistantMessageId: "asst-msg-42",
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(capturedAssistantMessageId).toBe("asst-msg-42");
|
|
171
|
+
});
|
|
172
|
+
|
|
137
173
|
// --- Both global and per-tool pre-hooks run in order ---
|
|
138
174
|
|
|
139
175
|
it("global pre-hook runs before per-tool pre-hook", async () => {
|
|
@@ -220,7 +220,9 @@ export function createToolRouter<T extends ToolMap>(
|
|
|
220
220
|
toolCall: ParsedToolCallUnion<T>,
|
|
221
221
|
turn: number,
|
|
222
222
|
sandboxId?: string,
|
|
223
|
-
onRewindRequested?: (signal: RewindSignal) => void
|
|
223
|
+
onRewindRequested?: (signal: RewindSignal) => void,
|
|
224
|
+
assistantMessageId?: string,
|
|
225
|
+
persistThreadState?: () => Promise<void>
|
|
224
226
|
): Promise<ProcessedToolCall> {
|
|
225
227
|
const startTime = Date.now();
|
|
226
228
|
const tool = toolMap.get(toolCall.name);
|
|
@@ -263,6 +265,8 @@ export function createToolRouter<T extends ToolMap>(
|
|
|
263
265
|
toolCallId: toolCall.id,
|
|
264
266
|
toolName: toolCall.name,
|
|
265
267
|
...(sandboxId !== undefined && { sandboxId }),
|
|
268
|
+
...(assistantMessageId !== undefined && { assistantMessageId }),
|
|
269
|
+
...(persistThreadState !== undefined && { persistThreadState }),
|
|
266
270
|
};
|
|
267
271
|
const response = await tool.handler(
|
|
268
272
|
effectiveArgs as Parameters<typeof tool.handler>[0],
|
|
@@ -419,6 +423,8 @@ export function createToolRouter<T extends ToolMap>(
|
|
|
419
423
|
|
|
420
424
|
const turn = context?.turn ?? 0;
|
|
421
425
|
const sandboxId = context?.sandboxId;
|
|
426
|
+
const assistantMessageId = context?.assistantMessageId;
|
|
427
|
+
const persistThreadState = context?.persistThreadState;
|
|
422
428
|
|
|
423
429
|
let rewindSignal: RewindSignal | undefined;
|
|
424
430
|
|
|
@@ -435,7 +441,14 @@ export function createToolRouter<T extends ToolMap>(
|
|
|
435
441
|
const outcomes = await scope.run(async () =>
|
|
436
442
|
Promise.allSettled(
|
|
437
443
|
toolCalls.map((tc) =>
|
|
438
|
-
processToolCall(
|
|
444
|
+
processToolCall(
|
|
445
|
+
tc,
|
|
446
|
+
turn,
|
|
447
|
+
sandboxId,
|
|
448
|
+
onRewindRequested,
|
|
449
|
+
assistantMessageId,
|
|
450
|
+
persistThreadState
|
|
451
|
+
)
|
|
439
452
|
)
|
|
440
453
|
)
|
|
441
454
|
);
|
|
@@ -457,7 +470,14 @@ export function createToolRouter<T extends ToolMap>(
|
|
|
457
470
|
|
|
458
471
|
const results: ToolCallResultUnion<TResults>[] = [];
|
|
459
472
|
for (const toolCall of toolCalls) {
|
|
460
|
-
const outcome = await processToolCall(
|
|
473
|
+
const outcome = await processToolCall(
|
|
474
|
+
toolCall,
|
|
475
|
+
turn,
|
|
476
|
+
sandboxId,
|
|
477
|
+
undefined,
|
|
478
|
+
assistantMessageId,
|
|
479
|
+
persistThreadState
|
|
480
|
+
);
|
|
461
481
|
if (outcome.kind === "rewind") {
|
|
462
482
|
rewindSignal = outcome.signal;
|
|
463
483
|
break;
|
|
@@ -492,6 +512,12 @@ export function createToolRouter<T extends ToolMap>(
|
|
|
492
512
|
...(context?.sandboxId !== undefined && {
|
|
493
513
|
sandboxId: context.sandboxId,
|
|
494
514
|
}),
|
|
515
|
+
...(context?.assistantMessageId !== undefined && {
|
|
516
|
+
assistantMessageId: context.assistantMessageId,
|
|
517
|
+
}),
|
|
518
|
+
...(context?.persistThreadState !== undefined && {
|
|
519
|
+
persistThreadState: context.persistThreadState,
|
|
520
|
+
}),
|
|
495
521
|
};
|
|
496
522
|
const response = await handler(
|
|
497
523
|
toolCall.args as ToolArgs<T, TName>,
|
|
@@ -178,6 +178,32 @@ export interface RouterContext {
|
|
|
178
178
|
toolCallId: string;
|
|
179
179
|
toolName: string;
|
|
180
180
|
sandboxId?: string;
|
|
181
|
+
/**
|
|
182
|
+
* Id of the assistant message that issued this tool call (the message
|
|
183
|
+
* the session passed as `assistantMessageId` into `runAgent`). Present
|
|
184
|
+
* for any tool call processed through `processToolCalls` from a
|
|
185
|
+
* session; may be absent when the router is driven manually (e.g.
|
|
186
|
+
* tests, custom orchestrators).
|
|
187
|
+
*
|
|
188
|
+
* Subagent handlers that fork the parent's thread mid-call use this
|
|
189
|
+
* to truncate the orphan trailing assistant message from the forked
|
|
190
|
+
* thread so the child's first model call sees a well-formed history.
|
|
191
|
+
*/
|
|
192
|
+
assistantMessageId?: string;
|
|
193
|
+
/**
|
|
194
|
+
* Persist the parent session's current `PersistedThreadState` slice
|
|
195
|
+
* (tasks + custom state) to the durable thread store. Wired up by
|
|
196
|
+
* the session — absent for manually-driven routers (tests, custom
|
|
197
|
+
* orchestrators).
|
|
198
|
+
*
|
|
199
|
+
* Subagent handlers invoke this before spawning a child that will
|
|
200
|
+
* read the parent's thread (`newThreadSource: "from-parent"` or an
|
|
201
|
+
* explicit parent threadId): the parent's slice otherwise only
|
|
202
|
+
* lands in storage at session-exit time, so the child would load a
|
|
203
|
+
* stale (or empty) snapshot. Best-effort — failures are logged by
|
|
204
|
+
* the session but never thrown.
|
|
205
|
+
*/
|
|
206
|
+
persistThreadState?: () => Promise<void>;
|
|
181
207
|
}
|
|
182
208
|
|
|
183
209
|
/**
|
|
@@ -294,6 +320,23 @@ export interface ProcessToolCallsContext {
|
|
|
294
320
|
turn?: number;
|
|
295
321
|
/** Active sandbox ID (when a sandbox is configured for this session) */
|
|
296
322
|
sandboxId?: string;
|
|
323
|
+
/**
|
|
324
|
+
* Id of the assistant message that produced these tool calls. The
|
|
325
|
+
* router forwards it into every handler's {@link RouterContext} so
|
|
326
|
+
* handlers can reference the message they were issued from (e.g.
|
|
327
|
+
* subagent forks that need to truncate the orphan assistant message
|
|
328
|
+
* out of a parent-forked thread).
|
|
329
|
+
*/
|
|
330
|
+
assistantMessageId?: string;
|
|
331
|
+
/**
|
|
332
|
+
* Optional callback that flushes the session's in-memory
|
|
333
|
+
* `PersistedThreadState` slice to the durable thread store. The
|
|
334
|
+
* router forwards it into every handler's {@link RouterContext}
|
|
335
|
+
* verbatim. The session uses this to let mid-loop tool handlers
|
|
336
|
+
* (notably subagents that fork or continue the parent's thread)
|
|
337
|
+
* persist the parent's slice before the child reads it.
|
|
338
|
+
*/
|
|
339
|
+
persistThreadState?: () => Promise<void>;
|
|
297
340
|
}
|
|
298
341
|
|
|
299
342
|
/**
|