@hivemagnus/ardent 0.0.0-stage → 1.0.1
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/LICENSE +202 -0
- package/README.md +274 -2
- package/dist/a2a-client.d.ts +35 -0
- package/dist/a2a-client.js +172 -0
- package/dist/a2a-hermes-alias.d.ts +3 -0
- package/dist/a2a-hermes-alias.js +57 -0
- package/dist/a2a-idempotency.d.ts +13 -0
- package/dist/a2a-idempotency.js +86 -0
- package/dist/a2a-peers.d.ts +28 -0
- package/dist/a2a-peers.js +108 -0
- package/dist/a2a-push-inbox.d.ts +22 -0
- package/dist/a2a-push-inbox.js +84 -0
- package/dist/a2a-push.d.ts +27 -0
- package/dist/a2a-push.js +188 -0
- package/dist/a2a-server.d.ts +37 -0
- package/dist/a2a-server.js +186 -0
- package/dist/a2a-store.d.ts +10 -0
- package/dist/a2a-store.js +122 -0
- package/dist/a2a.d.ts +19 -0
- package/dist/a2a.js +466 -0
- package/dist/api.d.ts +6 -0
- package/dist/api.js +381 -0
- package/dist/artifacts.d.ts +15 -0
- package/dist/artifacts.js +41 -0
- package/dist/backend-events.d.ts +49 -0
- package/dist/backend-events.js +8 -0
- package/dist/channels.d.ts +52 -0
- package/dist/channels.js +184 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +845 -0
- package/dist/codex-output-budget.d.ts +17 -0
- package/dist/codex-output-budget.js +31 -0
- package/dist/codex-output-budget.test.d.ts +1 -0
- package/dist/codex-output-budget.test.js +29 -0
- package/dist/codex.d.ts +82 -0
- package/dist/codex.js +431 -0
- package/dist/config.d.ts +171 -0
- package/dist/config.js +244 -0
- package/dist/context-checkpoints.d.ts +49 -0
- package/dist/context-checkpoints.js +248 -0
- package/dist/context.d.ts +16 -0
- package/dist/context.js +101 -0
- package/dist/gateway-lock.d.ts +18 -0
- package/dist/gateway-lock.js +104 -0
- package/dist/hermes-compat.d.ts +15 -0
- package/dist/hermes-compat.js +62 -0
- package/dist/identity.d.ts +15 -0
- package/dist/identity.js +52 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/job-engine.d.ts +222 -0
- package/dist/job-engine.js +497 -0
- package/dist/job-news-handlers.d.ts +54 -0
- package/dist/job-news-handlers.js +153 -0
- package/dist/job-ownership.d.ts +9 -0
- package/dist/job-ownership.js +17 -0
- package/dist/json-schema.d.ts +9 -0
- package/dist/json-schema.js +20 -0
- package/dist/llamacpp.d.ts +49 -0
- package/dist/llamacpp.js +216 -0
- package/dist/magnus-control.d.ts +3 -0
- package/dist/magnus-control.js +99 -0
- package/dist/magnus-node-dependency-impact.d.ts +1 -0
- package/dist/magnus-node-dependency-impact.js +126 -0
- package/dist/magnus-owner-reads.d.ts +4 -0
- package/dist/magnus-owner-reads.js +99 -0
- package/dist/magnus-readiness.d.ts +1 -0
- package/dist/magnus-readiness.js +108 -0
- package/dist/mattermost.d.ts +18 -0
- package/dist/mattermost.js +303 -0
- package/dist/memory-tools.d.ts +5 -0
- package/dist/memory-tools.js +49 -0
- package/dist/memory.d.ts +40 -0
- package/dist/memory.js +128 -0
- package/dist/model-policy.d.ts +64 -0
- package/dist/model-policy.js +56 -0
- package/dist/news.d.ts +10 -0
- package/dist/news.js +291 -0
- package/dist/owner-read.d.ts +32 -0
- package/dist/owner-read.js +130 -0
- package/dist/plugins.d.ts +74 -0
- package/dist/plugins.js +115 -0
- package/dist/research.d.ts +110 -0
- package/dist/research.js +164 -0
- package/dist/runtime.d.ts +33 -0
- package/dist/runtime.js +665 -0
- package/dist/skills.d.ts +13 -0
- package/dist/skills.js +87 -0
- package/dist/store.d.ts +142 -0
- package/dist/store.js +322 -0
- package/dist/team-artifacts.d.ts +138 -0
- package/dist/team-artifacts.js +241 -0
- package/dist/team-child.d.ts +21 -0
- package/dist/team-child.js +130 -0
- package/dist/team-contract.d.ts +116 -0
- package/dist/team-contract.js +84 -0
- package/dist/team-control.d.ts +8 -0
- package/dist/team-control.js +152 -0
- package/dist/telegram.d.ts +35 -0
- package/dist/telegram.js +284 -0
- package/dist/tools.d.ts +48 -0
- package/dist/tools.js +58 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +5 -0
- package/model-policy.default.json +20 -0
- package/package.json +84 -4
- package/profiles/assistant/IDENTITY.md +9 -0
- package/profiles/assistant/SOUL.md +8 -0
- package/profiles/assistant/config.yaml +88 -0
- package/profiles/assistant/peers.yaml +12 -0
- package/profiles/assistant/skills/grounded-answers/SKILL.md +18 -0
- package/protocol/LICENSE-A2A.txt +202 -0
- package/protocol/README.md +12 -0
- package/protocol/a2a.proto +811 -0
- package/protocol/lock.json +9 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { Role, TaskState } from '@a2a-js/sdk';
|
|
3
|
+
import { ClientFactory, ClientFactoryOptions, DefaultAgentCardResolver, JsonRpcTransportFactory, RestTransportFactory } from '@a2a-js/sdk/client';
|
|
4
|
+
import { GrpcTransportFactory } from '@a2a-js/sdk/client/grpc';
|
|
5
|
+
import { credentials } from '@grpc/grpc-js';
|
|
6
|
+
import { loadPeers, outboundTokenEnv } from './a2a-peers.js';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
export async function fetchHermesTeamCard(card, authorization, signal) {
|
|
9
|
+
const interfaceUrl = card.supportedInterfaces.find(item => item.protocolBinding.toUpperCase() === 'JSONRPC' && item.protocolVersion === '1.0')?.url;
|
|
10
|
+
if (!interfaceUrl)
|
|
11
|
+
throw new Error('Hermes peer has no JSON-RPC 1.0 interface');
|
|
12
|
+
const url = new URL('/.well-known/hermes-team-card.json', interfaceUrl);
|
|
13
|
+
const response = await fetch(url, { redirect: 'manual', headers: { Authorization: authorization, Accept: 'application/json' }, signal: signal ?? AbortSignal.timeout(10_000) });
|
|
14
|
+
if (!response.ok)
|
|
15
|
+
throw new Error(`Hermes team card HTTP ${response.status}`);
|
|
16
|
+
const encoded = await response.text();
|
|
17
|
+
if (Buffer.byteLength(encoded) > 256_000)
|
|
18
|
+
throw new Error('Hermes team card exceeds bound');
|
|
19
|
+
const raw = JSON.parse(encoded);
|
|
20
|
+
return z.record(z.string(), z.unknown()).parse(raw);
|
|
21
|
+
}
|
|
22
|
+
export class ArdentA2AClient {
|
|
23
|
+
profileDir;
|
|
24
|
+
config;
|
|
25
|
+
env;
|
|
26
|
+
pushInbox;
|
|
27
|
+
preferredTransports;
|
|
28
|
+
constructor(profileDir, config, env = process.env, pushInbox, preferredTransports = ['JSONRPC', 'HTTP+JSON', 'GRPC']) {
|
|
29
|
+
this.profileDir = profileDir;
|
|
30
|
+
this.config = config;
|
|
31
|
+
this.env = env;
|
|
32
|
+
this.pushInbox = pushInbox;
|
|
33
|
+
this.preferredTransports = preferredTransports;
|
|
34
|
+
}
|
|
35
|
+
async open(peerId) {
|
|
36
|
+
const { peers } = await loadPeers(this.profileDir, this.config, this.env);
|
|
37
|
+
const peer = peers.find(item => item.id === peerId && item.enabled);
|
|
38
|
+
if (!peer)
|
|
39
|
+
throw new Error(`A2A peer is not configured: ${peerId}`);
|
|
40
|
+
const token = this.env[outboundTokenEnv(peer)];
|
|
41
|
+
if (!token)
|
|
42
|
+
throw new Error(`A2A outbound credential is missing: ${outboundTokenEnv(peer)}`);
|
|
43
|
+
const base = new URL(peer.url);
|
|
44
|
+
const basePort = Number(base.port || (base.protocol === 'https:' ? 443 : 80));
|
|
45
|
+
const allowedPorts = new Set([basePort, ...peer.transport_ports]);
|
|
46
|
+
const sameHost = (url, grpc) => {
|
|
47
|
+
const parsed = new URL(grpc ? `http://${url}` : url);
|
|
48
|
+
const port = Number(parsed.port || (parsed.protocol === 'https:' ? 443 : 80));
|
|
49
|
+
return parsed.hostname === base.hostname && !parsed.username && !parsed.password && !parsed.hash && allowedPorts.has(port) &&
|
|
50
|
+
(grpc ? Boolean(parsed.port) : (base.protocol !== 'https:' || parsed.protocol === 'https:'));
|
|
51
|
+
};
|
|
52
|
+
const safeFetch = (input, init) => {
|
|
53
|
+
const url = new URL(input instanceof Request ? input.url : String(input));
|
|
54
|
+
if (!sameHost(url.href, false))
|
|
55
|
+
throw new Error('A2A peer card redirected to an unconfigured host or port');
|
|
56
|
+
return fetch(input, { ...init, redirect: 'manual', signal: init?.signal ?? AbortSignal.timeout(30000) });
|
|
57
|
+
};
|
|
58
|
+
const resolver = new DefaultAgentCardResolver({ fetchImpl: safeFetch, legacyCompat: { enabled: true } });
|
|
59
|
+
const card = await resolver.resolve(peer.url);
|
|
60
|
+
if (!card.supportedInterfaces.length)
|
|
61
|
+
throw new Error('A2A peer card has no interfaces');
|
|
62
|
+
for (const iface of card.supportedInterfaces)
|
|
63
|
+
if (!sameHost(iface.url, iface.protocolBinding.toUpperCase() === 'GRPC'))
|
|
64
|
+
throw new Error('A2A peer card advertises an unconfigured host');
|
|
65
|
+
const factory = new ClientFactory(ClientFactoryOptions.createFrom(ClientFactoryOptions.default, {
|
|
66
|
+
cardResolver: resolver,
|
|
67
|
+
transports: [new JsonRpcTransportFactory({ fetchImpl: safeFetch, legacyCompat: { enabled: true } }), new RestTransportFactory({ fetchImpl: safeFetch, legacyCompat: { enabled: true } }), new GrpcTransportFactory({ legacyCompat: { enabled: true }, grpcChannelCredentials: base.protocol === 'https:' ? credentials.createSsl() : credentials.createInsecure() })],
|
|
68
|
+
preferredTransports: this.preferredTransports
|
|
69
|
+
}));
|
|
70
|
+
return { card, client: await factory.createFromAgentCard(card), authorization: `Bearer ${token}` };
|
|
71
|
+
}
|
|
72
|
+
async teamControl(peerId, control) {
|
|
73
|
+
const { card, authorization } = await this.open(peerId);
|
|
74
|
+
const rawCard = await fetchHermesTeamCard(card, authorization);
|
|
75
|
+
const advertised = z.object({ metadata: z.object({ hermesTeam: z.object({ coordinationProtocolVersions: z.array(z.string()) }).passthrough() }).passthrough() }).passthrough().safeParse(rawCard);
|
|
76
|
+
const metadata = advertised.success ? advertised.data.metadata : undefined;
|
|
77
|
+
if (!metadata?.hermesTeam.coordinationProtocolVersions.includes('1.0'))
|
|
78
|
+
throw new Error('A2A peer does not advertise Hermes coordination 1.0');
|
|
79
|
+
const interfaceUrl = card.supportedInterfaces.find(iface => iface.protocolBinding.toUpperCase() === 'JSONRPC' && iface.protocolVersion === '1.0')?.url;
|
|
80
|
+
if (!interfaceUrl)
|
|
81
|
+
throw new Error('Hermes coordination peer has no JSON-RPC 1.0 interface');
|
|
82
|
+
const messageId = `ctl-${randomUUID()}`;
|
|
83
|
+
// Hermes's accepted control wire requires one exact text part. The SDK's
|
|
84
|
+
// canonical serializer adds mediaType, so this vendor edge is intentionally
|
|
85
|
+
// encoded separately from ordinary standards-compliant SendMessage calls.
|
|
86
|
+
const wire = { jsonrpc: '2.0', id: messageId, method: 'SendMessage', params: { message: {
|
|
87
|
+
messageId, contextId: messageId, role: 'ROLE_USER', parts: [{ text: 'HERMES_COORDINATION_CONTROL' }],
|
|
88
|
+
metadata: { hermesTeamControl: control }
|
|
89
|
+
}, configuration: { blocking: false } } };
|
|
90
|
+
const response = await fetch(interfaceUrl, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(30_000),
|
|
91
|
+
headers: { Authorization: authorization, 'A2A-Version': '1.0', 'Content-Type': 'application/json' }, body: JSON.stringify(wire) });
|
|
92
|
+
if (!response.ok)
|
|
93
|
+
throw new Error(`Hermes coordination HTTP ${response.status}`);
|
|
94
|
+
const raw = await response.json();
|
|
95
|
+
const rpc = z.object({ jsonrpc: z.literal('2.0'), id: z.string(), result: z.unknown().optional(),
|
|
96
|
+
error: z.object({ message: z.string() }).passthrough().optional() }).passthrough().parse(raw);
|
|
97
|
+
if (rpc.id !== messageId)
|
|
98
|
+
throw new Error('Hermes coordination request ID differs');
|
|
99
|
+
if (rpc.error)
|
|
100
|
+
throw new Error(`Hermes coordination rejected the control: ${rpc.error.message}`);
|
|
101
|
+
const task = z.object({ artifacts: z.array(z.object({ parts: z.array(z.object({ text: z.string().optional() }).passthrough()) }).passthrough()) }).passthrough()
|
|
102
|
+
.safeParse(rpc.result);
|
|
103
|
+
if (!task.success)
|
|
104
|
+
throw new Error('Hermes coordination returned no task receipt');
|
|
105
|
+
const texts = task.data.artifacts.flatMap(artifact => artifact.parts.flatMap(part => part.text ? [part.text] : []));
|
|
106
|
+
if (texts.length !== 1)
|
|
107
|
+
throw new Error('Hermes coordination receipt is missing or ambiguous');
|
|
108
|
+
const receipt = JSON.parse(texts[0] ?? '');
|
|
109
|
+
const parsed = z.object({ version: z.literal('1.0'), request_digest: z.string().regex(/^[a-f0-9]{64}$/), data: z.record(z.string(), z.unknown()) }).parse(receipt);
|
|
110
|
+
return parsed;
|
|
111
|
+
}
|
|
112
|
+
async send(peerId, prompt, signal, contextId, taskId) {
|
|
113
|
+
return (await this.sendWithReceipt(peerId, prompt, signal, contextId, taskId)).result;
|
|
114
|
+
}
|
|
115
|
+
async getTask(peerId, id, historyLength) {
|
|
116
|
+
const { client, authorization } = await this.open(peerId);
|
|
117
|
+
return client.getTask({ tenant: '', id, ...(historyLength === undefined ? {} : { historyLength }) }, { serviceParameters: { Authorization: authorization, 'A2A-Version': '1.0' } });
|
|
118
|
+
}
|
|
119
|
+
async listTasks(peerId, pageSize = 50, pageToken = '', contextId = '') {
|
|
120
|
+
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100)
|
|
121
|
+
throw new Error('A2A page size must be 1 to 100');
|
|
122
|
+
const { client, authorization } = await this.open(peerId);
|
|
123
|
+
return client.listTasks({ tenant: '', contextId, status: TaskState.TASK_STATE_UNSPECIFIED, pageSize, pageToken, statusTimestampAfter: undefined }, { serviceParameters: { Authorization: authorization, 'A2A-Version': '1.0' } });
|
|
124
|
+
}
|
|
125
|
+
async cancelTask(peerId, id) {
|
|
126
|
+
const { client, authorization } = await this.open(peerId);
|
|
127
|
+
return client.cancelTask({ tenant: '', id, metadata: undefined }, { serviceParameters: { Authorization: authorization, 'A2A-Version': '1.0' } });
|
|
128
|
+
}
|
|
129
|
+
async extendedCard(peerId) {
|
|
130
|
+
const { card, client, authorization } = await this.open(peerId);
|
|
131
|
+
if (!card.capabilities?.extendedAgentCard)
|
|
132
|
+
throw new Error('A2A peer does not advertise an extended Agent Card');
|
|
133
|
+
return client.getAgentCard({ serviceParameters: { Authorization: authorization, 'A2A-Version': '1.0' } });
|
|
134
|
+
}
|
|
135
|
+
async *subscribe(peerId, id, signal) {
|
|
136
|
+
const { client, authorization } = await this.open(peerId);
|
|
137
|
+
yield* client.resubscribeTask({ tenant: '', id }, { serviceParameters: { Authorization: authorization, 'A2A-Version': '1.0' }, ...(signal ? { signal } : {}) });
|
|
138
|
+
}
|
|
139
|
+
async *sendStreaming(peerId, prompt, signal) {
|
|
140
|
+
if (!prompt.trim() || prompt.length > 16000)
|
|
141
|
+
throw new Error('A2A prompt must be 1 to 16000 characters');
|
|
142
|
+
const { client, authorization } = await this.open(peerId);
|
|
143
|
+
const message = { messageId: randomUUID(), contextId: '', taskId: '', role: Role.ROLE_USER, parts: [{ content: { $case: 'text', value: prompt }, metadata: undefined, filename: '', mediaType: 'text/plain' }], metadata: {}, extensions: [], referenceTaskIds: [] };
|
|
144
|
+
yield* client.sendMessageStream({ tenant: '', message, configuration: undefined, metadata: undefined }, { serviceParameters: { Authorization: authorization, 'A2A-Version': '1.0' }, ...(signal ? { signal } : {}) });
|
|
145
|
+
}
|
|
146
|
+
async sendWithReceipt(peerId, prompt, signal, contextId, taskId) {
|
|
147
|
+
if (!prompt.trim() || prompt.length > 16000)
|
|
148
|
+
throw new Error('A2A prompt must be 1 to 16000 characters');
|
|
149
|
+
const { card, client, authorization } = await this.open(peerId);
|
|
150
|
+
const message = { messageId: randomUUID(), contextId: contextId ?? '', taskId: taskId ?? '', role: Role.ROLE_USER, parts: [{ content: { $case: 'text', value: prompt }, metadata: undefined, filename: '', mediaType: 'text/plain' }], metadata: {}, extensions: [], referenceTaskIds: [] };
|
|
151
|
+
const registered = this.pushInbox && card.capabilities?.pushNotifications ? this.pushInbox.register(peerId, message.messageId) : undefined;
|
|
152
|
+
const request = { tenant: '', message, configuration: registered ? {
|
|
153
|
+
acceptedOutputModes: ['text/plain', 'application/json'], returnImmediately: false,
|
|
154
|
+
taskPushNotificationConfig: { tenant: '', id: '', taskId: '', url: registered.url, token: registered.token, authentication: undefined }
|
|
155
|
+
} : undefined, metadata: undefined };
|
|
156
|
+
try {
|
|
157
|
+
const result = await client.sendMessage(request, { serviceParameters: { Authorization: authorization, 'A2A-Version': '1.0' }, ...(signal ? { signal } : {}) });
|
|
158
|
+
if (registered) {
|
|
159
|
+
if ('id' in result && typeof result.id === 'string')
|
|
160
|
+
this.pushInbox?.bind(registered.id, result.id);
|
|
161
|
+
else
|
|
162
|
+
this.pushInbox?.markFailed(registered.id);
|
|
163
|
+
}
|
|
164
|
+
return { result, ...(registered ? { outboundId: registered.id } : {}) };
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
if (registered)
|
|
168
|
+
this.pushInbox?.markFailed(registered.id);
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const aliases = {
|
|
3
|
+
'message/send': 'SendMessage',
|
|
4
|
+
'message/stream': 'SendStreamingMessage',
|
|
5
|
+
'tasks/get': 'GetTask',
|
|
6
|
+
'tasks/list': 'ListTasks',
|
|
7
|
+
'tasks/cancel': 'CancelTask',
|
|
8
|
+
'tasks/subscribe': 'SubscribeToTask',
|
|
9
|
+
'tasks/pushNotificationConfig/create': 'CreateTaskPushNotificationConfig',
|
|
10
|
+
'tasks/pushNotificationConfig/set': 'CreateTaskPushNotificationConfig',
|
|
11
|
+
'tasks/pushNotification/set': 'CreateTaskPushNotificationConfig',
|
|
12
|
+
'tasks/pushNotificationConfig/get': 'GetTaskPushNotificationConfig',
|
|
13
|
+
'tasks/pushNotificationConfig/list': 'ListTaskPushNotificationConfigs',
|
|
14
|
+
'tasks/pushNotificationConfig/delete': 'DeleteTaskPushNotificationConfig'
|
|
15
|
+
};
|
|
16
|
+
const Rpc = z.object({ jsonrpc: z.literal('2.0'), id: z.union([z.string(), z.number()]), method: z.string(), params: z.unknown().optional() }).passthrough();
|
|
17
|
+
const RecordValue = z.record(z.string(), z.unknown());
|
|
18
|
+
/** Accepted Hermes slash aliases are translated at the wire edge only. */
|
|
19
|
+
export const hermesAlias = (req, res, next) => {
|
|
20
|
+
if (req.header('A2A-Version') !== '1.0') {
|
|
21
|
+
next();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const parsed = Rpc.safeParse(req.body);
|
|
25
|
+
if (!parsed.success) {
|
|
26
|
+
next();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const method = aliases[parsed.data.method];
|
|
30
|
+
if (!method) {
|
|
31
|
+
next();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
let params = parsed.data.params;
|
|
35
|
+
if (method === 'CreateTaskPushNotificationConfig') {
|
|
36
|
+
const outer = RecordValue.safeParse(params);
|
|
37
|
+
const inner = outer.success ? RecordValue.safeParse(outer.data.pushNotificationConfig ?? outer.data.config) : undefined;
|
|
38
|
+
if (outer?.success && inner?.success)
|
|
39
|
+
params = { ...inner.data, taskId: outer.data.taskId, tenant: outer.data.tenant ?? '' };
|
|
40
|
+
}
|
|
41
|
+
req.body = { ...parsed.data, method, params };
|
|
42
|
+
if (parsed.data.method === 'message/send' || parsed.data.method === 'tasks/pushNotificationConfig/delete') {
|
|
43
|
+
const originalJson = res.json.bind(res);
|
|
44
|
+
res.json = ((value) => {
|
|
45
|
+
const response = RecordValue.safeParse(value);
|
|
46
|
+
if (!response.success || !('result' in response.data))
|
|
47
|
+
return originalJson(value);
|
|
48
|
+
if (parsed.data.method === 'tasks/pushNotificationConfig/delete')
|
|
49
|
+
return originalJson({ ...response.data, result: { deleted: true } });
|
|
50
|
+
const result = RecordValue.safeParse(response.data.result);
|
|
51
|
+
if (!result.success)
|
|
52
|
+
return originalJson(value);
|
|
53
|
+
return originalJson({ ...response.data, result: result.data.task ?? result.data.message ?? response.data.result });
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
next();
|
|
57
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Message, type SendMessageRequest, type StreamResponse, type Task } from '@a2a-js/sdk';
|
|
2
|
+
import { DefaultRequestHandler, type ServerCallContext } from '@a2a-js/sdk/server';
|
|
3
|
+
import type Database from 'better-sqlite3';
|
|
4
|
+
import type { SqliteA2ATaskStore } from './a2a-store.js';
|
|
5
|
+
/** Binds transport retries to the original task before invoking the executor. */
|
|
6
|
+
export declare class IdempotentA2AHandler extends DefaultRequestHandler {
|
|
7
|
+
#private;
|
|
8
|
+
readonly db: Database.Database;
|
|
9
|
+
readonly durableStore: SqliteA2ATaskStore;
|
|
10
|
+
constructor(db: Database.Database, durableStore: SqliteA2ATaskStore, ...args: ConstructorParameters<typeof DefaultRequestHandler>);
|
|
11
|
+
sendMessage(params: SendMessageRequest, context: ServerCallContext): Promise<Task | Message>;
|
|
12
|
+
sendMessageStream(params: SendMessageRequest, context: ServerCallContext): AsyncGenerator<StreamResponse, void, undefined>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { Message, TaskState } from '@a2a-js/sdk';
|
|
3
|
+
import { RequestMalformedError, UnsupportedOperationError } from '@a2a-js/sdk/errors';
|
|
4
|
+
import { DefaultRequestHandler } from '@a2a-js/sdk/server';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
const Receipt = z.object({ request_sha256: z.string(), task_id: z.string().nullable() });
|
|
7
|
+
const terminal = new Set([TaskState.TASK_STATE_COMPLETED, TaskState.TASK_STATE_FAILED, TaskState.TASK_STATE_CANCELED, TaskState.TASK_STATE_REJECTED]);
|
|
8
|
+
/** Binds transport retries to the original task before invoking the executor. */
|
|
9
|
+
export class IdempotentA2AHandler extends DefaultRequestHandler {
|
|
10
|
+
db;
|
|
11
|
+
durableStore;
|
|
12
|
+
constructor(db, durableStore, ...args) {
|
|
13
|
+
super(...args);
|
|
14
|
+
this.db = db;
|
|
15
|
+
this.durableStore = durableStore;
|
|
16
|
+
}
|
|
17
|
+
#scope(context) {
|
|
18
|
+
if (!context.user?.isAuthenticated || !context.user.userName)
|
|
19
|
+
throw new RequestMalformedError('Authenticated A2A caller required');
|
|
20
|
+
return { tenant: context.tenant ?? '', caller: context.user.userName };
|
|
21
|
+
}
|
|
22
|
+
#claim(params, context) {
|
|
23
|
+
const { tenant, caller } = this.#scope(context);
|
|
24
|
+
const message = params.message;
|
|
25
|
+
const messageId = message?.messageId;
|
|
26
|
+
if (!messageId || messageId.length > 128)
|
|
27
|
+
throw new RequestMalformedError('A bounded message.messageId is required');
|
|
28
|
+
if (!message)
|
|
29
|
+
throw new RequestMalformedError('message is required');
|
|
30
|
+
const digest = createHash('sha256').update(JSON.stringify({ message: Message.toJSON(message), configuration: params.configuration, metadata: params.metadata })).digest('hex');
|
|
31
|
+
return this.db.transaction(() => {
|
|
32
|
+
const inserted = this.db.prepare('INSERT OR IGNORE INTO a2a_request_receipts(tenant,caller,message_id,request_sha256,task_id,created_ms) VALUES (?,?,?,?,NULL,?)')
|
|
33
|
+
.run(tenant, caller, messageId, digest, Date.now());
|
|
34
|
+
const row = Receipt.parse(this.db.prepare('SELECT request_sha256,task_id FROM a2a_request_receipts WHERE tenant=? AND caller=? AND message_id=?').get(tenant, caller, messageId));
|
|
35
|
+
if (row.request_sha256 !== digest)
|
|
36
|
+
throw new RequestMalformedError('messageId reused with different A2A request content');
|
|
37
|
+
return { prior: inserted.changes === 0, tenant, caller, messageId };
|
|
38
|
+
})();
|
|
39
|
+
}
|
|
40
|
+
#bind(receipt, taskId) {
|
|
41
|
+
this.db.prepare('UPDATE a2a_request_receipts SET task_id=? WHERE tenant=? AND caller=? AND message_id=? AND (task_id IS NULL OR task_id=?)')
|
|
42
|
+
.run(taskId, receipt.tenant, receipt.caller, receipt.messageId, taskId);
|
|
43
|
+
}
|
|
44
|
+
async #original(receipt, context) {
|
|
45
|
+
const until = Date.now() + 3000;
|
|
46
|
+
while (Date.now() < until) {
|
|
47
|
+
const row = Receipt.parse(this.db.prepare('SELECT request_sha256,task_id FROM a2a_request_receipts WHERE tenant=? AND caller=? AND message_id=?')
|
|
48
|
+
.get(receipt.tenant, receipt.caller, receipt.messageId));
|
|
49
|
+
if (row.task_id) {
|
|
50
|
+
const task = await this.durableStore.load(row.task_id, context);
|
|
51
|
+
if (task)
|
|
52
|
+
return task;
|
|
53
|
+
}
|
|
54
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
55
|
+
}
|
|
56
|
+
throw new UnsupportedOperationError('Original A2A admission outcome is unresolved; inspect the receipt before retrying');
|
|
57
|
+
}
|
|
58
|
+
async sendMessage(params, context) {
|
|
59
|
+
if ('hermesTeamReport' in (params.message?.metadata ?? {}))
|
|
60
|
+
throw new RequestMalformedError('Report intake is not supported by this profile');
|
|
61
|
+
const receipt = this.#claim(params, context);
|
|
62
|
+
if (receipt.prior)
|
|
63
|
+
return this.#original(receipt, context);
|
|
64
|
+
const result = await super.sendMessage(params, context);
|
|
65
|
+
if ('id' in result)
|
|
66
|
+
this.#bind(receipt, result.id);
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
async *sendMessageStream(params, context) {
|
|
70
|
+
if ('hermesTeamReport' in (params.message?.metadata ?? {}))
|
|
71
|
+
throw new UnsupportedOperationError('Report intake requires a terminal SendMessage receipt');
|
|
72
|
+
const receipt = this.#claim(params, context);
|
|
73
|
+
if (receipt.prior) {
|
|
74
|
+
const task = await this.#original(receipt, context);
|
|
75
|
+
yield { payload: { $case: 'task', value: task } };
|
|
76
|
+
if (task.status && !terminal.has(task.status.state))
|
|
77
|
+
yield* this.resubscribe({ tenant: context.tenant ?? '', id: task.id }, context);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
for await (const event of super.sendMessageStream(params, context)) {
|
|
81
|
+
if (event.payload?.$case === 'task')
|
|
82
|
+
this.#bind(receipt, event.payload.value.id);
|
|
83
|
+
yield event;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ArdentConfig } from './config.js';
|
|
3
|
+
declare const Peer: z.ZodObject<{
|
|
4
|
+
id: z.ZodString;
|
|
5
|
+
url: z.ZodString;
|
|
6
|
+
token_env: z.ZodOptional<z.ZodString>;
|
|
7
|
+
inbound_token_env: z.ZodOptional<z.ZodString>;
|
|
8
|
+
outbound_token_env: z.ZodOptional<z.ZodString>;
|
|
9
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
10
|
+
transport_ports: z.ZodDefault<z.ZodArray<z.ZodNumber>>;
|
|
11
|
+
callback_ports: z.ZodDefault<z.ZodArray<z.ZodNumber>>;
|
|
12
|
+
allow_private_callbacks: z.ZodDefault<z.ZodBoolean>;
|
|
13
|
+
}, z.core.$strict>;
|
|
14
|
+
export type PeerConfig = z.infer<typeof Peer>;
|
|
15
|
+
export declare function inboundTokenEnv(peer: PeerConfig): string;
|
|
16
|
+
export declare function outboundTokenEnv(peer: PeerConfig): string;
|
|
17
|
+
export declare function parsePeersConfig(text: string): PeerConfig[];
|
|
18
|
+
export declare function readPeersConfig(profileDir: string, config: ArdentConfig): Promise<{
|
|
19
|
+
text: string;
|
|
20
|
+
revision: string;
|
|
21
|
+
}>;
|
|
22
|
+
export declare function savePeersConfig(profileDir: string, config: ArdentConfig, text: string, expectedRevision: string): Promise<string>;
|
|
23
|
+
export declare function listPeers(profileDir: string, config: ArdentConfig): Promise<PeerConfig[]>;
|
|
24
|
+
export declare function loadPeers(profileDir: string, config: ArdentConfig, env?: NodeJS.ProcessEnv): Promise<{
|
|
25
|
+
peers: PeerConfig[];
|
|
26
|
+
authenticate: (authorization: string | undefined) => string | undefined;
|
|
27
|
+
}>;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { timingSafeEqual, randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFile, writeFile, open, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { revision } from './config.js';
|
|
7
|
+
const envName = z.string().regex(/^[A-Z][A-Z0-9_]*$/);
|
|
8
|
+
const Peer = z.object({ id: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/), url: z.string().url(),
|
|
9
|
+
token_env: envName.optional(), inbound_token_env: envName.optional(), outbound_token_env: envName.optional(),
|
|
10
|
+
enabled: z.boolean().default(true), transport_ports: z.array(z.number().int().min(1).max(65535)).default([]),
|
|
11
|
+
callback_ports: z.array(z.number().int().min(1).max(65535)).default([]), allow_private_callbacks: z.boolean().default(false)
|
|
12
|
+
}).strict().superRefine((peer, context) => {
|
|
13
|
+
const legacy = Boolean(peer.token_env);
|
|
14
|
+
const directional = Boolean(peer.inbound_token_env && peer.outbound_token_env);
|
|
15
|
+
if (legacy === directional || Boolean(peer.inbound_token_env) !== Boolean(peer.outbound_token_env))
|
|
16
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: 'Use one legacy token_env or both directional token references' });
|
|
17
|
+
if (directional && peer.inbound_token_env === peer.outbound_token_env)
|
|
18
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: 'Directional token references must differ' });
|
|
19
|
+
});
|
|
20
|
+
const Peers = z.object({ schema_version: z.literal(1), peers: z.array(Peer).max(100) }).strict();
|
|
21
|
+
export function inboundTokenEnv(peer) { return peer.inbound_token_env ?? peer.token_env ?? ''; }
|
|
22
|
+
export function outboundTokenEnv(peer) { return peer.outbound_token_env ?? peer.token_env ?? ''; }
|
|
23
|
+
export function parsePeersConfig(text) {
|
|
24
|
+
const document = YAML.parseDocument(text, { uniqueKeys: true });
|
|
25
|
+
if (document.errors.length)
|
|
26
|
+
throw new Error(`Invalid peer YAML: ${document.errors[0]?.message ?? 'unknown error'}`);
|
|
27
|
+
const peers = Peers.parse(document.toJS()).peers;
|
|
28
|
+
if (new Set(peers.map(peer => peer.id)).size !== peers.length)
|
|
29
|
+
throw new Error('Duplicate A2A peer ID');
|
|
30
|
+
for (const peer of peers) {
|
|
31
|
+
const url = new URL(peer.url);
|
|
32
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash || url.search)
|
|
33
|
+
throw new Error(`Invalid A2A peer URL: ${peer.id}`);
|
|
34
|
+
}
|
|
35
|
+
return peers;
|
|
36
|
+
}
|
|
37
|
+
export async function readPeersConfig(profileDir, config) {
|
|
38
|
+
const text = await readFile(join(profileDir, config.a2a.peers_file), 'utf8');
|
|
39
|
+
parsePeersConfig(text);
|
|
40
|
+
return { text, revision: revision(text) };
|
|
41
|
+
}
|
|
42
|
+
export async function savePeersConfig(profileDir, config, text, expectedRevision) {
|
|
43
|
+
if (Buffer.byteLength(text) > 200_000)
|
|
44
|
+
throw new Error('Peer configuration exceeds size limit');
|
|
45
|
+
parsePeersConfig(text);
|
|
46
|
+
const lockPath = join(profileDir, '.peers.write.lock');
|
|
47
|
+
const lock = await open(lockPath, 'wx', 0o600).catch((error) => {
|
|
48
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST')
|
|
49
|
+
throw new Error('Peer configuration edit is already in progress');
|
|
50
|
+
throw error;
|
|
51
|
+
});
|
|
52
|
+
let temporary;
|
|
53
|
+
try {
|
|
54
|
+
const current = await readPeersConfig(profileDir, config);
|
|
55
|
+
if (current.revision !== expectedRevision)
|
|
56
|
+
throw new Error('Peer configuration revision conflict');
|
|
57
|
+
temporary = join(profileDir, `.peers.${randomUUID()}.tmp`);
|
|
58
|
+
await writeFile(temporary, text, { flag: 'wx', mode: 0o600 });
|
|
59
|
+
await rename(temporary, join(profileDir, config.a2a.peers_file));
|
|
60
|
+
temporary = undefined;
|
|
61
|
+
return revision(text);
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
if (temporary)
|
|
65
|
+
await unlink(temporary).catch(() => { });
|
|
66
|
+
await lock.close();
|
|
67
|
+
await unlink(lockPath);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export async function listPeers(profileDir, config) {
|
|
71
|
+
return parsePeersConfig(await readFile(join(profileDir, config.a2a.peers_file), 'utf8'));
|
|
72
|
+
}
|
|
73
|
+
export async function loadPeers(profileDir, config, env = process.env) {
|
|
74
|
+
const peers = await listPeers(profileDir, config);
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
const credentials = [];
|
|
77
|
+
for (const peer of peers) {
|
|
78
|
+
if (seen.has(peer.id))
|
|
79
|
+
throw new Error(`Duplicate A2A peer id: ${peer.id}`);
|
|
80
|
+
seen.add(peer.id);
|
|
81
|
+
if (!peer.enabled)
|
|
82
|
+
continue;
|
|
83
|
+
const url = new URL(peer.url);
|
|
84
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.hash)
|
|
85
|
+
throw new Error(`Invalid A2A peer URL: ${peer.id}`);
|
|
86
|
+
const inbound = env[inboundTokenEnv(peer)];
|
|
87
|
+
const outbound = env[outboundTokenEnv(peer)];
|
|
88
|
+
if (!inbound || inbound.length < 16)
|
|
89
|
+
throw new Error(`A2A inbound credential is missing or too short: ${inboundTokenEnv(peer)}`);
|
|
90
|
+
if (!outbound || outbound.length < 16)
|
|
91
|
+
throw new Error(`A2A outbound credential is missing or too short: ${outboundTokenEnv(peer)}`);
|
|
92
|
+
if (peer.inbound_token_env && inbound === outbound)
|
|
93
|
+
throw new Error(`A2A directional credentials must differ: ${peer.id}`);
|
|
94
|
+
if (credentials.some(entry => entry.token.length === Buffer.byteLength(inbound) && timingSafeEqual(entry.token, Buffer.from(inbound))))
|
|
95
|
+
throw new Error(`A2A inbound credential is assigned to multiple peers: ${peer.id}`);
|
|
96
|
+
credentials.push({ id: peer.id, token: Buffer.from(inbound) });
|
|
97
|
+
}
|
|
98
|
+
return { peers, authenticate: authorization => {
|
|
99
|
+
const bearer = /^Bearer (.+)$/.exec(authorization ?? '')?.[1];
|
|
100
|
+
if (!bearer)
|
|
101
|
+
return undefined;
|
|
102
|
+
const candidate = Buffer.from(bearer);
|
|
103
|
+
for (const entry of credentials)
|
|
104
|
+
if (entry.token.length === candidate.length && timingSafeEqual(entry.token, candidate))
|
|
105
|
+
return entry.id;
|
|
106
|
+
return undefined;
|
|
107
|
+
} };
|
|
108
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
import { type StreamResponse } from '@a2a-js/sdk';
|
|
3
|
+
import type { PeerConfig } from './a2a-peers.js';
|
|
4
|
+
export declare class A2APushInbox {
|
|
5
|
+
readonly db: Database.Database;
|
|
6
|
+
readonly peers: PeerConfig[];
|
|
7
|
+
readonly publicUrl: string;
|
|
8
|
+
constructor(db: Database.Database, peers: PeerConfig[], publicUrl: string);
|
|
9
|
+
register(peerId: string, messageId: string): {
|
|
10
|
+
id: string;
|
|
11
|
+
token: string;
|
|
12
|
+
url: string;
|
|
13
|
+
};
|
|
14
|
+
bind(id: string, taskId: string): void;
|
|
15
|
+
markFailed(id: string): void;
|
|
16
|
+
accept(peerId: string, token: string | undefined, raw: unknown): {
|
|
17
|
+
duplicate: boolean;
|
|
18
|
+
outboundId: string;
|
|
19
|
+
taskId: string;
|
|
20
|
+
};
|
|
21
|
+
events(outboundId: string): StreamResponse[];
|
|
22
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { StreamResponse as StreamCodec } from '@a2a-js/sdk';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
const Outbound = z.object({ id: z.string(), peer_id: z.string(), task_id: z.string().nullable() });
|
|
5
|
+
const Count = z.object({ count: z.number() });
|
|
6
|
+
const EventRow = z.object({ event_json: z.string() });
|
|
7
|
+
function digest(value) { return createHash('sha256').update(value).digest('hex'); }
|
|
8
|
+
export class A2APushInbox {
|
|
9
|
+
db;
|
|
10
|
+
peers;
|
|
11
|
+
publicUrl;
|
|
12
|
+
constructor(db, peers, publicUrl) {
|
|
13
|
+
this.db = db;
|
|
14
|
+
this.peers = peers;
|
|
15
|
+
this.publicUrl = publicUrl;
|
|
16
|
+
db.exec(`CREATE TABLE IF NOT EXISTS a2a_outbound_calls (
|
|
17
|
+
id TEXT PRIMARY KEY, peer_id TEXT NOT NULL, message_id TEXT NOT NULL, task_id TEXT,
|
|
18
|
+
token_hash TEXT NOT NULL UNIQUE, state TEXT NOT NULL, created_ms INTEGER NOT NULL, updated_ms INTEGER NOT NULL);
|
|
19
|
+
CREATE TABLE IF NOT EXISTS a2a_inbound_push (
|
|
20
|
+
id TEXT PRIMARY KEY, outbound_id TEXT NOT NULL, task_id TEXT NOT NULL,
|
|
21
|
+
event_json TEXT NOT NULL, received_ms INTEGER NOT NULL);
|
|
22
|
+
CREATE INDEX IF NOT EXISTS a2a_inbound_push_outbound ON a2a_inbound_push(outbound_id,received_ms);`);
|
|
23
|
+
}
|
|
24
|
+
register(peerId, messageId) {
|
|
25
|
+
if (!this.peers.some(peer => peer.enabled && peer.id === peerId))
|
|
26
|
+
throw new Error('A2A push peer is not configured');
|
|
27
|
+
if (!messageId || messageId.length > 128)
|
|
28
|
+
throw new Error('A2A push message ID is invalid');
|
|
29
|
+
const url = new URL(`/a2a/push/${encodeURIComponent(peerId)}`, this.publicUrl);
|
|
30
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password)
|
|
31
|
+
throw new Error('A2A callback public URL is invalid');
|
|
32
|
+
const id = randomUUID();
|
|
33
|
+
const token = randomBytes(32).toString('base64url');
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
this.db.prepare('INSERT INTO a2a_outbound_calls(id,peer_id,message_id,task_id,token_hash,state,created_ms,updated_ms) VALUES (?,?,?,NULL,?,?,?,?)')
|
|
36
|
+
.run(id, peerId, messageId, digest(token), 'pending', now, now);
|
|
37
|
+
return { id, token, url: url.href };
|
|
38
|
+
}
|
|
39
|
+
bind(id, taskId) {
|
|
40
|
+
const row = this.db.prepare('SELECT id,peer_id,task_id FROM a2a_outbound_calls WHERE id=?').get(id);
|
|
41
|
+
if (!row)
|
|
42
|
+
throw new Error('A2A outbound call is missing');
|
|
43
|
+
const call = Outbound.parse(row);
|
|
44
|
+
if (!taskId || taskId.length > 256 || (call.task_id && call.task_id !== taskId))
|
|
45
|
+
throw new Error('A2A callback task mismatch');
|
|
46
|
+
this.db.prepare("UPDATE a2a_outbound_calls SET task_id=?,state='bound',updated_ms=? WHERE id=?").run(taskId, Date.now(), id);
|
|
47
|
+
}
|
|
48
|
+
markFailed(id) { this.db.prepare("UPDATE a2a_outbound_calls SET state='failed',updated_ms=? WHERE id=?").run(Date.now(), id); }
|
|
49
|
+
accept(peerId, token, raw) {
|
|
50
|
+
if (!token || token.length > 256 || !this.peers.some(peer => peer.enabled && peer.id === peerId))
|
|
51
|
+
throw new Error('A2A push is unauthorized');
|
|
52
|
+
const row = this.db.prepare('SELECT id,peer_id,task_id FROM a2a_outbound_calls WHERE token_hash=?').get(digest(token));
|
|
53
|
+
if (!row)
|
|
54
|
+
throw new Error('A2A push is unauthorized');
|
|
55
|
+
const call = Outbound.parse(row);
|
|
56
|
+
if (call.peer_id !== peerId)
|
|
57
|
+
throw new Error('A2A push is unauthorized');
|
|
58
|
+
const event = StreamCodec.fromJSON(raw);
|
|
59
|
+
const payload = event.payload;
|
|
60
|
+
if (!payload)
|
|
61
|
+
throw new Error('A2A push event is malformed');
|
|
62
|
+
const taskId = payload.$case === 'task' ? payload.value.id : payload.value.taskId;
|
|
63
|
+
if (!taskId || taskId.length > 256 || (call.task_id && call.task_id !== taskId))
|
|
64
|
+
throw new Error('A2A push task does not match the outbound call');
|
|
65
|
+
const body = JSON.stringify(StreamCodec.toJSON(event));
|
|
66
|
+
if (Buffer.byteLength(body) > 2 * 1024 * 1024)
|
|
67
|
+
throw new Error('A2A push event exceeds 2 MiB');
|
|
68
|
+
const id = digest(`${call.id}\n${body}`);
|
|
69
|
+
return this.db.transaction(() => {
|
|
70
|
+
const count = Count.parse(this.db.prepare('SELECT COUNT(*) AS count FROM a2a_inbound_push WHERE outbound_id=?').get(call.id)).count;
|
|
71
|
+
const prior = this.db.prepare('SELECT 1 FROM a2a_inbound_push WHERE id=?').get(id);
|
|
72
|
+
if (!prior && count >= 100)
|
|
73
|
+
throw new Error('A2A push event limit reached');
|
|
74
|
+
if (!call.task_id)
|
|
75
|
+
this.db.prepare("UPDATE a2a_outbound_calls SET task_id=?,state='bound',updated_ms=? WHERE id=? AND task_id IS NULL").run(taskId, Date.now(), call.id);
|
|
76
|
+
const inserted = this.db.prepare('INSERT OR IGNORE INTO a2a_inbound_push(id,outbound_id,task_id,event_json,received_ms) VALUES (?,?,?,?,?)').run(id, call.id, taskId, body, Date.now());
|
|
77
|
+
return { duplicate: inserted.changes === 0, outboundId: call.id, taskId };
|
|
78
|
+
})();
|
|
79
|
+
}
|
|
80
|
+
events(outboundId) {
|
|
81
|
+
return this.db.prepare('SELECT event_json FROM a2a_inbound_push WHERE outbound_id=? ORDER BY received_ms,id LIMIT 100').all(outboundId)
|
|
82
|
+
.map(row => { const parsed = EventRow.parse(row); return StreamCodec.fromJSON(JSON.parse(parsed.event_json)); });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
import { type StreamResponse, type Task, type TaskPushNotificationConfig } from '@a2a-js/sdk';
|
|
3
|
+
import type { PushNotificationSender, PushNotificationStore, ServerCallContext, StoredPushNotificationConfig } from '@a2a-js/sdk/server';
|
|
4
|
+
import type { PeerConfig } from './a2a-peers.js';
|
|
5
|
+
export declare class SqliteA2APushStore implements PushNotificationStore {
|
|
6
|
+
readonly db: Database.Database;
|
|
7
|
+
readonly peers: PeerConfig[];
|
|
8
|
+
constructor(db: Database.Database, peers: PeerConfig[]);
|
|
9
|
+
save(taskId: string, context: ServerCallContext, config: TaskPushNotificationConfig): Promise<void>;
|
|
10
|
+
load(taskId: string, context: ServerCallContext): Promise<TaskPushNotificationConfig[]>;
|
|
11
|
+
loadWithMetadata(taskId: string, context: ServerCallContext): Promise<StoredPushNotificationConfig[]>;
|
|
12
|
+
delete(taskId: string, context: ServerCallContext, configId?: string): Promise<void>;
|
|
13
|
+
claimDelivery(id: string, taskId: string, context: ServerCallContext, configId: string): boolean;
|
|
14
|
+
deliveryState(id: string): 'queued' | 'sent' | 'failed' | 'unknown' | undefined;
|
|
15
|
+
setDelivery(id: string, state: 'sent' | 'failed' | 'unknown', httpStatus?: number, error?: string): void;
|
|
16
|
+
}
|
|
17
|
+
/** Thrown by a `beforeDelivery` hook to refuse a push deterministically (recorded as failed, not unknown). */
|
|
18
|
+
export declare class DeliveryVetoError extends Error {
|
|
19
|
+
}
|
|
20
|
+
export declare class SafeA2APushSender implements PushNotificationSender {
|
|
21
|
+
#private;
|
|
22
|
+
readonly store: SqliteA2APushStore;
|
|
23
|
+
readonly beforeDelivery?: ((taskId: string) => void) | undefined;
|
|
24
|
+
constructor(store: SqliteA2APushStore, beforeDelivery?: ((taskId: string) => void) | undefined);
|
|
25
|
+
drain(): Promise<void>;
|
|
26
|
+
send(event: StreamResponse, context: ServerCallContext, _task?: Task): Promise<void>;
|
|
27
|
+
}
|