@nuxtagentic/agents 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE.md +9 -0
  2. package/README.md +99 -2
  3. package/module.ts +35 -0
  4. package/package.json +56 -3
  5. package/runtime/actions.ts +22 -0
  6. package/runtime/components/AgentEditor.vue +446 -0
  7. package/runtime/components/AgentList.vue +110 -0
  8. package/runtime/components/AgentToolPicker.vue +371 -0
  9. package/runtime/components/AgentsWorkspace.vue +328 -0
  10. package/runtime/components/ModelSelect.vue +113 -0
  11. package/runtime/components/RunPanel.vue +114 -0
  12. package/runtime/components/RuntimeModelPicker.vue +276 -0
  13. package/runtime/components/SourceEditor.vue +78 -0
  14. package/runtime/composables/useAgentIntegrationTools.ts +24 -0
  15. package/runtime/composables/useAgentRuntimes.ts +32 -0
  16. package/runtime/composables/useAgents.ts +24 -0
  17. package/runtime/index.ts +71 -0
  18. package/runtime/middleware/agents-route.ts +10 -0
  19. package/runtime/pages/agents.vue +26 -0
  20. package/runtime/server/api/agents/[id]/runs/[run].get.ts +11 -0
  21. package/runtime/server/api/agents/[id]/runs.post.ts +33 -0
  22. package/runtime/server/api/agents/runtimes.get.ts +37 -0
  23. package/runtime/server/utils/agent-execution.ts +124 -0
  24. package/runtime/server/utils/agent-gateway.ts +13 -0
  25. package/runtime/server/utils/agent-model-catalog.ts +93 -0
  26. package/runtime/server/utils/agent-provider-keys.ts +46 -0
  27. package/runtime/server/utils/local-agent-catalog.ts +151 -0
  28. package/runtime/server/utils/local-agent-runtime.ts +8 -0
  29. package/runtime/server/utils/model-fetch.ts +66 -0
  30. package/runtime/server/utils/run-local-agent.ts +208 -0
  31. package/runtime/server/utils/tool-result.ts +22 -0
  32. package/runtime/shared/agent-summary.ts +9 -0
  33. package/runtime/shared/agent.ts +4 -0
  34. package/runtime/shared/definition.ts +16 -0
  35. package/runtime/shared/editorTheme.ts +43 -0
  36. package/runtime/shared/integration-tool.ts +11 -0
  37. package/runtime/shared/presentation.ts +50 -0
  38. package/runtime/shared/prompt.ts +37 -0
  39. package/runtime/shared/runtime.ts +40 -0
  40. package/runtime/shared/source.ts +81 -0
  41. package/schema.ts +2 -0
  42. package/server.ts +2 -0
@@ -0,0 +1,124 @@
1
+ import { createServerClient } from '@cloudconfig/core/server'
2
+ import type { AgentsConfig } from '@cloudconfig/core/agents'
3
+ import type { AgentRunHost, AgentRunRequest } from '@cloudconfig/core/agents/platform'
4
+ import { runSavedAgent } from '@cloudconfig/core/agents/runtime'
5
+ import { toAgentDefinition } from '../../shared/definition'
6
+ import { getAgentProviderKeys } from './agent-provider-keys'
7
+ import type { IntegrationsConfig } from '@nuxtagentic/integrations/schema'
8
+ import {
9
+ actorOf,
10
+ backendConfig,
11
+ getDatabaseProvider,
12
+ integrationApi,
13
+ listGrantedTools,
14
+ performIntegrationAction,
15
+ type IntegrationServiceContext,
16
+ } from '@nuxtagentic/integrations/runtime/server/utils/integration-context'
17
+ import { createRetryingModelFetch } from './model-fetch'
18
+ import { limitToolResult } from './tool-result'
19
+
20
+ export async function resolveAgentExecution(
21
+ request: AgentRunRequest,
22
+ env?: Record<string, unknown>,
23
+ ) {
24
+ const database = await getDatabaseProvider(env)
25
+ const context: IntegrationServiceContext = {
26
+ database,
27
+ env,
28
+ request: new Request('https://agent.internal'),
29
+ ownerId: request.scope.ownerId,
30
+ workspace: request.scope.workspace,
31
+ }
32
+ // Runs act as the agent's owner, and only while they are still a member.
33
+ await actorOf(context)
34
+ const api = createServerClient(
35
+ backendConfig as AgentsConfig & IntegrationsConfig,
36
+ { request: context.request, env },
37
+ { db: database, getUser: () => ({ id: context.ownerId }) },
38
+ )
39
+ const record = await api.agent.get(request.scope.agentId)
40
+ const agent = toAgentDefinition(record)
41
+ const granted = agent.tools.some((tool) => tool.enabled) ? await listGrantedTools(context) : []
42
+ const tools = agent.tools
43
+ .filter((tool) => tool.enabled && agent.toolChoice !== 'none')
44
+ .map((tool) => {
45
+ const action = granted.find((entry) => entry.name === tool.name)
46
+ if (!action) {
47
+ throw new Error(`Tool ${tool.name} has no connected handler or permission.`)
48
+ }
49
+ return {
50
+ name: action.name,
51
+ description: tool.description || action.description,
52
+ parameters: action.parameters,
53
+ provider: action.provider,
54
+ action: action.action,
55
+ bindingId: action.bindingId,
56
+ }
57
+ })
58
+ return {
59
+ agent,
60
+ tools,
61
+ keys: getAgentProviderKeys({ ...process.env, ...env }),
62
+ callTool: async (
63
+ name: string,
64
+ input: Record<string, unknown>,
65
+ call: { idempotencyKey: string },
66
+ ) => {
67
+ const tool = tools.find((entry) => entry.name === name)
68
+ if (!tool) {
69
+ throw new Error('The agent requested a tool it has not been granted.')
70
+ }
71
+ return limitToolResult(
72
+ await performIntegrationAction(
73
+ context,
74
+ { provider: tool.provider, action: tool.action, input, bindingId: tool.bindingId },
75
+ {
76
+ idempotencyKey: call.idempotencyKey,
77
+ continuation: { agentScope: request.scope, agentRunId: request.runId },
78
+ },
79
+ ),
80
+ )
81
+ },
82
+ async approval(id: number) {
83
+ const integrations = await integrationApi(context)
84
+ const approval = await integrations.integrationApproval.get(id)
85
+ const connection = await integrations.integrationConnection.get(approval.connectionId)
86
+ if (connection.workspace !== context.workspace) {
87
+ throw new Error('Approval belongs to another workspace.')
88
+ }
89
+ if (approval.status === 'completed') {
90
+ return limitToolResult(approval.result)
91
+ }
92
+ if (approval.status === 'pending' || approval.status === 'approved') {
93
+ return { approvalRequired: true, approvalId: id }
94
+ }
95
+ throw new Error(
96
+ approval.status === 'rejected'
97
+ ? 'The owner rejected the action.'
98
+ : 'The approved action failed.',
99
+ )
100
+ },
101
+ }
102
+ }
103
+
104
+ /** How saved agents run on every deployment target; the framework supplies the platform. */
105
+ export const agentRunHost = {
106
+ resolve: resolveAgentExecution,
107
+ approval: async (request, approvalId, env) =>
108
+ (await resolveAgentExecution(request, env)).approval(approvalId),
109
+ // Local Codex and Claude CLIs run only for runs a localhost request started in development.
110
+ execute: async (invocation, request) => {
111
+ if (invocation.fetch) {
112
+ return runSavedAgent(invocation)
113
+ }
114
+ if (
115
+ import.meta.dev &&
116
+ request.context?.localCli === true &&
117
+ invocation.agent.runtime &&
118
+ invocation.agent.runtime !== 'api'
119
+ ) {
120
+ return (await import('./run-local-agent')).runLocalAgent(invocation)
121
+ }
122
+ return runSavedAgent({ ...invocation, fetch: createRetryingModelFetch() })
123
+ },
124
+ } satisfies AgentRunHost<Record<string, unknown> | undefined>
@@ -0,0 +1,13 @@
1
+ import type { H3Event } from 'h3'
2
+ import { useCloudConfigAgents } from '#cloudconfig/agents'
3
+ import { getIntegrationContext } from '@nuxtagentic/integrations/runtime/server/utils/integration-context'
4
+
5
+ /** Open the caller's runs of one saved agent. The scope comes from the session, never the body. */
6
+ export async function connectAgentGateway(event: H3Event, agentId: number, sessionId = 'test') {
7
+ if (!Number.isSafeInteger(agentId) || agentId < 1) {
8
+ throw new Error('Choose an agent.')
9
+ }
10
+ const context = await getIntegrationContext(event)
11
+ const scope = { ownerId: context.ownerId, workspace: context.workspace, agentId, sessionId }
12
+ return { scope, runtime: await useCloudConfigAgents(event).runs(scope) }
13
+ }
@@ -0,0 +1,93 @@
1
+ import type { AgentProvider } from '@cloudconfig/core/agents'
2
+ import type { AgentModelOption } from '../../shared/runtime'
3
+
4
+ const FRESH_FOR = 60 * 60_000
5
+ const RETRY_AFTER = 60_000
6
+ const cache = new Map<
7
+ AgentProvider,
8
+ { key: string; until: number; models: Promise<AgentModelOption[]> }
9
+ >()
10
+
11
+ /** Serves the last good list immediately and refreshes it in the background. */
12
+ export function listApiAgentModels(provider: AgentProvider, key: string) {
13
+ if (!key) {
14
+ return Promise.resolve([])
15
+ }
16
+ const cached = cache.get(provider)
17
+ if (cached?.key === key) {
18
+ if (cached.until <= Date.now()) {
19
+ cached.until = Date.now() + RETRY_AFTER
20
+ void discover(provider, key).then(
21
+ (models) => {
22
+ if (!models.length) {
23
+ return
24
+ }
25
+ cached.models = Promise.resolve(models)
26
+ cached.until = Date.now() + FRESH_FOR
27
+ },
28
+ () => {},
29
+ )
30
+ }
31
+ return cached.models
32
+ }
33
+ const entry = {
34
+ key,
35
+ until: Date.now() + RETRY_AFTER,
36
+ models: Promise.resolve<AgentModelOption[]>([]),
37
+ }
38
+ entry.models = discover(provider, key).then(
39
+ (models) => {
40
+ if (models.length) {
41
+ entry.until = Date.now() + FRESH_FOR
42
+ }
43
+ return models
44
+ },
45
+ () => [],
46
+ )
47
+ cache.set(provider, entry)
48
+ return entry.models
49
+ }
50
+
51
+ async function discover(provider: AgentProvider, key: string): Promise<AgentModelOption[]> {
52
+ const urls = {
53
+ openai: 'https://api.openai.com/v1/models',
54
+ anthropic: 'https://api.anthropic.com/v1/models?limit=100',
55
+ google: 'https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000',
56
+ }
57
+ const headers: Record<AgentProvider, Record<string, string>> = {
58
+ openai: { Authorization: `Bearer ${key}` },
59
+ anthropic: { 'x-api-key': key, 'anthropic-version': '2023-06-01' },
60
+ google: { 'x-goog-api-key': key },
61
+ }
62
+ const response = await fetch(urls[provider], {
63
+ headers: headers[provider],
64
+ signal: AbortSignal.timeout(5000),
65
+ redirect: 'error',
66
+ })
67
+ if (!response.ok) {
68
+ return []
69
+ }
70
+ const body = (await response.json()) as {
71
+ data?: { id: string; display_name?: string; created?: number }[]
72
+ models?: { name: string; displayName?: string; supportedGenerationMethods?: string[] }[]
73
+ }
74
+ if (provider === 'google') {
75
+ return (body.models ?? [])
76
+ .filter((model) => model.supportedGenerationMethods?.includes('generateContent'))
77
+ .map((model) => ({
78
+ value: model.name.replace(/^models\//, ''),
79
+ label: model.displayName || model.name,
80
+ }))
81
+ }
82
+ // Anthropic lists newest first; OpenAI needs its creation time to match.
83
+ return (body.data ?? [])
84
+ .filter(
85
+ (model) =>
86
+ provider !== 'openai' ||
87
+ (/^(gpt-|o\d|chatgpt-)/.test(model.id) &&
88
+ !/(audio|realtime|transcribe|tts|image|search|instruct|embedding)/.test(model.id) &&
89
+ !/-\d{4}-\d{2}-\d{2}$/.test(model.id)),
90
+ )
91
+ .sort((a, b) => (b.created ?? 0) - (a.created ?? 0))
92
+ .map((model) => ({ value: model.id, label: model.display_name || model.id }))
93
+ }
@@ -0,0 +1,46 @@
1
+ import type { AgentProviderKeys } from '../../shared/runtime'
2
+ import { useRuntimeConfig } from '#imports'
3
+ import type { H3Event } from 'h3'
4
+
5
+ export function getAgentProviderKeysForEvent(event: H3Event) {
6
+ const config = useRuntimeConfig(event)
7
+ type Settings = { openaiApiKey?: string; anthropicApiKey?: string; googleAiApiKey?: string }
8
+ const agents = config.agents as Settings | undefined
9
+ const automations = config.automations as Settings | undefined
10
+ return getAgentProviderKeys({
11
+ NUXT_AGENTS_OPENAI_API_KEY: agents?.openaiApiKey,
12
+ NUXT_AGENTS_ANTHROPIC_API_KEY: agents?.anthropicApiKey,
13
+ NUXT_AGENTS_GOOGLE_AI_API_KEY: agents?.googleAiApiKey,
14
+ NUXT_AUTOMATIONS_OPENAI_API_KEY: automations?.openaiApiKey,
15
+ NUXT_AUTOMATIONS_ANTHROPIC_API_KEY: automations?.anthropicApiKey,
16
+ NUXT_AUTOMATIONS_GOOGLE_AI_API_KEY: automations?.googleAiApiKey,
17
+ ...process.env,
18
+ ...event.context.cloudflare?.env,
19
+ })
20
+ }
21
+
22
+ export function getAgentProviderKeys(values: Record<string, unknown>): AgentProviderKeys {
23
+ return {
24
+ openai: String(
25
+ values.NUXT_AGENTS_OPENAI_API_KEY ||
26
+ values.NUXT_AUTOMATIONS_OPENAI_API_KEY ||
27
+ values.NUXT_OPENAI_API_KEY ||
28
+ values.OPENAI_API_KEY ||
29
+ '',
30
+ ),
31
+ anthropic: String(
32
+ values.NUXT_AGENTS_ANTHROPIC_API_KEY ||
33
+ values.NUXT_AUTOMATIONS_ANTHROPIC_API_KEY ||
34
+ values.NUXT_ANTHROPIC_API_KEY ||
35
+ values.ANTHROPIC_API_KEY ||
36
+ '',
37
+ ),
38
+ google: String(
39
+ values.NUXT_AGENTS_GOOGLE_AI_API_KEY ||
40
+ values.NUXT_AUTOMATIONS_GOOGLE_AI_API_KEY ||
41
+ values.NUXT_GOOGLE_AI_API_KEY ||
42
+ values.GEMINI_API_KEY ||
43
+ '',
44
+ ),
45
+ }
46
+ }
@@ -0,0 +1,151 @@
1
+ import { access, readFile, realpath, stat } from 'node:fs/promises'
2
+ import { constants, createReadStream } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { delimiter, join } from 'node:path'
5
+ import {
6
+ LOCAL_RUNTIMES,
7
+ type AgentModelOption,
8
+ type AgentRuntimeOption,
9
+ type LocalAgentRuntime,
10
+ } from '../../shared/runtime'
11
+
12
+ export async function findLocalAgentBinary(runtime: LocalAgentRuntime) {
13
+ const directories = [...(process.env.PATH ?? '').split(delimiter), join(homedir(), '.local/bin')]
14
+ for (const directory of directories.filter(Boolean)) {
15
+ const path = join(directory, runtime)
16
+ try {
17
+ await access(path, constants.X_OK)
18
+ return await realpath(path)
19
+ } catch {
20
+ /* Try the next PATH directory. */
21
+ }
22
+ }
23
+ return null
24
+ }
25
+
26
+ const RUNTIME_DEFAULT: AgentModelOption = { value: '', label: 'Runtime default' }
27
+ // Claude Code resolves these aliases itself, so they never go stale.
28
+ const FALLBACK: Record<LocalAgentRuntime, AgentModelOption[]> = {
29
+ claude: ['opus', 'sonnet', 'haiku'].map((value) => ({
30
+ value,
31
+ label: `${value[0]!.toUpperCase()}${value.slice(1)} (latest)`,
32
+ })),
33
+ codex: [],
34
+ }
35
+
36
+ // Discovery reads the CLI's 250MB bundle, so repeat it only when the install changes.
37
+ const discovered = new Map<
38
+ LocalAgentRuntime,
39
+ { fingerprint: string; models: Promise<AgentModelOption[]> }
40
+ >()
41
+
42
+ async function computeFingerprint(path: string) {
43
+ try {
44
+ const file = await stat(path)
45
+ return `${path}:${file.size}:${Math.floor(file.mtimeMs)}`
46
+ } catch {
47
+ return ''
48
+ }
49
+ }
50
+
51
+ async function discoverModels(runtime: LocalAgentRuntime, binary: string) {
52
+ // Codex refreshes its account's models into a cache file without changing the binary.
53
+ const source = runtime === 'codex' ? codexModelsCachePath() : binary
54
+ const key = await computeFingerprint(source)
55
+ const cached = discovered.get(runtime)
56
+ if (cached?.fingerprint === key) {
57
+ return cached.models
58
+ }
59
+ const models = (runtime === 'codex' ? codexModels(source) : claudeModels(binary)).catch(() => [])
60
+ discovered.set(runtime, { fingerprint: key, models })
61
+ return models
62
+ }
63
+
64
+ export function getLocalAgentCatalog(): Promise<AgentRuntimeOption[]> {
65
+ return Promise.all(
66
+ (Object.keys(LOCAL_RUNTIMES) as LocalAgentRuntime[]).map(async (value) => {
67
+ const binary = await findLocalAgentBinary(value)
68
+ const models = binary ? await discoverModels(value, binary) : []
69
+ return {
70
+ value,
71
+ label: LOCAL_RUNTIMES[value].label,
72
+ provider: LOCAL_RUNTIMES[value].provider,
73
+ available: !!binary,
74
+ hint: binary
75
+ ? 'Uses your local CLI login.'
76
+ : `Install ${value} and sign in to use it locally.`,
77
+ models: [RUNTIME_DEFAULT, ...(models.length ? models : FALLBACK[value])],
78
+ }
79
+ }),
80
+ )
81
+ }
82
+
83
+ function codexModelsCachePath() {
84
+ return join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'models_cache.json')
85
+ }
86
+
87
+ async function codexModels(path: string): Promise<AgentModelOption[]> {
88
+ const cache = JSON.parse(await readFile(path, 'utf8')) as {
89
+ models?: { slug: string; display_name?: string; visibility?: string; priority?: number }[]
90
+ }
91
+ return (cache.models ?? [])
92
+ .filter((model) => model.slug && (!model.visibility || model.visibility === 'list'))
93
+ .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))
94
+ .map((model) => ({ value: model.slug, label: model.display_name || model.slug }))
95
+ }
96
+
97
+ const CHUNK = 4 * 1024 * 1024
98
+ const OVERLAP = 8 * 1024
99
+
100
+ async function claudeModels(binary: string): Promise<AgentModelOption[]> {
101
+ // Like Open Run, inspect the installed bundle without starting an inference session.
102
+ const models = new Map<string, AgentModelOption & { rank: number; effort: boolean }>()
103
+ const stream = createReadStream(binary, { encoding: 'latin1', highWaterMark: CHUNK })
104
+ let carry = ''
105
+ try {
106
+ for await (const chunk of stream) {
107
+ const text = carry + chunk
108
+ carry = text.slice(-OVERLAP)
109
+ if (!text.includes('display_name:"')) {
110
+ continue
111
+ }
112
+ const entries = [
113
+ ...text.matchAll(/\{id:"(claude-[a-z0-9.-]+)",family:"[a-z0-9]+",display_name:"([^"]+)"/g),
114
+ ]
115
+ for (const [index, entry] of entries.entries()) {
116
+ const end = entries[index + 1]?.index ?? text.indexOf('],aliases:', entry.index)
117
+ const metadata = text.slice(entry.index, end >= 0 ? end : entry.index + 2400)
118
+ models.set(entry[1]!, {
119
+ value: entry[1]!,
120
+ label: entry[2]!,
121
+ rank: Number(/advisor_rank:(\d+)/.exec(metadata)?.[1] ?? 0),
122
+ effort: (/capabilities:\[([^\]]*)\]/.exec(metadata)?.[1] ?? '').includes('"effort"'),
123
+ })
124
+ }
125
+ const latest = text.match(/latest_per_family:\{([^}]*)\}/)?.[1]
126
+ if (!latest) {
127
+ continue
128
+ }
129
+ const ids = new Set([...latest.matchAll(/"(claude-[a-z0-9.-]+)"/g)].map((entry) => entry[1]))
130
+ // `claude` without --model resolves the opus alias; lead with what a run gets today.
131
+ const preferred = /aliases:\{opus:\{default:"([a-z0-9-]+)"/.exec(text)?.[1]
132
+ // Match Open Run: keep selectable effort models as well as each family's latest.
133
+ const current = [...models.values()].filter((model) => model.effort || ids.has(model.value))
134
+ if (current.length) {
135
+ return current
136
+ .sort(
137
+ (a, b) =>
138
+ Number(b.value === preferred) - Number(a.value === preferred) ||
139
+ b.rank - a.rank ||
140
+ b.value.localeCompare(a.value, undefined, { numeric: true }),
141
+ )
142
+ .map(({ value, label }) => ({ value, label, preferred: value === preferred }))
143
+ }
144
+ }
145
+ } finally {
146
+ stream.destroy()
147
+ }
148
+ return [...models.values()]
149
+ .sort((a, b) => b.rank - a.rank || b.value.localeCompare(a.value, undefined, { numeric: true }))
150
+ .map(({ value, label }) => ({ value, label }))
151
+ }
@@ -0,0 +1,8 @@
1
+ import { getRequestURL, type H3Event } from 'h3'
2
+
3
+ export function isLocalAgentRuntimeEnabled(event: H3Event) {
4
+ // Cloudflare's development proxy supplies a context even on the local Node server.
5
+ return (
6
+ import.meta.dev && ['localhost', '127.0.0.1', '[::1]'].includes(getRequestURL(event).hostname)
7
+ )
8
+ }
@@ -0,0 +1,66 @@
1
+ /** Rate limits, provider overload and gateway failures that clear on their own. */
2
+ const TRANSIENT_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504, 529])
3
+ const MAX_RETRIES = 3
4
+ const BASE_DELAY_MS = 500
5
+ const MAX_DELAY_MS = 8_000
6
+
7
+ /**
8
+ * Model transport that retries rate limits, overloads and dropped connections.
9
+ * Model requests have no side effects, so a retry only costs time; the call's
10
+ * own deadline still bounds every attempt and every wait.
11
+ */
12
+ export function createRetryingModelFetch(base: typeof fetch = fetch): typeof fetch {
13
+ return async (input, init) => {
14
+ for (let attempt = 0; ; attempt++) {
15
+ let response: Response | undefined
16
+ try {
17
+ response = await base(input, init)
18
+ } catch (cause) {
19
+ if (init?.signal?.aborted || attempt >= MAX_RETRIES) {
20
+ throw cause
21
+ }
22
+ }
23
+ if (response && (!TRANSIENT_STATUSES.has(response.status) || attempt >= MAX_RETRIES)) {
24
+ return response
25
+ }
26
+ const delay = retryDelay(response, attempt)
27
+ // A provider asking for a longer pause is out of quota, not briefly busy.
28
+ if (response && delay > MAX_DELAY_MS) {
29
+ return response
30
+ }
31
+ await response?.body?.cancel()
32
+ await wait(Math.min(delay, MAX_DELAY_MS), init?.signal)
33
+ }
34
+ }
35
+ }
36
+
37
+ function retryDelay(response: Response | undefined, attempt: number) {
38
+ const header = response?.headers.get('retry-after')
39
+ if (header) {
40
+ const seconds = Number(header)
41
+ const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(header) - Date.now()
42
+ if (Number.isFinite(delay)) {
43
+ return Math.max(delay, 0)
44
+ }
45
+ }
46
+ // Full jitter keeps parallel runs from retrying in lockstep.
47
+ return BASE_DELAY_MS * 2 ** attempt * (0.5 + Math.random() / 2)
48
+ }
49
+
50
+ function wait(milliseconds: number, signal?: AbortSignal | null) {
51
+ return new Promise<void>((resolve, reject) => {
52
+ if (signal?.aborted) {
53
+ reject(signal.reason)
54
+ return
55
+ }
56
+ const abort = () => {
57
+ clearTimeout(timer)
58
+ reject(signal?.reason)
59
+ }
60
+ const timer = setTimeout(() => {
61
+ signal?.removeEventListener('abort', abort)
62
+ resolve()
63
+ }, milliseconds)
64
+ signal?.addEventListener('abort', abort, { once: true })
65
+ })
66
+ }