@panagos/jojoscript 0.9.3 → 1.0.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.
@@ -234,16 +234,38 @@ function parsePipelineStatement(source, statementStart, statementEnd) {
234
234
  throw new SyntaxError(`Empty pipeline stage in:\n${statement}`)
235
235
  }
236
236
 
237
- stages.push(parseStage(statement, stageTokens))
237
+ stages.push(parseStageWithId(statement, stageTokens))
238
238
  i = stageEnd - 1
239
239
  }
240
240
 
241
+ const ids = stages.map(stage => stage.id).filter(Boolean)
242
+ const duplicate = ids.find((id, index) => ids.indexOf(id) !== index)
243
+ if (duplicate) throw new SyntaxError(`Duplicate stage id: @${duplicate}`)
244
+
241
245
  const prefixTokens = tokens.slice(0, inputStartIndex)
242
246
  const prefix = prefixTokens.length ? text(statement, prefixTokens).trim() : ''
243
247
 
244
248
  return { expression, stages, prefix }
245
249
  }
246
250
 
251
+ export const STAGE_ID = /^[A-Za-z_][\w-]*$/
252
+
253
+ // A trailing `@id` labels a stage without changing what it computes.
254
+ function parseStageWithId(source, tokens) {
255
+ let depth = 0
256
+ for (let i = 0; i < tokens.length; i++) {
257
+ const value = tokens[i].value
258
+ if (value === '(' || value === '[' || value === '{') depth++
259
+ else if (value === ')' || value === ']' || value === '}') depth--
260
+ else if (value === '@' && depth === 0) {
261
+ const id = source.slice(tokens[i].end, tokens[tokens.length - 1].end).trim()
262
+ if (!STAGE_ID.test(id)) throw new SyntaxError(`Invalid stage id: @${id}`)
263
+ return { ...parseStage(source, tokens.slice(0, i)), id }
264
+ }
265
+ }
266
+ return parseStage(source, tokens)
267
+ }
268
+
247
269
  function metaLiteral(pipelineId, nodeId, type, extra = {}) {
248
270
  return JSON.stringify({ id: nodeId, pipelineId, type, ...extra })
249
271
  }
@@ -312,7 +334,7 @@ export function emitPipeline(pipeline, options = {}) {
312
334
 
313
335
  const stagesWithIds = pipeline.stages.map((stage, index) => ({
314
336
  ...stage,
315
- nodeId: `node-${index + 1}`
337
+ nodeId: stage.id ?? `node-${index + 1}`
316
338
  }))
317
339
 
318
340
  const stages = fuseRetryStages(stagesWithIds, pipelineId)
@@ -0,0 +1,203 @@
1
+ import { compile } from './compiler.js'
2
+ import { findAllPipelines, STAGE_ID } from './pipeline-parser.js'
3
+ import { computePipelineId } from './pipeline-hash.js'
4
+ import { inferPipelineName } from './pipeline-explain.js'
5
+ import { transformNamedPipelines } from './named-pipelines.js'
6
+
7
+ const PLAN_VERSION = 1
8
+
9
+ const EXPRESSION_STAGES = new Set([
10
+ 'map', 'filter', 'flatMap', 'tap', 'mapAsync', 'filterAsync', 'find', 'some',
11
+ 'every', 'sortBy', 'distinctBy', 'groupBy', 'partition', 'catch'
12
+ ])
13
+
14
+ const INTEGER_STAGE_FIELDS = { parallel: 'concurrency', batch: 'size' }
15
+
16
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/
17
+ const STAGE_NAME = /^\.?[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/
18
+ const SIMPLE_STRING = /^(["'])([^"'\\]*)\1$/
19
+
20
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
21
+
22
+ class PlanValidationError extends Error {
23
+ constructor(message) {
24
+ super(`Invalid plan: ${message}`)
25
+ this.name = 'PlanValidationError'
26
+ }
27
+ }
28
+
29
+ /*
30
+ * The canonical, machine-readable form of every pipeline in `source`.
31
+ * Arguments stay as source text, so the plan describes computation without
32
+ * evaluating it and `planToJojo` can reproduce it.
33
+ */
34
+ export function planPipelines(jojoSource) {
35
+ const source = transformNamedPipelines(jojoSource)
36
+ return {
37
+ version: PLAN_VERSION,
38
+ pipelines: findAllPipelines(source).map(pipeline => ({
39
+ pipeline: inferPipelineName(source, pipeline),
40
+ id: computePipelineId(pipeline),
41
+ stages: [{ type: 'source', expression: pipeline.expression }, ...pipeline.stages.map(planStage)]
42
+ }))
43
+ }
44
+ }
45
+
46
+ function planStage(stage) {
47
+ const planned = plannedStage(stage)
48
+ return stage.id ? { ...planned, id: stage.id } : planned
49
+ }
50
+
51
+ function plannedStage(stage) {
52
+ const { name: type, args } = stage
53
+ const integerField = INTEGER_STAGE_FIELDS[type]
54
+
55
+ if (integerField && args.length === 1 && /^[1-9]\d*$/.test(args[0])) {
56
+ return { type, [integerField]: Number(args[0]) }
57
+ }
58
+ if (type === 'checkpoint' && args.length === 1 && SIMPLE_STRING.test(args[0])) {
59
+ return { type, name: args[0].match(SIMPLE_STRING)[2] }
60
+ }
61
+ if (EXPRESSION_STAGES.has(type) && args.length === 1) {
62
+ return { type, expression: args[0] }
63
+ }
64
+ if (stage.propertyCall || args.length) return { type, args }
65
+ return { type }
66
+ }
67
+
68
+ export function planToJojo(plan) {
69
+ validatePlan(plan)
70
+
71
+ return plan.pipelines.map((pipeline, index) => {
72
+ const source = emitPipeline(pipeline)
73
+ assertCompiles(source, `pipelines[${index}]`)
74
+ return source
75
+ }).join('\n\n') + '\n'
76
+ }
77
+
78
+ function emitPipeline({ pipeline, stages: [source, ...stages] }) {
79
+ const head = pipeline ? `${pipeline} := ${source.expression}` : source.expression
80
+ return [head, ...stages.map(stage => ` |> ${emitStage(stage)}`)].join('\n')
81
+ }
82
+
83
+ function emitStage(stage) {
84
+ const args = stageArgs(stage)
85
+ const call = args ? `${stage.type}(${args.join(', ')})` : stage.type
86
+ return stage.id ? `${call} @${stage.id}` : call
87
+ }
88
+
89
+ function stageArgs(stage) {
90
+ if ('expression' in stage) return [stage.expression]
91
+ if ('name' in stage) return [JSON.stringify(stage.name)]
92
+ if ('concurrency' in stage) return [String(stage.concurrency)]
93
+ if ('size' in stage) return [String(stage.size)]
94
+ return stage.args ?? null
95
+ }
96
+
97
+ // Building the function only parses the generated code; it is never called.
98
+ function assertCompiles(source, path) {
99
+ const compiled = compile(source).replace(/^import .*\n/, '')
100
+ try {
101
+ new AsyncFunction(compiled)
102
+ } catch (error) {
103
+ throw new PlanValidationError(`${path} does not compile to valid JavaScript (${error.message})`)
104
+ }
105
+ }
106
+
107
+ function validatePlan(plan) {
108
+ if (!isObject(plan)) throw new PlanValidationError('plan must be an object')
109
+ assertKnownFields(plan, ['version', 'pipelines'], '')
110
+ if (plan.version !== PLAN_VERSION) throw new PlanValidationError(`version must be ${PLAN_VERSION}`)
111
+ if (!Array.isArray(plan.pipelines)) throw new PlanValidationError('pipelines must be an array')
112
+
113
+ const names = new Set()
114
+ plan.pipelines.forEach((pipeline, index) => {
115
+ const path = `pipelines[${index}]`
116
+ if (!isObject(pipeline)) throw new PlanValidationError(`${path} must be an object`)
117
+ assertKnownFields(pipeline, ['pipeline', 'id', 'stages'], path)
118
+
119
+ if (pipeline.pipeline !== null && !IDENTIFIER.test(pipeline.pipeline ?? '')) {
120
+ throw new PlanValidationError(`${path}.pipeline must be an identifier or null`)
121
+ }
122
+ if (names.has(pipeline.pipeline)) {
123
+ throw new PlanValidationError(`${path}.pipeline "${pipeline.pipeline}" is already declared`)
124
+ }
125
+ if (pipeline.pipeline) names.add(pipeline.pipeline)
126
+
127
+ if (!Array.isArray(pipeline.stages) || !pipeline.stages.length) {
128
+ throw new PlanValidationError(`${path}.stages must be a non-empty array`)
129
+ }
130
+ const stageIds = new Set()
131
+ pipeline.stages.forEach((stage, stageIndex) => {
132
+ const stagePath = `${path}.stages[${stageIndex}]`
133
+ validateStage(stage, stageIndex, stagePath)
134
+ if (stage.id === undefined) return
135
+ if (stageIds.has(stage.id)) throw new PlanValidationError(`${stagePath}.id "${stage.id}" is already used in this pipeline`)
136
+ stageIds.add(stage.id)
137
+ })
138
+ })
139
+ }
140
+
141
+ function validateStage(stage, index, path) {
142
+ if (!isObject(stage)) throw new PlanValidationError(`${path} must be an object`)
143
+
144
+ if (index === 0) {
145
+ if (stage.type !== 'source') throw new PlanValidationError(`${path} must be a source stage`)
146
+ assertKnownFields(stage, ['type', 'expression'], path)
147
+ assertNonEmptyString(stage.expression, `${path}.expression`)
148
+ return
149
+ }
150
+
151
+ if (stage.type === 'source') throw new PlanValidationError(`${path} must not be a source stage`)
152
+ if (typeof stage.type !== 'string' || !STAGE_NAME.test(stage.type)) {
153
+ throw new PlanValidationError(`${path}.type must be a stage name such as "map" or ".trim"`)
154
+ }
155
+
156
+ const fields = allowedFields(stage.type)
157
+ assertKnownFields(stage, ['type', 'id', ...fields], path)
158
+ if (stage.id !== undefined && (typeof stage.id !== 'string' || !STAGE_ID.test(stage.id))) {
159
+ throw new PlanValidationError(`${path}.id must be a stage id such as "enrich"`)
160
+ }
161
+
162
+ const used = fields.filter(field => field in stage)
163
+ if (used.length > 1) {
164
+ throw new PlanValidationError(`${path} must use only one of ${used.map(field => `"${field}"`).join(' or ')}`)
165
+ }
166
+
167
+ const [field] = used
168
+ if (field === 'concurrency' || field === 'size') {
169
+ if (!Number.isInteger(stage[field]) || stage[field] <= 0) {
170
+ throw new PlanValidationError(`${path}.${field} must be a positive integer`)
171
+ }
172
+ } else if (field === 'args') {
173
+ if (!Array.isArray(stage.args) || !stage.args.every(arg => typeof arg === 'string' && arg.trim())) {
174
+ throw new PlanValidationError(`${path}.args must be an array of non-empty strings`)
175
+ }
176
+ } else if (field) {
177
+ assertNonEmptyString(stage[field], `${path}.${field}`)
178
+ }
179
+ }
180
+
181
+ function allowedFields(type) {
182
+ if (INTEGER_STAGE_FIELDS[type]) return [INTEGER_STAGE_FIELDS[type], 'args']
183
+ if (type === 'checkpoint') return ['name', 'args']
184
+ if (EXPRESSION_STAGES.has(type)) return ['expression', 'args']
185
+ return ['args']
186
+ }
187
+
188
+ function assertKnownFields(value, fields, path) {
189
+ const unknown = Object.keys(value).find(key => !fields.includes(key))
190
+ if (unknown) {
191
+ throw new PlanValidationError(path ? `${path} has unknown field "${unknown}"` : `unknown field "${unknown}"`)
192
+ }
193
+ }
194
+
195
+ function assertNonEmptyString(value, path) {
196
+ if (typeof value !== 'string' || !value.trim()) {
197
+ throw new PlanValidationError(`${path} must be a non-empty string`)
198
+ }
199
+ }
200
+
201
+ function isObject(value) {
202
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
203
+ }
package/src/purity.js ADDED
@@ -0,0 +1,101 @@
1
+ import { maskNonCode } from './lexer.js'
2
+ import { findEffectDeclarations } from './effect-parser.js'
3
+
4
+ const PURITY_DECLARATION = /^([ \t]*)(export[ \t]+)?(async[ \t]+)?(pure|effectful)[ \t]+(async[ \t]+)?fn[ \t]+([A-Za-z_$][\w$]*)/gm
5
+
6
+ function lineAt(source, offset) {
7
+ return source.slice(0, offset).split('\n').length
8
+ }
9
+
10
+ function matchingBrace(code, openIndex) {
11
+ let depth = 0
12
+ for (let i = openIndex; i < code.length; i++) {
13
+ if (code[i] === '{' || code[i] === '(') depth++
14
+ else if ((code[i] === '}' || code[i] === ')') && --depth === 0) return i
15
+ }
16
+ return code.length
17
+ }
18
+
19
+ /*
20
+ * Body bounds come from the masked source so braces inside strings and
21
+ * comments never end a body early.
22
+ */
23
+ function bodyRange(code, nameEnd) {
24
+ const paramsOpen = code.indexOf('(', nameEnd)
25
+ const paramsClose = matchingBrace(code, paramsOpen)
26
+ const rest = code.slice(paramsClose + 1)
27
+ const head = rest.search(/[{=]/)
28
+ const start = paramsClose + 1 + head
29
+
30
+ if (code[start] === '=') {
31
+ const lineEnd = code.indexOf('\n', start)
32
+ return { start, end: lineEnd === -1 ? code.length : lineEnd }
33
+ }
34
+ return { start, end: matchingBrace(code, start) + 1 }
35
+ }
36
+
37
+ export function findPurityDeclarations(source) {
38
+ const code = maskNonCode(source)
39
+ const regex = new RegExp(PURITY_DECLARATION.source, PURITY_DECLARATION.flags)
40
+ const declarations = []
41
+ let match
42
+
43
+ while ((match = regex.exec(code))) {
44
+ const nameEnd = match.index + match[0].length
45
+ declarations.push({
46
+ name: match[6],
47
+ purity: match[4],
48
+ line: lineAt(source, match.index),
49
+ body: bodyRange(code, nameEnd)
50
+ })
51
+ }
52
+
53
+ return declarations
54
+ }
55
+
56
+ /*
57
+ * Static and name-based: a pure function may not mention a declared effect
58
+ * or an effectful function outside strings, comments, and property access.
59
+ */
60
+ export function findPurityViolations(source) {
61
+ const code = maskNonCode(source)
62
+ const declarations = findPurityDeclarations(source)
63
+ const forbidden = [
64
+ ...findEffectDeclarations(source).map(declaration => ({ name: declaration.name, kind: 'effect' })),
65
+ ...declarations
66
+ .filter(declaration => declaration.purity === 'effectful')
67
+ .map(declaration => ({ name: declaration.name, kind: 'effectful function' }))
68
+ ]
69
+
70
+ return declarations
71
+ .filter(declaration => declaration.purity === 'pure')
72
+ .flatMap(declaration => {
73
+ const body = code.slice(declaration.body.start, declaration.body.end)
74
+
75
+ return forbidden
76
+ .filter(({ name }) => name !== declaration.name)
77
+ .map(({ name, kind }) => ({ kind, name, index: body.search(new RegExp(`(?<![.\\w$])${name}(?![\\w$])`)) }))
78
+ .filter(({ index }) => index !== -1)
79
+ .map(({ name, kind, index }) => ({
80
+ function: declaration.name,
81
+ uses: name,
82
+ kind,
83
+ line: lineAt(source, declaration.body.start + index)
84
+ }))
85
+ })
86
+ .sort((a, b) => a.line - b.line)
87
+ }
88
+
89
+ export function transformPurityModifiers(source) {
90
+ const violation = findPurityViolations(source)[0]
91
+ if (violation) {
92
+ throw new SyntaxError(`pure fn "${violation.function}" uses ${violation.kind} "${violation.uses}" (line ${violation.line})`)
93
+ }
94
+
95
+ const code = maskNonCode(source)
96
+ return source.replace(PURITY_DECLARATION, (match, ...args) => {
97
+ const offset = args[args.length - 2]
98
+ if (code.slice(offset, offset + match.length) !== match) return match
99
+ return match.replace(/(pure|effectful)[ \t]+(?=(?:async[ \t]+)?fn[ \t])/, '')
100
+ })
101
+ }
@@ -0,0 +1,322 @@
1
+ const TERMINAL_TASK_STATES = new Set([
2
+ 'TASK_STATE_COMPLETED',
3
+ 'TASK_STATE_FAILED',
4
+ 'TASK_STATE_CANCELED',
5
+ 'TASK_STATE_REJECTED',
6
+ 'TASK_STATE_INPUT_REQUIRED',
7
+ 'TASK_STATE_AUTH_REQUIRED'
8
+ ])
9
+
10
+ function isObject(value) {
11
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
12
+ }
13
+
14
+ function taskIdFrom(task) {
15
+ const id = typeof task === 'string' ? task : task?.id
16
+ if (typeof id !== 'string' || !id) {
17
+ throw new TypeError('An A2A task id is required')
18
+ }
19
+ return id
20
+ }
21
+
22
+ function abortError(reason) {
23
+ if (reason instanceof A2AError) return reason
24
+ return new A2AError('A2A operation was aborted', {
25
+ code: 'ABORTED',
26
+ cause: reason
27
+ })
28
+ }
29
+
30
+ function throwIfAborted(signal) {
31
+ if (signal?.aborted) throw abortError(signal.reason)
32
+ }
33
+
34
+ function waitForDelay(delayMs, signal) {
35
+ throwIfAborted(signal)
36
+
37
+ return new Promise((resolve, reject) => {
38
+ const timer = setTimeout(done, delayMs)
39
+ function done() {
40
+ signal?.removeEventListener('abort', onAbort)
41
+ resolve()
42
+ }
43
+ function onAbort() {
44
+ clearTimeout(timer)
45
+ signal.removeEventListener('abort', onAbort)
46
+ reject(abortError(signal.reason))
47
+ }
48
+ signal?.addEventListener('abort', onAbort, { once: true })
49
+ })
50
+ }
51
+
52
+ function createWaitSignal(signal, timeoutMs, taskId) {
53
+ const controller = new AbortController()
54
+ const abort = () => controller.abort(signal.reason)
55
+ const timer = setTimeout(() => {
56
+ controller.abort(new A2AError(`Timed out waiting for A2A task "${taskId}"`, {
57
+ code: 'TIMEOUT'
58
+ }))
59
+ }, timeoutMs)
60
+
61
+ signal?.addEventListener('abort', abort, { once: true })
62
+ if (signal?.aborted) abort()
63
+
64
+ return {
65
+ signal: controller.signal,
66
+ cleanup() {
67
+ clearTimeout(timer)
68
+ signal?.removeEventListener('abort', abort)
69
+ }
70
+ }
71
+ }
72
+
73
+ export class A2AError extends Error {
74
+ constructor(message, options = {}) {
75
+ super(message, options.cause === undefined ? undefined : { cause: options.cause })
76
+ this.name = 'A2AError'
77
+ this.code = options.code
78
+ this.data = options.data
79
+ this.status = options.status
80
+ this.remoteCode = options.remoteCode
81
+ }
82
+ }
83
+
84
+ export class A2AClient {
85
+ constructor(agentUrl, options = {}) {
86
+ if (typeof agentUrl !== 'string' || !agentUrl) {
87
+ throw new TypeError('An A2A agent URL is required')
88
+ }
89
+ if (options.headers !== undefined && !isObject(options.headers)) {
90
+ throw new TypeError('A2A headers must be an object')
91
+ }
92
+ if (options.bearerToken !== undefined &&
93
+ (typeof options.bearerToken !== 'string' || !options.bearerToken)) {
94
+ throw new TypeError('A2A bearerToken must be a non-empty string')
95
+ }
96
+
97
+ this.agentUrl = new URL(agentUrl)
98
+ this.fetch = options.fetch ?? globalThis.fetch
99
+ this.headers = {
100
+ ...(options.bearerToken ? { authorization: `Bearer ${options.bearerToken}` } : {}),
101
+ ...(options.headers ?? {})
102
+ }
103
+ this.agentCard = null
104
+ this.endpoint = null
105
+ this.requestId = 0
106
+
107
+ if (typeof this.fetch !== 'function') {
108
+ throw new TypeError('A fetch implementation is required')
109
+ }
110
+ }
111
+
112
+ async discover(options = {}) {
113
+ throwIfAborted(options.signal)
114
+
115
+ const cardUrl = new URL('/.well-known/agent-card.json', this.agentUrl)
116
+ const response = await this.#fetch(cardUrl.href, {
117
+ method: 'GET',
118
+ headers: this.headers,
119
+ signal: options.signal
120
+ }, 'Agent Card request')
121
+ if (!response.ok) {
122
+ throw new A2AError(`A2A Agent Card request failed with HTTP ${response.status}`, {
123
+ code: 'DISCOVERY_HTTP_ERROR',
124
+ status: response.status
125
+ })
126
+ }
127
+
128
+ const card = await this.#readJson(response, 'A2A Agent Card', 'INVALID_AGENT_CARD')
129
+ throwIfAborted(options.signal)
130
+ if (!isObject(card)) {
131
+ throw new A2AError('A2A Agent Card response must be a JSON object', {
132
+ code: 'INVALID_AGENT_CARD'
133
+ })
134
+ }
135
+
136
+ const jsonRpcInterface = card.supportedInterfaces?.find(
137
+ item => isObject(item) && item.protocolBinding === 'JSONRPC' && typeof item.url === 'string'
138
+ )
139
+ const endpoint = jsonRpcInterface?.url
140
+ ?? (card.preferredTransport === 'JSONRPC' ? card.url : null)
141
+
142
+ if (typeof endpoint !== 'string' || !endpoint) {
143
+ throw new A2AError('A2A Agent Card does not advertise a JSON-RPC interface', {
144
+ code: 'UNSUPPORTED_INTERFACE'
145
+ })
146
+ }
147
+
148
+ let endpointUrl
149
+ try {
150
+ endpointUrl = new URL(endpoint, cardUrl)
151
+ } catch (error) {
152
+ throw new A2AError('A2A Agent Card contains an invalid JSON-RPC endpoint', {
153
+ code: 'INVALID_AGENT_CARD',
154
+ cause: error
155
+ })
156
+ }
157
+ if (!['http:', 'https:'].includes(endpointUrl.protocol) ||
158
+ endpointUrl.username || endpointUrl.password) {
159
+ throw new A2AError('A2A Agent Card contains an unsafe JSON-RPC endpoint', {
160
+ code: 'INVALID_AGENT_CARD'
161
+ })
162
+ }
163
+
164
+ this.agentCard = card
165
+ this.endpoint = endpointUrl
166
+ return card
167
+ }
168
+
169
+ async sendMessage(message, configuration, options = {}) {
170
+ if (!isObject(message)) {
171
+ throw new TypeError('A2A message must be an object')
172
+ }
173
+
174
+ const params = { message }
175
+ if (configuration !== undefined) params.configuration = configuration
176
+ return this.#request('SendMessage', params, options)
177
+ }
178
+
179
+ async getTask(task, options = {}) {
180
+ const params = typeof task === 'string' ? { id: task } : task
181
+ taskIdFrom(params)
182
+ return this.#request('GetTask', params, options)
183
+ }
184
+
185
+ async cancelTask(task, options = {}) {
186
+ const id = taskIdFrom(task)
187
+ return this.#request('CancelTask', { id }, options)
188
+ }
189
+
190
+ async waitForTask(taskOrId, options = {}) {
191
+ const taskId = taskIdFrom(taskOrId)
192
+ const pollIntervalMs = options.pollIntervalMs ?? 1000
193
+ const maxPollIntervalMs = options.maxPollIntervalMs ?? 30000
194
+ const backoffMultiplier = options.backoffMultiplier ?? 1.5
195
+ const timeoutMs = options.timeoutMs ?? 300000
196
+
197
+ if (!Number.isFinite(pollIntervalMs) || pollIntervalMs < 0) {
198
+ throw new TypeError('pollIntervalMs must be a non-negative number')
199
+ }
200
+ if (!Number.isFinite(maxPollIntervalMs) || maxPollIntervalMs < pollIntervalMs) {
201
+ throw new TypeError('maxPollIntervalMs must be at least pollIntervalMs')
202
+ }
203
+ if (!Number.isFinite(backoffMultiplier) || backoffMultiplier < 1) {
204
+ throw new TypeError('backoffMultiplier must be at least 1')
205
+ }
206
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
207
+ throw new TypeError('timeoutMs must be a positive number')
208
+ }
209
+
210
+ const waiting = createWaitSignal(options.signal, timeoutMs, taskId)
211
+ try {
212
+ let task = typeof taskOrId === 'string'
213
+ ? await this.getTask(taskId, { signal: waiting.signal })
214
+ : taskOrId
215
+ let intervalMs = pollIntervalMs
216
+
217
+ while (!TERMINAL_TASK_STATES.has(task?.status?.state)) {
218
+ if (!isObject(task) || !isObject(task.status) || typeof task.status.state !== 'string') {
219
+ throw new A2AError(`A2A task "${taskId}" has an invalid status`, {
220
+ code: 'INVALID_TASK'
221
+ })
222
+ }
223
+
224
+ await waitForDelay(intervalMs, waiting.signal)
225
+ task = await this.getTask(taskId, { signal: waiting.signal })
226
+ intervalMs = Math.min(maxPollIntervalMs, intervalMs * backoffMultiplier)
227
+ }
228
+
229
+ return task
230
+ } finally {
231
+ waiting.cleanup()
232
+ }
233
+ }
234
+
235
+ async #fetch(url, options, description) {
236
+ try {
237
+ const response = await this.fetch(url, options)
238
+ throwIfAborted(options.signal)
239
+ return response
240
+ } catch (error) {
241
+ if (options.signal?.aborted) throw abortError(options.signal.reason)
242
+ if (error instanceof A2AError) throw error
243
+ throw new A2AError(`A2A ${description} failed`, {
244
+ code: 'NETWORK_ERROR',
245
+ cause: error
246
+ })
247
+ }
248
+ }
249
+
250
+ async #readJson(response, description, code) {
251
+ try {
252
+ return await response.json()
253
+ } catch (error) {
254
+ throw new A2AError(`${description} response is not valid JSON`, {
255
+ code,
256
+ cause: error
257
+ })
258
+ }
259
+ }
260
+
261
+ async #request(method, params, options = {}) {
262
+ if (!this.endpoint) {
263
+ throw new A2AError('Call discover() before making A2A requests', {
264
+ code: 'NOT_DISCOVERED'
265
+ })
266
+ }
267
+ throwIfAborted(options.signal)
268
+
269
+ const requestId = ++this.requestId
270
+ const response = await this.#fetch(this.endpoint.href, {
271
+ method: 'POST',
272
+ headers: {
273
+ 'content-type': 'application/json',
274
+ ...this.headers
275
+ },
276
+ body: JSON.stringify({
277
+ jsonrpc: '2.0',
278
+ id: requestId,
279
+ method,
280
+ params
281
+ }),
282
+ signal: options.signal
283
+ }, `${method} request`)
284
+ if (!response.ok) {
285
+ throw new A2AError(`A2A ${method} request failed with HTTP ${response.status}`, {
286
+ code: 'HTTP_ERROR',
287
+ status: response.status
288
+ })
289
+ }
290
+
291
+ const payload = await this.#readJson(response, `A2A ${method}`, 'INVALID_RPC_RESPONSE')
292
+ throwIfAborted(options.signal)
293
+ if (
294
+ !isObject(payload)
295
+ || payload.jsonrpc !== '2.0'
296
+ || payload.id !== requestId
297
+ || (!Object.hasOwn(payload, 'result') && !Object.hasOwn(payload, 'error'))
298
+ ) {
299
+ throw new A2AError(`Invalid JSON-RPC response for A2A method "${method}"`, {
300
+ code: 'INVALID_RPC_RESPONSE'
301
+ })
302
+ }
303
+ if (payload.error) {
304
+ if (!isObject(payload.error)) {
305
+ throw new A2AError(`Invalid JSON-RPC error for A2A method "${method}"`, {
306
+ code: 'INVALID_RPC_RESPONSE'
307
+ })
308
+ }
309
+ throw new A2AError(payload.error.message ?? 'A2A JSON-RPC request failed', {
310
+ code: 'REMOTE_ERROR',
311
+ remoteCode: payload.error.code,
312
+ data: payload.error.data
313
+ })
314
+ }
315
+
316
+ return payload.result
317
+ }
318
+ }
319
+
320
+ export function createA2AClient(agentUrl, options = {}) {
321
+ return new A2AClient(agentUrl, options)
322
+ }