@hanphone/dsh-a2a 0.1.0 → 0.2.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 +98 -47
- package/README.zh.md +43 -42
- package/lib/client.js +49 -1
- package/lib/index.js +272 -17
- package/lib/tsconfig.client.tsbuildinfo +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/lib/types/api.d.ts +13 -0
- package/lib/types/api.d.ts.map +1 -1
- package/lib/types/api.js +5 -0
- package/lib/types/api.js.map +1 -1
- package/lib/types/client/index.d.ts +5 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +74 -10
- package/lib/types/index.js.map +1 -1
- package/lib/types/server/a2a-server.d.ts +3 -1
- package/lib/types/server/a2a-server.d.ts.map +1 -1
- package/lib/types/server/a2a-server.js +5 -1
- package/lib/types/server/a2a-server.js.map +1 -1
- package/lib/types/server/identity.d.ts +38 -0
- package/lib/types/server/identity.d.ts.map +1 -0
- package/lib/types/server/identity.js +89 -0
- package/lib/types/server/identity.js.map +1 -0
- package/lib/types/server/inbound-registry.d.ts +60 -0
- package/lib/types/server/inbound-registry.d.ts.map +1 -0
- package/lib/types/server/inbound-registry.js +86 -0
- package/lib/types/server/inbound-registry.js.map +1 -0
- package/lib/types/server/store.d.ts +2 -0
- package/lib/types/server/store.d.ts.map +1 -1
- package/lib/types/server/store.js +4 -0
- package/lib/types/server/store.js.map +1 -1
- package/lib/types/service.d.ts +19 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +12 -0
- package/lib/types/service.js.map +1 -1
- package/package.json +2 -2
- package/src/api.ts +27 -2
- package/src/client/index.ts +99 -2
- package/src/index.ts +76 -10
- package/src/server/a2a-server.ts +7 -2
- package/src/server/identity.ts +109 -0
- package/src/server/inbound-registry.ts +129 -0
- package/src/server/store.ts +5 -0
- package/src/service.ts +23 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service identity: the inbound AgentCard's name/description/version,
|
|
3
|
+
* editable at runtime from the GUI dashboard and persisted in the `a2a`
|
|
4
|
+
* domain's `identity` table. Skills stay derived from the live tool registry
|
|
5
|
+
* (the "card derives from ctx.tools" design); identity editing builds a fresh
|
|
6
|
+
* card preserving the endpoint URL and swaps it onto the server, so routes
|
|
7
|
+
* and the facade see the new value immediately.
|
|
8
|
+
* @module dsh-a2a/server/identity
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { buildCard, type CardOptions } from './card.ts'
|
|
12
|
+
import type { AgentCard, AgentSkill } from '../protocol.ts'
|
|
13
|
+
import type { A2aDomain } from './store.ts'
|
|
14
|
+
|
|
15
|
+
/** Persisted service identity (only the card identity fields; skills derive). */
|
|
16
|
+
export interface A2aIdentity {
|
|
17
|
+
readonly name: string
|
|
18
|
+
readonly description: string
|
|
19
|
+
readonly version: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const IDENTITY_KEY = 'service'
|
|
23
|
+
|
|
24
|
+
function decode(raw: string | undefined): A2aIdentity | undefined {
|
|
25
|
+
if (raw === undefined) return undefined
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(raw) as Partial<A2aIdentity>
|
|
28
|
+
if (typeof parsed.name !== 'string' || typeof parsed.description !== 'string' || typeof parsed.version !== 'string') {
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
31
|
+
return { name: parsed.name, description: parsed.description, version: parsed.version }
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function encode(value: A2aIdentity): string {
|
|
38
|
+
return JSON.stringify(value)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Read the persisted identity (undefined when never configured). */
|
|
42
|
+
export function readIdentity(domain: A2aDomain): A2aIdentity | undefined {
|
|
43
|
+
return decode(domain.identity.get(IDENTITY_KEY))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Persist a new identity (fire-and-forget like the task store writes). */
|
|
47
|
+
export function writeIdentity(domain: A2aDomain, identity: A2aIdentity): void {
|
|
48
|
+
void domain.identity.put(IDENTITY_KEY, encode(identity)).catch((err: unknown) => {
|
|
49
|
+
throw new Error(`a2a: identity persistence failed: ${String(err)}`)
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build the AgentCard options for a given base URL/path and the override
|
|
55
|
+
* identity. `identity` (persisted) wins over the composition `defaults`; when
|
|
56
|
+
* no identity is stored, the composition defaults stand.
|
|
57
|
+
*/
|
|
58
|
+
export function cardOptionsFor(
|
|
59
|
+
baseUrl: string,
|
|
60
|
+
endpointPath: string,
|
|
61
|
+
defaults: { readonly name: string; readonly description: string; readonly version: string },
|
|
62
|
+
identity: A2aIdentity | undefined,
|
|
63
|
+
skills: readonly AgentSkill[],
|
|
64
|
+
authToken?: string,
|
|
65
|
+
): CardOptions {
|
|
66
|
+
return {
|
|
67
|
+
baseUrl,
|
|
68
|
+
endpointPath,
|
|
69
|
+
name: identity?.name ?? defaults.name,
|
|
70
|
+
description: identity?.description ?? defaults.description,
|
|
71
|
+
version: identity?.version ?? defaults.version,
|
|
72
|
+
skills,
|
|
73
|
+
...(authToken !== undefined ? { authToken } : {}),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build a fresh AgentCard from an existing one plus a new identity and skill
|
|
79
|
+
* list, preserving the endpoint URL (baseUrl/path) and security scheme.
|
|
80
|
+
*/
|
|
81
|
+
export function rebuildCardWithIdentity(card: AgentCard, identity: A2aIdentity, skills: readonly AgentSkill[]): AgentCard {
|
|
82
|
+
return buildCard({
|
|
83
|
+
baseUrl: endpointBaseOf(card),
|
|
84
|
+
endpointPath: endpointPathOf(card),
|
|
85
|
+
name: identity.name,
|
|
86
|
+
description: identity.description,
|
|
87
|
+
version: identity.version,
|
|
88
|
+
skills,
|
|
89
|
+
// Preserve the advertised scheme; buildCard only uses the presence (never
|
|
90
|
+
// the value) to declare securitySchemes.
|
|
91
|
+
...(card.securitySchemes !== undefined ? { authToken: 'preserved-scheme' } : {}),
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function endpointBaseOf(card: AgentCard): string {
|
|
96
|
+
const url = card.supportedInterfaces?.[0]?.url
|
|
97
|
+
if (!url) return 'http://127.0.0.1'
|
|
98
|
+
return url.replace(/\/[^/]*$/, '')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function endpointPathOf(card: AgentCard): string {
|
|
102
|
+
const url = card.supportedInterfaces?.[0]?.url
|
|
103
|
+
if (!url) return '/a2a'
|
|
104
|
+
try {
|
|
105
|
+
return new URL(url).pathname
|
|
106
|
+
} catch {
|
|
107
|
+
return '/a2a'
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound connection registry: which remote peers are talking to this DSH's
|
|
3
|
+
* A2A server. Fed by the A2A server's `onInbound` hook (every JSON-RPC
|
|
4
|
+
* request / SSE open), surfaced through the dashboard API, and disconnectable
|
|
5
|
+
* (`closePeer` cancels the peer's active tasks and drops the record).
|
|
6
|
+
* @module dsh-a2a/server/inbound-registry
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** One live inbound connection (observed peer). */
|
|
10
|
+
export interface InboundPeerRecord {
|
|
11
|
+
/** Stable per-process id (random). */
|
|
12
|
+
readonly id: string
|
|
13
|
+
/** Short display label derived from the request source. */
|
|
14
|
+
readonly label: string
|
|
15
|
+
/** Source socket address, when visible ("127.0.0.1:54321"). */
|
|
16
|
+
readonly source: string | null
|
|
17
|
+
/** First-seen ISO timestamp. */
|
|
18
|
+
readonly firstSeen: string
|
|
19
|
+
/** Last-activity ISO timestamp. */
|
|
20
|
+
readonly lastSeen: string
|
|
21
|
+
/** Number of tasks this peer created or continued. */
|
|
22
|
+
readonly taskCount: number
|
|
23
|
+
/** Ids of tasks still running for this peer. */
|
|
24
|
+
readonly activeTaskIds: readonly string[]
|
|
25
|
+
/** True while at least one streaming (SSE) connection is open. */
|
|
26
|
+
readonly streaming: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Control and observation contract the dashboard API and facade use. */
|
|
30
|
+
export interface InboundRegistry {
|
|
31
|
+
list(): readonly InboundPeerRecord[]
|
|
32
|
+
/** Cancel a peer's active tasks and remove its record. */
|
|
33
|
+
closePeer(peerId: string): { readonly ok: boolean; readonly message: string }
|
|
34
|
+
/** Active task ids of one peer (the facade cancels them on close). */
|
|
35
|
+
activeTasksOf(peerId: string): readonly string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Mutable tracking state behind one peer record. */
|
|
39
|
+
interface PeerState {
|
|
40
|
+
readonly id: string
|
|
41
|
+
readonly label: string
|
|
42
|
+
readonly source: string | null
|
|
43
|
+
readonly firstSeen: string
|
|
44
|
+
lastSeen: string
|
|
45
|
+
taskCount: number
|
|
46
|
+
readonly active: Set<string>
|
|
47
|
+
streamingCount: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function newPeerId(): string {
|
|
51
|
+
return `peer-${crypto.randomUUID()}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** In-memory inbound peer registry. */
|
|
55
|
+
export class LiveInboundRegistry implements InboundRegistry {
|
|
56
|
+
private readonly peers = new Map<string, PeerState>()
|
|
57
|
+
|
|
58
|
+
private findBySource(source: string | null): PeerState | undefined {
|
|
59
|
+
if (source === null) return undefined
|
|
60
|
+
for (const peer of this.peers.values()) {
|
|
61
|
+
if (peer.source === source) return peer
|
|
62
|
+
}
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Observe one inbound event: method, source, task ids, streaming flag. */
|
|
67
|
+
note(input: { readonly method: string; readonly source?: string; readonly taskIds: readonly string[]; readonly streaming: boolean }): void {
|
|
68
|
+
const now = new Date().toISOString()
|
|
69
|
+
const source = input.source ?? null
|
|
70
|
+
let peer = this.findBySource(source)
|
|
71
|
+
if (peer === undefined) {
|
|
72
|
+
peer = {
|
|
73
|
+
id: newPeerId(),
|
|
74
|
+
label: source ?? 'unknown',
|
|
75
|
+
source,
|
|
76
|
+
firstSeen: now,
|
|
77
|
+
lastSeen: now,
|
|
78
|
+
taskCount: 0,
|
|
79
|
+
active: new Set<string>(),
|
|
80
|
+
streamingCount: 0,
|
|
81
|
+
}
|
|
82
|
+
this.peers.set(peer.id, peer)
|
|
83
|
+
}
|
|
84
|
+
peer.lastSeen = now
|
|
85
|
+
peer.taskCount += input.taskIds.length
|
|
86
|
+
for (const id of input.taskIds) peer.active.add(id)
|
|
87
|
+
if (input.streaming) peer.streamingCount += 1
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A task settled: drop it from every peer's active set. */
|
|
91
|
+
settle(taskId: string): void {
|
|
92
|
+
for (const peer of this.peers.values()) {
|
|
93
|
+
if (peer.active.delete(taskId)) peer.lastSeen = new Date().toISOString()
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Decrement streaming count when an SSE connection closes. */
|
|
98
|
+
endStream(source?: string): void {
|
|
99
|
+
const peer = this.findBySource(source ?? null)
|
|
100
|
+
if (peer !== undefined && peer.streamingCount > 0) {
|
|
101
|
+
peer.streamingCount -= 1
|
|
102
|
+
peer.lastSeen = new Date().toISOString()
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
list(): readonly InboundPeerRecord[] {
|
|
107
|
+
return [...this.peers.values()].map((p) => ({
|
|
108
|
+
id: p.id,
|
|
109
|
+
label: p.label,
|
|
110
|
+
source: p.source,
|
|
111
|
+
firstSeen: p.firstSeen,
|
|
112
|
+
lastSeen: p.lastSeen,
|
|
113
|
+
taskCount: p.taskCount,
|
|
114
|
+
activeTaskIds: [...p.active],
|
|
115
|
+
streaming: p.streamingCount > 0,
|
|
116
|
+
})).sort((a, b) => (a.lastSeen < b.lastSeen ? 1 : -1))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
closePeer(peerId: string): { readonly ok: boolean; readonly message: string } {
|
|
120
|
+
const peer = this.peers.get(peerId)
|
|
121
|
+
if (peer === undefined) return { ok: false, message: `inbound peer ${peerId} not found` }
|
|
122
|
+
this.peers.delete(peerId)
|
|
123
|
+
return { ok: true, message: `inbound peer ${peer.label} closed` }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
activeTasksOf(peerId: string): readonly string[] {
|
|
127
|
+
return [...(this.peers.get(peerId)?.active ?? [])]
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/server/store.ts
CHANGED
|
@@ -61,6 +61,7 @@ export const a2aDomainSpec = defineDomain({
|
|
|
61
61
|
tasks: domainTable<string, string>(json),
|
|
62
62
|
contexts: domainTable<string, string>(json),
|
|
63
63
|
agents: domainTable<string, string>(json),
|
|
64
|
+
identity: domainTable<string, string>(json),
|
|
64
65
|
},
|
|
65
66
|
})
|
|
66
67
|
|
|
@@ -107,6 +108,10 @@ export class A2aDomain {
|
|
|
107
108
|
return this.handle.table('agents')
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
get identity(): KvTable<string, string> {
|
|
112
|
+
return this.handle.table('identity')
|
|
113
|
+
}
|
|
114
|
+
|
|
110
115
|
async close(): Promise<void> {
|
|
111
116
|
await this.handle.close()
|
|
112
117
|
}
|
package/src/service.ts
CHANGED
|
@@ -25,6 +25,13 @@ export interface A2AServiceImpl {
|
|
|
25
25
|
removeAgent(id: string): Promise<OpResult>
|
|
26
26
|
setAgentEnabled(id: string, enabled: boolean): Promise<OpResult>
|
|
27
27
|
refreshAgentCard(id: string): Promise<OpResult>
|
|
28
|
+
/** Current service identity (persisted override + composition defaults). */
|
|
29
|
+
identity(): unknown
|
|
30
|
+
/** Persist and apply a new service identity onto the live AgentCard. */
|
|
31
|
+
updateIdentity(patch: { readonly name?: string; readonly description?: string; readonly version?: string }): Promise<OpResult>
|
|
32
|
+
/** Cancel an inbound peer's active tasks and drop its record. */
|
|
33
|
+
closeInbound(peerId: string): Promise<OpResult>
|
|
34
|
+
inbounds(): unknown
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
/** The service other plugins read as `ctx.a2a`. */
|
|
@@ -75,4 +82,20 @@ export class A2AService extends Service {
|
|
|
75
82
|
async refreshAgentCard(id: string): Promise<OpResult> {
|
|
76
83
|
return this.impl.refreshAgentCard(id)
|
|
77
84
|
}
|
|
85
|
+
|
|
86
|
+
identity(): unknown {
|
|
87
|
+
return this.impl.identity()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async updateIdentity(patch: { readonly name?: string; readonly description?: string; readonly version?: string }): Promise<OpResult> {
|
|
91
|
+
return this.impl.updateIdentity(patch)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async closeInbound(peerId: string): Promise<OpResult> {
|
|
95
|
+
return this.impl.closeInbound(peerId)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
inbounds(): unknown {
|
|
99
|
+
return this.impl.inbounds()
|
|
100
|
+
}
|
|
78
101
|
}
|