| ... | @@ -18,6 +18,7 @@ import { | ... | @@ -18,6 +18,7 @@ import { |
| 18 | type ProviderInstanceId, | 18 | type ProviderInstanceId, |
| 19 | type PlanId, | 19 | type PlanId, |
| 20 | type OrchestrationV2PlanStep, | 20 | type OrchestrationV2PlanStep, |
| | 21 | type ProviderApprovalDecision, |
| 21 | type RuntimeRequestId, | 22 | type RuntimeRequestId, |
| 22 | type ServerProviderModel, | 23 | type ServerProviderModel, |
| 23 | } from "@t3tools/contracts"; | 24 | } from "@t3tools/contracts"; |
| ... | @@ -157,7 +158,7 @@ const MuseProviderCapabilitiesV2 = { | ... | @@ -157,7 +158,7 @@ const MuseProviderCapabilitiesV2 = { |
| 157 | supportsApplyPatchApproval: false, | 158 | supportsApplyPatchApproval: false, |
| 158 | approvalsHaveNativeRequestIds: true, | 159 | approvalsHaveNativeRequestIds: true, |
| 159 | approvalCallbacksAreLiveOnly: true, | 160 | approvalCallbacksAreLiveOnly: true, |
| 160 | approvalsCanOriginateFromSubagents: false, | 161 | approvalsCanOriginateFromSubagents: true, |
| 161 | }, | 162 | }, |
| 162 | planning: { | 163 | planning: { |
| 163 | emitsPlanUpdated: true, | 164 | emitsPlanUpdated: true, |
| ... | @@ -664,6 +665,8 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte | ... | @@ -664,6 +665,8 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte |
| 664 | } | 665 | } |
| 665 | observedChildren.clear(); | 666 | observedChildren.clear(); |
| 666 | if (thread?.pendingBackgroundTasks?.length) yield* syncBackground(); | 667 | if (thread?.pendingBackgroundTasks?.length) yield* syncBackground(); |
| | 668 | // Only workflow children's requests outlive a turn; they end with the workflow. |
| | 669 | for (const entry of pending.values()) yield* resolvePending(entry, "cancelled"); |
| 667 | }); | 670 | }); |
| 668 | const resolvePending = Effect.fnUntraced(function* ( | 671 | const resolvePending = Effect.fnUntraced(function* ( |
| 669 | entry: PendingRequest, | 672 | entry: PendingRequest, |
| ... | @@ -718,7 +721,9 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte | ... | @@ -718,7 +721,9 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte |
| 718 | yield* publishItem(turn, item, item.status === "inProgress" ? status : undefined); | 721 | yield* publishItem(turn, item, item.status === "inProgress" ? status : undefined); |
| 719 | } | 722 | } |
| 720 | turn.dirty.clear(); | 723 | turn.dirty.clear(); |
| 721 | for (const entry of pending.values()) yield* resolvePending(entry, "cancelled"); | 724 | // A workflow child's request is runless and keeps waiting after the turn ends. |
| | 725 | for (const entry of pending.values()) |
| | 726 | if (entry.request.providerTurnId !== null) yield* resolvePending(entry, "cancelled"); |
| 722 | turn.providerTurn = { ...turn.providerTurn, status, completedAt }; | 727 | turn.providerTurn = { ...turn.providerTurn, status, completedAt }; |
| 723 | lastProviderTurn = turn.providerTurn; | 728 | lastProviderTurn = turn.providerTurn; |
| 724 | yield* emit({ | 729 | yield* emit({ |
| ... | @@ -801,28 +806,54 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte | ... | @@ -801,28 +806,54 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte |
| 801 | }); | 806 | }); |
| 802 | const owns = (turn: ActiveTurn, turnId: string) => | 807 | const owns = (turn: ActiveTurn, turnId: string) => |
| 803 | turnId === turn.nativeId || turn.joined.has(turnId); | 808 | turnId === turn.nativeId || turn.joined.has(turnId); |
| | 809 | /** Sends T3's decision for a pending approval to Muse. */ |
| | 810 | const decideApproval = Effect.fnUntraced(function* ( |
| | 811 | entry: PendingRequest, |
| | 812 | decision: ProviderApprovalDecision | undefined, |
| | 813 | ) { |
| | 814 | if (entry.native.type !== "approval") return; |
| | 815 | const approval = entry.native.value; |
| | 816 | const choice = decision && museApprovalChoices(approval).get(decision); |
| | 817 | if (!choice) return yield* protocolError("Muse did not offer this approval decision"); |
| | 818 | // Recorded first: Muse can settle the approval before it acknowledges the command. |
| | 819 | entry.response = { decision }; |
| | 820 | yield* request("approval/decide", { |
| | 821 | approvalId: approval.approvalId, |
| | 822 | requirementId: approval.currentRequirementId, |
| | 823 | choiceId: choice.choiceId, |
| | 824 | }).pipe(Effect.tapError(() => Effect.sync(() => delete entry.response))); |
| | 825 | }); |
| | 826 | /** |
| | 827 | * Shows a Muse approval or question as a T3 request. A workflow child's approval |
| | 828 | * is runless: the turn that started the workflow may end long before the child |
| | 829 | * asks, and T3 keeps a runless request on the thread until Muse settles it. |
| | 830 | */ |
| 804 | const publishRequest = Effect.fnUntraced(function* (native: PendingRequest["native"]) { | 831 | const publishRequest = Effect.fnUntraced(function* (native: PendingRequest["native"]) { |
| 805 | const turn = active; | 832 | const child = native.type === "approval" && native.value.subagentOrigin !== undefined; |
| | 833 | const turn = child ? undefined : active; |
| 806 | if ( | 834 | if ( |
| 807 | !turn || | 835 | !thread || |
| | 836 | (!child && !turn) || |
| 808 | native.value.sessionId !== nativeSessionId || | 837 | native.value.sessionId !== nativeSessionId || |
| 809 | (native.value.turnId && !owns(turn, native.value.turnId)) | 838 | (turn && native.value.turnId && !owns(turn, native.value.turnId)) |
| 810 | ) | 839 | ) |
| 811 | return; | 840 | return; |
| 812 | const nativeId = | 841 | const nativeId = |
| 813 | native.type === "approval" ? native.value.approvalId : native.value.userInputId; | 842 | native.type === "approval" ? native.value.approvalId : native.value.userInputId; |
| 814 | if (turn.settledRequests.has(`${native.type}:${nativeId}`)) return; | 843 | if (turn?.settledRequests.has(`${native.type}:${nativeId}`)) return; |
| 815 | const previous = [...pending.values()].find( | 844 | const previous = [...pending.values()].find( |
| 816 | (entry) => | 845 | (entry) => |
| 817 | entry.native.type === native.type && | 846 | entry.native.type === native.type && |
| 818 | entry.request.nativeRequestRef?.nativeId === nativeId, | 847 | entry.request.nativeRequestRef?.nativeId === nativeId, |
| 819 | ); | 848 | ); |
| | 849 | const providerTurnId = turn?.providerTurn.id ?? null; |
| | 850 | const threadId = turn?.input.threadId ?? thread.appThreadId ?? input.threadId; |
| 820 | const requestId = | 851 | const requestId = |
| 821 | previous?.request.id ?? | 852 | previous?.request.id ?? |
| 822 | (yield* idAllocator.allocate | 853 | (yield* idAllocator.allocate |
| 823 | .runtimeRequest({ | 854 | .runtimeRequest({ |
| 824 | driver: MUSE_PROVIDER, | 855 | driver: MUSE_PROVIDER, |
| 825 | providerTurnId: turn.providerTurn.id, | 856 | ...(providerTurnId ? { providerTurnId } : {}), |
| 826 | nativeRequestId: nativeId, | 857 | nativeRequestId: nativeId, |
| 827 | }) | 858 | }) |
| 828 | .pipe( | 859 | .pipe( |
| ... | @@ -843,7 +874,7 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte | ... | @@ -843,7 +874,7 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte |
| 843 | const runtimeRequest: OrchestrationV2RuntimeRequest = { | 874 | const runtimeRequest: OrchestrationV2RuntimeRequest = { |
| 844 | id: requestId, | 875 | id: requestId, |
| 845 | nodeId, | 876 | nodeId, |
| 846 | providerTurnId: turn.providerTurn.id, | 877 | providerTurnId, |
| 847 | nativeRequestRef: nativeRef(nativeId), | 878 | nativeRequestRef: nativeRef(nativeId), |
| 848 | kind, | 879 | kind, |
| 849 | status: "pending", | 880 | status: "pending", |
| ... | @@ -853,23 +884,37 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte | ... | @@ -853,23 +884,37 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte |
| 853 | }; | 884 | }; |
| 854 | const node: OrchestrationV2ExecutionNode = { | 885 | const node: OrchestrationV2ExecutionNode = { |
| 855 | id: nodeId, | 886 | id: nodeId, |
| 856 | threadId: turn.input.threadId, | 887 | threadId, |
| 857 | runId: turn.input.runId, | 888 | runId: turn?.input.runId ?? null, |
| 858 | parentNodeId: turn.input.rootNodeId, | 889 | parentNodeId: turn?.input.rootNodeId ?? null, |
| 859 | rootNodeId: turn.input.rootNodeId, | 890 | rootNodeId: turn?.input.rootNodeId ?? nodeId, |
| 860 | kind: native.type === "approval" ? "approval_request" : "user_input_request", | 891 | kind: native.type === "approval" ? "approval_request" : "user_input_request", |
| 861 | status: "waiting", | 892 | status: "waiting", |
| 862 | countsForRun: false, | 893 | countsForRun: false, |
| 863 | providerThreadId: turn.input.providerThread.id, | 894 | providerThreadId: thread.id, |
| 864 | providerTurnId: turn.providerTurn.id, | 895 | providerTurnId, |
| 865 | nativeItemRef: nativeRef(nativeId), | 896 | nativeItemRef: nativeRef(nativeId), |
| 866 | runtimeRequestId: requestId, | 897 | runtimeRequestId: requestId, |
| 867 | checkpointScopeId: null, | 898 | checkpointScopeId: null, |
| 868 | startedAt: runtimeRequest.createdAt, | 899 | startedAt: runtimeRequest.createdAt, |
| 869 | completedAt: null, | 900 | completedAt: null, |
| 870 | }; | 901 | }; |
| | 902 | const latestTurn = active?.providerTurn ?? lastProviderTurn; |
| 871 | const base = { | 903 | const base = { |
| 872 | ...baseItem(turn, `request:${nativeId}`, time), | 904 | ...(turn |
| | 905 | ? baseItem(turn, `request:${nativeId}`, time) |
| | 906 | : { |
| | 907 | threadId, |
| | 908 | runId: null, |
| | 909 | providerThreadId: thread.id, |
| | 910 | providerTurnId: null, |
| | 911 | nativeItemRef: nativeRef(nativeId), |
| | 912 | parentItemId: null, |
| | 913 | // Sorts after the latest turn's items. |
| | 914 | ordinal: previous?.item.ordinal ?? ((latestTurn?.ordinal ?? 0) + 1) * 100, |
| | 915 | startedAt: runtimeRequest.createdAt, |
| | 916 | updatedAt: time, |
| | 917 | }), |
| 873 | id: idAllocator.derive.approvalTurnItem({ requestId }), | 918 | id: idAllocator.derive.approvalTurnItem({ requestId }), |
| 874 | nodeId, | 919 | nodeId, |
| 875 | status: "waiting" as const, | 920 | status: "waiting" as const, |
| ... | @@ -909,16 +954,71 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte | ... | @@ -909,16 +954,71 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte |
| 909 | required: true, | 954 | required: true, |
| 910 | })), | 955 | })), |
| 911 | }; | 956 | }; |
| 912 | pending.set(requestId, { native, request: runtimeRequest, node, item }); | 957 | const entry: PendingRequest = { native, request: runtimeRequest, node, item }; |
| | 958 | pending.set(requestId, entry); |
| 913 | yield* emit({ | 959 | yield* emit({ |
| 914 | type: "runtime_request.updated", | 960 | type: "runtime_request.updated", |
| 915 | driver: MUSE_PROVIDER, | 961 | driver: MUSE_PROVIDER, |
| 916 | threadId: turn.input.threadId, | 962 | threadId, |
| 917 | runtimeRequest, | 963 | runtimeRequest, |
| 918 | }); | 964 | }); |
| 919 | yield* emit({ type: "node.updated", driver: MUSE_PROVIDER, node }); | 965 | yield* emit({ type: "node.updated", driver: MUSE_PROVIDER, node }); |
| 920 | yield* emit({ type: "turn_item.updated", driver: MUSE_PROVIDER, turnItem: item }); | 966 | yield* emit({ type: "turn_item.updated", driver: MUSE_PROVIDER, turnItem: item }); |
| 921 | yield* updateSession("waiting"); | 967 | yield* updateSession("waiting"); |
| | 968 | // Muse does not give workflow children the session's allowAll mode, so in full |
| | 969 | // access T3 approves for them. If Muse rejects that, the request waits for the user. |
| | 970 | if (child && input.runtimePolicy.runtimeMode === "full-access") |
| | 971 | yield* decideApproval(entry, "accept").pipe(Effect.ignore, Effect.forkIn(scope)); |
| | 972 | }); |
| | 973 | /** Approval and question events; `turn` is undefined for a workflow child's approval. */ |
| | 974 | const handleRequestEvent = Effect.fnUntraced(function* ( |
| | 975 | method: string, |
| | 976 | params: Record<string, unknown>, |
| | 977 | turn: ActiveTurn | undefined, |
| | 978 | ) { |
| | 979 | switch (method) { |
| | 980 | case "approval/requested": |
| | 981 | case "approval/updated": |
| | 982 | if ( |
| | 983 | method === "approval/updated" && |
| | 984 | ![...pending.values()].some( |
| | 985 | (entry) => |
| | 986 | entry.native.type === "approval" && |
| | 987 | entry.native.value.approvalId === params.approvalId, |
| | 988 | ) |
| | 989 | ) |
| | 990 | return; |
| | 991 | return yield* publishRequest({ |
| | 992 | type: "approval", |
| | 993 | value: yield* decode(MuseApproval, params), |
| | 994 | }); |
| | 995 | case "userInput/requested": |
| | 996 | return yield* publishRequest({ |
| | 997 | type: "question", |
| | 998 | value: yield* decode(MuseUserInput, params), |
| | 999 | }); |
| | 1000 | case "approval/resolved": |
| | 1001 | case "userInput/settled": { |
| | 1002 | const id = method === "approval/resolved" ? params.approvalId : params.userInputId; |
| | 1003 | if (typeof id === "string") |
| | 1004 | turn?.settledRequests.add( |
| | 1005 | `${method === "approval/resolved" ? "approval" : "question"}:${id}`, |
| | 1006 | ); |
| | 1007 | const entry = [...pending.values()].find( |
| | 1008 | (candidate) => candidate.request.nativeRequestRef?.nativeId === id, |
| | 1009 | ); |
| | 1010 | if (entry) { |
| | 1011 | // An approval settled outside T3 still shows Muse's own decision. |
| | 1012 | const native = |
| | 1013 | method === "approval/resolved" && typeof params.decision === "string" |
| | 1014 | ? museApprovalDecision({ decision: params.decision, scope: "once" }) |
| | 1015 | : undefined; |
| | 1016 | if (!entry.response && native) entry.response = { decision: native }; |
| | 1017 | yield* resolvePending(entry, "resolved"); |
| | 1018 | } |
| | 1019 | if (!pending.size) yield* updateSession(active ? "running" : "ready"); |
| | 1020 | } |
| | 1021 | } |
| 922 | }); | 1022 | }); |
| 923 | const handleNotification = Effect.fnUntraced(function* (method: string, data: unknown) { | 1023 | const handleNotification = Effect.fnUntraced(function* (method: string, data: unknown) { |
| 924 | const params = yield* decode(recordSchema, data); | 1024 | const params = yield* decode(recordSchema, data); |
| ... | @@ -962,6 +1062,10 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte | ... | @@ -962,6 +1062,10 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte |
| 962 | return; | 1062 | return; |
| 963 | } | 1063 | } |
| 964 | } | 1064 | } |
| | 1065 | // A workflow child's approval comes under the child's own run id, during a turn |
| | 1066 | // or after it, so it skips turn routing. |
| | 1067 | if (params.subagentOrigin !== undefined && method.startsWith("approval/")) |
| | 1068 | return yield* handleRequestEvent(method, params, undefined); |
| 965 | // Muse starts a turn on its own when a workflow finishes, to report its result. | 1069 | // Muse starts a turn on its own when a workflow finishes, to report its result. |
| 966 | // During a run, that run shows it. Otherwise hold it and ask the orchestrator for | 1070 | // During a run, that run shows it. Otherwise hold it and ask the orchestrator for |
| 967 | // a run; that run takes the held events, unless a user turn takes them first. | 1071 | // a run; that run takes the held events, unless a user turn takes them first. |
| ... | @@ -1097,45 +1201,11 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte | ... | @@ -1097,45 +1201,11 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte |
| 1097 | } | 1201 | } |
| 1098 | case "approval/requested": | 1202 | case "approval/requested": |
| 1099 | case "approval/updated": | 1203 | case "approval/updated": |
| 1100 | if ( | | |
| 1101 | method === "approval/updated" && | | |
| 1102 | ![...pending.values()].some( | | |
| 1103 | (entry) => | | |
| 1104 | entry.native.type === "approval" && | | |
| 1105 | entry.native.value.approvalId === params.approvalId, | | |
| 1106 | ) | | |
| 1107 | ) | | |
| 1108 | break; | | |
| 1109 | yield* publishRequest({ type: "approval", value: yield* decode(MuseApproval, params) }); | | |
| 1110 | break; | | |
| 1111 | case "userInput/requested": | | |
| 1112 | yield* publishRequest({ | | |
| 1113 | type: "question", | | |
| 1114 | value: yield* decode(MuseUserInput, params), | | |
| 1115 | }); | | |
| 1116 | break; | | |
| 1117 | case "approval/resolved": | 1204 | case "approval/resolved": |
| 1118 | case "userInput/settled": { | 1205 | case "userInput/requested": |
| 1119 | const id = method === "approval/resolved" ? params.approvalId : params.userInputId; | 1206 | case "userInput/settled": |
| 1120 | if (typeof id === "string") | 1207 | yield* handleRequestEvent(method, params, turn); |
| 1121 | turn.settledRequests.add( | | |
| 1122 | `${method === "approval/resolved" ? "approval" : "question"}:${id}`, | | |
| 1123 | ); | | |
| 1124 | const entry = [...pending.values()].find( | | |
| 1125 | (candidate) => candidate.request.nativeRequestRef?.nativeId === id, | | |
| 1126 | ); | | |
| 1127 | if (entry) { | | |
| 1128 | // An approval settled outside T3 still shows Muse's own decision. | | |
| 1129 | const native = | | |
| 1130 | method === "approval/resolved" && typeof params.decision === "string" | | |
| 1131 | ? museApprovalDecision({ decision: params.decision, scope: "once" }) | | |
| 1132 | : undefined; | | |
| 1133 | if (!entry.response && native) entry.response = { decision: native }; | | |
| 1134 | yield* resolvePending(entry, "resolved"); | | |
| 1135 | } | | |
| 1136 | if (!pending.size) yield* updateSession("running"); | | |
| 1137 | break; | 1208 | break; |
| 1138 | } | | |
| 1139 | case "session/tokenUsage": { | 1209 | case "session/tokenUsage": { |
| 1140 | const usage = yield* decode(MuseTokenUsageEvent, params); | 1210 | const usage = yield* decode(MuseTokenUsageEvent, params); |
| 1141 | const previous = turn.providerTurn.turnTokenUsage; | 1211 | const previous = turn.providerTurn.turnTokenUsage; |
| ... | @@ -1892,18 +1962,8 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte | ... | @@ -1892,18 +1962,8 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte |
| 1892 | Effect.gen(function* () { | 1962 | Effect.gen(function* () { |
| 1893 | const entry = pending.get(args.requestId); | 1963 | const entry = pending.get(args.requestId); |
| 1894 | if (!entry) return yield* protocolError("This Muse request is no longer pending"); | 1964 | if (!entry) return yield* protocolError("This Muse request is no longer pending"); |
| 1895 | if (entry.native.type === "approval") { | 1965 | if (entry.native.type === "approval") yield* decideApproval(entry, args.decision); |
| 1896 | const approval = entry.native.value; | 1966 | else { |
| 1897 | const choice = args.decision && museApprovalChoices(approval).get(args.decision); | | |
| 1898 | if (!choice) return yield* protocolError("Muse did not offer this approval decision"); | | |
| 1899 | // Recorded first: Muse can settle the approval before it acknowledges the command. | | |
| 1900 | entry.response = { decision: args.decision }; | | |
| 1901 | yield* request("approval/decide", { | | |
| 1902 | approvalId: approval.approvalId, | | |
| 1903 | requirementId: approval.currentRequirementId, | | |
| 1904 | choiceId: choice.choiceId, | | |
| 1905 | }).pipe(Effect.tapError(() => Effect.sync(() => delete entry.response))); | | |
| 1906 | } else { | | |
| 1907 | const questionRequest = entry.native.value; | 1967 | const questionRequest = entry.native.value; |
| 1908 | const answers = []; | 1968 | const answers = []; |
| 1909 | for (const question of questionRequest.questions) { | 1969 | for (const question of questionRequest.questions) { |