Skip to content

Commit 7681713

Browse files
Nick Ficanoclaude
andcommitted
effect(runtime-agent-registry): add AgentRegistryService alongside legacy class
Introduce AgentRegistryService (tag arcp/AgentRegistryService) backed by Ref<Map<string, Map<string, AgentHandler>>> with register, resolve, has, unregister, and list operations. Failures surface on the typed channel as TaggedAgentNotAvailable / TaggedAgentVersionNotAvailable. The legacy AgentRegistry class is unchanged in public shape — ARCPServer still consumes it via registerAgent / registerAgentVersion / setDefaultVersion — and is refactored internally to use getOrCreate from @arcp/core/util for the bucket helper, matching the pattern from slice #32. Runtime-configured defaults (setDefaultVersion) remain a legacy-only concern; the Effect service preserves the "first-registered-version" semantics for resolve(name, null) by preferring the empty-string slot, otherwise the first entry in insertion order. Closes #43 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ba1bb54 commit 7681713

2 files changed

Lines changed: 405 additions & 27 deletions

File tree

packages/runtime/src/agent-registry.ts

Lines changed: 200 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1+
import {
2+
TaggedAgentNotAvailable,
3+
TaggedAgentVersionNotAvailable,
4+
} from "@arcp/core";
15
import {
26
AgentNotAvailableError,
37
AgentVersionNotAvailableError,
48
} from "@arcp/core/errors";
59
import type { AgentInventoryEntry } from "@arcp/core/messages";
10+
import { getOrCreate } from "@arcp/core/util";
11+
import { Effect, Ref } from "effect";
612

713
import 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

Comments
 (0)