@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.
- package/README.md +848 -25
- package/bin/jojo.js +257 -23
- package/package.json +2 -2
- package/src/check.js +253 -0
- package/src/compiler.js +36 -37
- package/src/contracts.js +124 -0
- package/src/describe.js +63 -0
- package/src/effect-parser.js +4 -2
- package/src/formatter.js +105 -0
- package/src/index.js +31 -0
- package/src/match-parser.js +212 -14
- package/src/named-pipelines.js +87 -0
- package/src/parse.js +124 -0
- package/src/pipeline-explain.js +132 -0
- package/src/pipeline-ir.js +9 -7
- package/src/pipeline-parser.js +24 -2
- package/src/pipeline-plan.js +203 -0
- package/src/purity.js +101 -0
- package/src/runtime/a2a.js +322 -0
- package/src/runtime/checkpoint-store.js +26 -5
- package/src/runtime/checkpoint.js +3 -2
- package/src/runtime/effects.js +136 -2
- package/src/runtime/errors.js +14 -0
- package/src/runtime/index.js +15 -3
- package/src/runtime/mcp.js +237 -35
- package/src/runtime/serialization.js +97 -0
- package/src/runtime/tagged-unions.js +29 -0
- package/src/simulate.js +156 -0
- package/src/type-declarations.js +83 -0
- package/src/why.js +146 -0
package/src/pipeline-parser.js
CHANGED
|
@@ -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(
|
|
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
|
+
}
|