1+ import {
2+ TaggedAgentNotAvailable ,
3+ TaggedAgentVersionNotAvailable ,
4+ } from "@arcp/core" ;
15import {
26 AgentNotAvailableError ,
37 AgentVersionNotAvailableError ,
48} from "@arcp/core/errors" ;
59import type { AgentInventoryEntry } from "@arcp/core/messages" ;
10+ import { getOrCreate } from "@arcp/core/util" ;
11+ import { Effect , Ref } from "effect" ;
612
713import type { AgentHandler } from "./types.js" ;
814
@@ -47,16 +53,27 @@ export class AgentRegistry {
4753 throw new AgentNotAvailableError ( `Agent "${ name } " is not registered` ) ;
4854 }
4955 if ( version !== null ) {
50- const handler = bucket . get ( version ) ;
51- if ( handler === undefined ) {
52- throw new AgentVersionNotAvailableError (
53- `Agent "${ name } @${ version } " is not registered` ,
54- ) ;
55- }
56- return { handler, version } ;
56+ return resolveExplicitVersion ( name , version , bucket ) ;
57+ }
58+ return this . resolveDefault ( name , bucket ) ;
59+ }
60+
61+ public inventory ( ) : AgentInventoryEntry [ ] {
62+ const out : AgentInventoryEntry [ ] = [ ] ;
63+ for ( const [ name , bucket ] of this . handlers . entries ( ) ) {
64+ const versions = [ ...bucket . keys ( ) ] . filter ( ( v ) => v !== "" ) ;
65+ const entry : AgentInventoryEntry = { name, versions } ;
66+ const def = this . defaults . get ( name ) ;
67+ if ( def !== undefined && versions . includes ( def ) ) entry . default = def ;
68+ out . push ( entry ) ;
5769 }
58- // bare name → prefer the runtime-configured default, else the unversioned
59- // slot, else pick the first registered version.
70+ return out ;
71+ }
72+
73+ private resolveDefault (
74+ name : string ,
75+ bucket : Map < string , AgentHandler > ,
76+ ) : { handler : AgentHandler ; version : string } {
6077 const defaultVersion = this . defaults . get ( name ) ;
6178 if ( defaultVersion !== undefined ) {
6279 const handler = bucket . get ( defaultVersion ) ;
@@ -71,7 +88,6 @@ export class AgentRegistry {
7188 if ( unversioned !== undefined ) {
7289 return { handler : unversioned , version : "" } ;
7390 }
74- // Pick an arbitrary version. Clients that require stability MUST pin one.
7591 const firstEntry = bucket . entries ( ) . next ( ) . value ;
7692 if ( firstEntry === undefined ) {
7793 throw new AgentNotAvailableError ( `Agent "${ name } " is not registered` ) ;
@@ -80,24 +96,181 @@ export class AgentRegistry {
8096 return { handler : h , version : v } ;
8197 }
8298
83- public inventory ( ) : AgentInventoryEntry [ ] {
84- const out : AgentInventoryEntry [ ] = [ ] ;
85- for ( const [ name , bucket ] of this . handlers . entries ( ) ) {
86- const versions = [ ...bucket . keys ( ) ] . filter ( ( v ) => v !== "" ) ;
87- const entry : AgentInventoryEntry = { name, versions } ;
88- const def = this . defaults . get ( name ) ;
89- if ( def !== undefined && versions . includes ( def ) ) entry . default = def ;
90- out . push ( entry ) ;
91- }
92- return out ;
99+ private bucket ( name : string ) : Map < string , AgentHandler > {
100+ return getOrCreate (
101+ this . handlers ,
102+ name ,
103+ ( ) => new Map < string , AgentHandler > ( ) ,
104+ ) ;
93105 }
106+ }
94107
95- private bucket ( name : string ) : Map < string , AgentHandler > {
96- let bucket = this . handlers . get ( name ) ;
97- if ( bucket === undefined ) {
98- bucket = new Map < string , AgentHandler > ( ) ;
99- this . handlers . set ( name , bucket ) ;
100- }
101- return bucket ;
108+ function resolveExplicitVersion (
109+ name : string ,
110+ version : string ,
111+ bucket : Map < string , AgentHandler > ,
112+ ) : { handler : AgentHandler ; version : string } {
113+ const handler = bucket . get ( version ) ;
114+ if ( handler === undefined ) {
115+ throw new AgentVersionNotAvailableError (
116+ `Agent "${ name } @${ version } " is not registered` ,
117+ ) ;
102118 }
119+ return { handler, version } ;
120+ }
121+
122+ // ============================================================================
123+ // Effect-shaped twin — `AgentRegistryService`
124+ // ============================================================================
125+
126+ /**
127+ * Failure modes surfaced on the typed-error channel for
128+ * {@link AgentRegistryService}. Mirrors the legacy class:
129+ * - unknown agent name → {@link TaggedAgentNotAvailable}
130+ * - unknown version → {@link TaggedAgentVersionNotAvailable}
131+ */
132+ export type AgentRegistryFailure =
133+ | TaggedAgentNotAvailable
134+ | TaggedAgentVersionNotAvailable ;
135+
136+ /** Resolved handler plus the version slot it came from. */
137+ export interface ResolvedAgent {
138+ readonly handler : AgentHandler ;
139+ readonly version : string ;
140+ }
141+
142+ type RegistryState = ReadonlyMap < string , ReadonlyMap < string , AgentHandler > > ;
143+
144+ const EMPTY_STATE : RegistryState = new Map ( ) ;
145+
146+ interface HandlerInsert {
147+ readonly name : string ;
148+ readonly version : string ;
149+ readonly handler : AgentHandler ;
150+ }
151+
152+ function withHandler (
153+ state : RegistryState ,
154+ insert : HandlerInsert ,
155+ ) : RegistryState {
156+ const next = new Map ( state ) ;
157+ const existing = state . get ( insert . name ) ;
158+ const bucket =
159+ existing === undefined
160+ ? new Map < string , AgentHandler > ( )
161+ : new Map ( existing ) ;
162+ bucket . set ( insert . version , insert . handler ) ;
163+ next . set ( insert . name , bucket ) ;
164+ return next ;
103165}
166+
167+ function pickFromBucket (
168+ bucket : ReadonlyMap < string , AgentHandler > ,
169+ version : string | null ,
170+ ) : ResolvedAgent | { readonly missingVersion : string } {
171+ if ( version !== null ) {
172+ const handler = bucket . get ( version ) ;
173+ return handler === undefined
174+ ? { missingVersion : version }
175+ : { handler, version } ;
176+ }
177+ const unversioned = bucket . get ( "" ) ;
178+ if ( unversioned !== undefined ) return { handler : unversioned , version : "" } ;
179+ const first = bucket . entries ( ) . next ( ) . value ;
180+ if ( first === undefined ) return { missingVersion : "" } ;
181+ const [ v , h ] = first ;
182+ return { handler : h , version : v } ;
183+ }
184+
185+ function resolveFromState (
186+ state : RegistryState ,
187+ name : string ,
188+ version : string | null ,
189+ ) : Effect . Effect < ResolvedAgent , AgentRegistryFailure > {
190+ const bucket = state . get ( name ) ;
191+ if ( bucket === undefined || bucket . size === 0 ) {
192+ return Effect . fail (
193+ new TaggedAgentNotAvailable ( {
194+ message : `Agent "${ name } " is not registered` ,
195+ } ) ,
196+ ) ;
197+ }
198+ const picked = pickFromBucket ( bucket , version ) ;
199+ if ( "missingVersion" in picked ) {
200+ return Effect . fail (
201+ new TaggedAgentVersionNotAvailable ( {
202+ message : `Agent "${ name } @${ picked . missingVersion } " is not registered` ,
203+ } ) ,
204+ ) ;
205+ }
206+ return Effect . succeed ( picked ) ;
207+ }
208+
209+ function makeOps ( ref : Ref . Ref < RegistryState > ) {
210+ return {
211+ register : (
212+ name : string ,
213+ version : string ,
214+ handler : AgentHandler ,
215+ ) : Effect . Effect < void > =>
216+ Ref . update ( ref , ( s ) =>
217+ withHandler ( s , { name, version, handler } ) ,
218+ ) ,
219+ resolve : (
220+ name : string ,
221+ version : string | null ,
222+ ) : Effect . Effect < ResolvedAgent , AgentRegistryFailure > =>
223+ Ref . get ( ref ) . pipe (
224+ Effect . flatMap ( ( s ) => resolveFromState ( s , name , version ) ) ,
225+ ) ,
226+ has : ( name : string ) : Effect . Effect < boolean > =>
227+ Ref . get ( ref ) . pipe ( Effect . map ( ( s ) => s . has ( name ) ) ) ,
228+ unregister : ( name : string ) : Effect . Effect < boolean > =>
229+ Ref . modify ( ref , ( s ) => {
230+ if ( ! s . has ( name ) ) return [ false , s ] ;
231+ const next = new Map ( s ) ;
232+ next . delete ( name ) ;
233+ return [ true , next ] ;
234+ } ) ,
235+ list : ( ) : Effect . Effect < readonly AgentInventoryEntry [ ] > =>
236+ Ref . get ( ref ) . pipe ( Effect . map ( ( s ) => snapshotInventory ( s ) ) ) ,
237+ } as const ;
238+ }
239+
240+ function snapshotInventory ( state : RegistryState ) : AgentInventoryEntry [ ] {
241+ const out : AgentInventoryEntry [ ] = [ ] ;
242+ for ( const [ name , bucket ] of state . entries ( ) ) {
243+ const versions = [ ...bucket . keys ( ) ] . filter ( ( v ) => v !== "" ) ;
244+ out . push ( { name, versions } ) ;
245+ }
246+ return out ;
247+ }
248+
249+ /**
250+ * Effect-shaped twin of {@link AgentRegistry}. Backs the handler map with a
251+ * {@link Ref} so concurrent fibers can `register`, `resolve`, and `unregister`
252+ * without trampling each other. `resolve(name, null)` preserves the legacy
253+ * "first registered version" semantics: it prefers the empty-string slot if
254+ * present, otherwise picks the first entry in insertion order. Runtime
255+ * defaults (`setDefaultVersion`) are a legacy-only concern and intentionally
256+ * not modelled here.
257+ *
258+ * @example
259+ * ```ts
260+ * const program = Effect.gen(function* () {
261+ * const reg = yield* AgentRegistryService
262+ * yield* reg.register("planner", "v1", handler)
263+ * const { handler: h } = yield* reg.resolve("planner", "v1")
264+ * return h
265+ * }).pipe(Effect.provide(AgentRegistryService.Default))
266+ * ```
267+ */
268+ export class AgentRegistryService extends Effect . Service < AgentRegistryService > ( ) (
269+ "arcp/AgentRegistryService" ,
270+ {
271+ effect : Effect . gen ( function * ( ) {
272+ const ref = yield * Ref . make < RegistryState > ( EMPTY_STATE ) ;
273+ return makeOps ( ref ) ;
274+ } ) ,
275+ } ,
276+ ) { }
0 commit comments