Skip to content
Source
Sign inSign up
Open History to see the last commit

probe.ts

808 lines30.3 KB

1import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe";
2import {
3 AcpRegistryListProvidersResult,
4 AcpRegistryOperationError,
5 AcpRegistryListSessionsResult,
6 AcpRegistryProbeResult,
7 type AcpRegistryProbeAuthMethod,
8 type AcpRegistryProbeModel,
9 type AcpRegistryUrlAuthAction,
10 type ProviderInstanceId,
11 type ServerProviderSkill,
12 type ServerProviderSlashCommand,
13} from "@t3tools/contracts";
14import { AcpRegistrySettings } from "../settings.ts";
15import * as SecureRandom from "effect/crypto/SecureRandom";
16import * as Deferred from "effect/Deferred";
17import * as Duration from "effect/Duration";
18import * as Effect from "effect/Effect";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import * as Ref from "effect/Ref";
22import * as Result from "effect/Result";
23import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
24import type * as EffectAcpErrors from "effect-acp/errors";
25import type * as EffectAcpSchema from "effect-acp/compat";
26​
27import * as PtyAdapter from "@t3tools/shared/PtyAdapter";
28import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";
29import {
30 findModelConfigOption,
31 parseSessionModeState,
32} from "@t3tools/provider-acp/server/runtimeModel";
33import {
34 acpProviderOptionDescriptors,
35 flattenSelectChoices,
36} from "@t3tools/provider-acp/server/sessionConfig";
37import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts";
38import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
39​
40const MAX_AUTH_METHODS = 32;
41const MAX_MODELS = 256;
42const MAX_ID_LENGTH = 128;
43const MAX_NAME_LENGTH = 160;
44const MAX_DESCRIPTION_LENGTH = 1_024;
45const MAX_COMMAND_LINE_LENGTH = 2_048;
46const PROBE_TIMEOUT_MS = 60_000;
47const COMMAND_ADVERTISEMENT_GRACE = "500 millis";
48const PROBE_SESSION_DELETE_TIMEOUT = "5 seconds";
49​
50const boundedText = (value: string, maximumLength: number): string =>
51 value.trim().slice(0, maximumLength);
52​
53const boundedOpaqueValue = (value: string, maximumLength: number): string | undefined =>
54 value.length > 0 && value === value.trim() && value.length <= maximumLength ? value : undefined;
55​
56export function normalizeAcpRegistryWebUrl(value: string): string | undefined {
57 if (value.length > 2_048 || !URL.canParse(value)) return undefined;
58 const url = new URL(value);
59 return url.protocol === "https:" || url.protocol === "http:" ? url.href : undefined;
61​
62function normalizedModel(id: string, candidate: AcpRegistryProbeModel): AcpRegistryProbeModel {
63 return {
64 id,
65 name: boundedText(candidate.name, MAX_NAME_LENGTH) || id,
66 description:
67 candidate.description === null
68 ? null
69 : boundedText(candidate.description, MAX_DESCRIPTION_LENGTH) || null,
70 };
72​
73function normalizeModels(
74 candidates: ReadonlyArray<AcpRegistryProbeModel>,
75 currentModelId: string | null,
76): ReadonlyArray<AcpRegistryProbeModel> {
77 const seen = new Set<string>();
78 const models: Array<AcpRegistryProbeModel> = [];
79 for (const candidate of candidates) {
80 if (models.length === MAX_MODELS) break;
81 const id = boundedOpaqueValue(candidate.id, MAX_ID_LENGTH);
82 if (id === undefined || seen.has(id)) continue;
83 seen.add(id);
84 models.push(normalizedModel(id, candidate));
85 }
86 // Keep the running model visible even when its advertised position exceeds the cap.
87 if (currentModelId !== null && models.length === MAX_MODELS && !seen.has(currentModelId)) {
88 const current = candidates.find((candidate) => candidate.id === currentModelId);
89 if (current !== undefined) {
90 models[MAX_MODELS - 1] = normalizedModel(currentModelId, current);
91 }
92 }
93 return models;
95​
96export interface AcpRegistryAuthSpawnContext {
97 readonly command: string;
98 readonly args: ReadonlyArray<string>;
100​
101const SHELL_SAFE_TOKEN = /^[A-Za-z0-9_@%+=:,./-]+$/u;
102​
103function shellDisplayToken(token: string): string {
104 return SHELL_SAFE_TOKEN.test(token) ? token : `'${token.replaceAll("'", `'\\''`)}'`;
106​
107function terminalAuthCommand(
108 method: Extract<EffectAcpSchema.AuthMethod, { readonly type: "terminal" }>,
109 spawn: AcpRegistryAuthSpawnContext,
110): string | undefined {
111 const environmentPrefix = Object.entries(method.env ?? {}).map(
112 ([name, value]) => `${name}=${shellDisplayToken(value)}`,
113 );
114 const command = [spawn.command, ...spawn.args, ...(method.args ?? [])].map(shellDisplayToken);
115 const displayCommand = [...environmentPrefix, ...command].join(" ");
116 return displayCommand.length <= MAX_COMMAND_LINE_LENGTH ? displayCommand : undefined;
118​
119export function normalizeAcpRegistryAuthMethods(
120 methods: ReadonlyArray<EffectAcpSchema.AuthMethod> | undefined,
121 spawn?: AcpRegistryAuthSpawnContext,
122): ReadonlyArray<AcpRegistryProbeAuthMethod> {
123 const normalized: Array<AcpRegistryProbeAuthMethod> = [];
124 for (const method of methods ?? []) {
125 const id = boundedOpaqueValue(method.id, MAX_ID_LENGTH);
126 if (id === undefined) continue;
127 const type = method.type ?? "agent";
128 const envVarNames =
129 type === "env_var" && "vars" in method
130 ? method.vars
131 .flatMap((variable) => {
132 const name = boundedOpaqueValue(variable.name, MAX_ID_LENGTH);
133 return name === undefined ? [] : [name];
134 })
135 .slice(0, 16)
136 : [];
137 const command =
138 spawn !== undefined && "type" in method && method.type === "terminal"
139 ? terminalAuthCommand(method, spawn)
140 : undefined;
141 const link =
142 type === "env_var" && "link" in method && method.link
143 ? normalizeAcpRegistryWebUrl(method.link)
144 : undefined;
145 normalized.push({
146 id,
147 name: boundedText(method.name, MAX_NAME_LENGTH) || id,
148 description:
149 method.description == null
150 ? null
151 : boundedText(method.description, MAX_DESCRIPTION_LENGTH) || null,
152 type,
153 ...(command === undefined ? {} : { command }),
154 ...(envVarNames.length > 0 ? { envVarNames } : {}),
155 ...(link === undefined ? {} : { link }),
156 });
157 if (normalized.length === MAX_AUTH_METHODS) break;
158 }
159 return normalized;
161​
162export interface AcpRegistryAvailableCommands {
163 readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
164 readonly skills: ReadonlyArray<ServerProviderSkill>;
166​
167export interface AcpRegistryLiveConfiguration {
168 readonly models: ReadonlyArray<AcpRegistryProbeModel>;
169 readonly currentModelId: string | null;
170 readonly configOptions: AcpRegistryProbeResult["configOptions"];
171 readonly modelOptionIds?: NonNullable<AcpRegistryProbeResult["modelOptionIds"]>;
173​
174export function normalizeAcpRegistryLiveConfiguration(
175 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>,
176 modeState?: Parameters<typeof acpProviderOptionDescriptors>[0]["modeState"],
177 modelState?: EffectAcpSchema.SessionModelState | null,
178): AcpRegistryLiveConfiguration {
179 const modelOption = findModelConfigOption(configOptions);
180 const currentModelId =
181 modelOption?.type === "select" ? modelOption.currentValue : modelState?.currentModelId;
182 const candidates =
183 modelOption === undefined
184 ? (modelState?.availableModels ?? []).map((model) => ({
185 id: model.modelId,
186 name: model.name,
187 description: model.description ?? null,
188 }))
189 : flattenSelectChoices(modelOption.options).map((model) => ({
190 id: model.value,
191 name: model.name,
192 description: model.description ?? null,
193 }));
194 const boundedCurrentModelId =
195 currentModelId === undefined
196 ? null
197 : (boundedOpaqueValue(currentModelId, MAX_ID_LENGTH) ?? null);
198 const models = normalizeModels(candidates, boundedCurrentModelId);
199 const descriptors = acpProviderOptionDescriptors({ configOptions, modeState });
200 const modelOptionIds = new Set(
201 configOptions
202 .filter((option) => option.category === "thought_level" || option.category === "model_config")
203 .map((option) => option.id),
204 );
205 return {
206 models,
207 currentModelId: models.some((model) => model.id === boundedCurrentModelId)
208 ? boundedCurrentModelId
209 : null,
210 configOptions: descriptors,
211 modelOptionIds: descriptors
212 .filter((descriptor) => modelOptionIds.has(descriptor.id))
213 .map((descriptor) => descriptor.id),
214 };
216​
217const emptyAcpRegistryAvailableCommands = (): AcpRegistryAvailableCommands => ({
218 slashCommands: [],
219 skills: [],
220});
221​
222export function normalizeAcpRegistryCommands(
223 commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>,
224): AcpRegistryAvailableCommands {
225 const seen = new Set<string>();
226 const slashCommands: Array<ServerProviderSlashCommand> = [];
227 const skills: Array<ServerProviderSkill> = [];
228 for (const command of commands) {
229 const name = boundedOpaqueValue(command.name, MAX_ID_LENGTH);
230 if (name === undefined) continue;
231 const key = name.toLowerCase();
232 if (seen.has(key)) continue;
233 seen.add(key);
234 const description = boundedText(command.description, MAX_DESCRIPTION_LENGTH);
235 const hint = command.input ? boundedText(command.input.hint, MAX_DESCRIPTION_LENGTH) : "";
236 if (name.startsWith("$")) {
237 const skillName = boundedOpaqueValue(name.slice(1), MAX_ID_LENGTH);
238 if (skillName === undefined) continue;
239 skills.push({
240 name: skillName,
241 ...(description ? { description } : {}),
242 path: `acp://skill/${encodeURIComponent(skillName)}`,
243 scope: "agent",
244 enabled: true,
245 });
246 } else {
247 slashCommands.push({
248 name,
249 ...(description ? { description } : {}),
250 ...(hint ? { input: { hint } } : {}),
251 });
252 }
253 }
254 return { slashCommands, skills };
256​
257export function acpRegistryProbeResult(
258 instanceId: ProviderInstanceId,
259 started: AcpSessionRuntime.AcpSessionRuntimeStartResult,
260 icon: string | null = null,
261 spawn?: AcpRegistryAuthSpawnContext,
262): AcpRegistryProbeResult {
263 const liveConfiguration = normalizeAcpRegistryLiveConfiguration(
264 started.sessionSetupResult.configOptions ?? [],
265 parseSessionModeState(started.sessionSetupResult),
266 started.sessionSetupResult.models,
267 );
268 return AcpRegistryProbeResult.make({
269 instanceId,
270 ready: true,
271 icon,
272 authMethods: normalizeAcpRegistryAuthMethods(started.initializeResult.authMethods, spawn),
273 sessionManagement: {
274 canList: started.initializeResult.agentCapabilities?.sessionCapabilities?.list != null,
275 canLoad: started.initializeResult.agentCapabilities?.loadSession === true,
276 canResume: started.initializeResult.agentCapabilities?.sessionCapabilities?.resume != null,
277 canLogout: started.initializeResult.agentCapabilities?.auth?.logout != null,
278 canDelete: started.initializeResult.agentCapabilities?.sessionCapabilities?.delete != null,
279 canConfigureProviders: started.initializeResult.agentCapabilities?.providers != null,
280 },
281 ...liveConfiguration,
282 });
284​
285export function acpRegistryProbeFailure(
286 error: EffectAcpErrors.AcpError,
287 authMethods: ReadonlyArray<AcpRegistryProbeAuthMethod> = [],
288 authAction?: AcpRegistryUrlAuthAction,
289): AcpRegistryOperationError {
290 const detail = error._tag === "AcpTransportError" && error.detail ? error.detail : error.message;
291 const authenticationFailed =
292 (error._tag === "AcpRequestError" && error.code === -32000) ||
293 /\b(?:authenticat(?:e|ed|es|ing|ion)|credentials?|log(?:[\s-]+)?in)\b/iu.test(detail);
294 return new AcpRegistryOperationError({
295 reason: authenticationFailed ? "authentication_failed" : "probe_failed",
296 message: authenticationFailed
297 ? "The ACP agent could not complete authentication."
298 : "The ACP agent could not create a test session.",
299 cause: error,
300 ...(authMethods.length > 0 ? { authMethods } : {}),
301 ...(authAction === undefined ? {} : { authAction }),
302 });
304​
305export interface AcpRegistryConfigurationProbeInput {
306 readonly instanceId: ProviderInstanceId;
307 readonly settings: AcpRegistrySettings;
308 readonly cwd: string;
309 readonly environment: NodeJS.ProcessEnv;
311​
312type AcpRegistryManagementInput = AcpRegistryConfigurationProbeInput;
313​
314export interface AcpRegistryConfigurationProbeResult {
315 readonly probe: AcpRegistryProbeResult;
316 readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
317 readonly skills: ReadonlyArray<ServerProviderSkill>;
319​
320export type AcpRegistryConfigurationProbe<Requirements = never> = (
321 input: AcpRegistryConfigurationProbeInput,
322) => Effect.Effect<AcpRegistryConfigurationProbeResult, AcpRegistryOperationError, Requirements>;
323​
324export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeConfiguration")(
325 function* (
326 input: AcpRegistryConfigurationProbeInput,
327 ): Effect.fn.Return<
328 AcpRegistryConfigurationProbeResult,
329 AcpRegistryOperationError,
330 | AcpRegistrySupport.AcpRegistryCatalog
331 | ChildProcessSpawner.ChildProcessSpawner
332 | SecureRandom.SecureRandom
333 > {
334 const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog;
335 const probeTimeoutMs = resolveProviderProbeTimeoutMs(
336 "acp_registry",
337 PROBE_TIMEOUT_MS,
338 input.environment,
339 );
340 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
341 const random = yield* SecureRandom.SecureRandom;
342 const pty = yield* Effect.serviceOption(PtyAdapter.PtyAdapter);
343​
344 const result = yield* Effect.gen(function* () {
345 // Resolution may install packages; bound it by the probe deadline.
346 const resolved = yield* catalog
347 .resolve(input.settings, input.cwd, input.environment)
348 .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError));
349 const authMethodsRef = yield* Ref.make<ReadonlyArray<AcpRegistryProbeAuthMethod>>([]);
350 const authActionRef = yield* Ref.make<AcpRegistryUrlAuthAction | undefined>(undefined);
351 const runtimeCoordinator = yield* Effect.serviceOption(
352 AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator,
353 );
354 const runtimeContext = yield* Layer.build(
355 AcpSessionRuntime.layer({
356 spawn: resolved.spawn,
357 cwd: input.cwd,
358 clientCapabilities: {
359 auth: { terminal: Option.isSome(pty) },
360 elicitation: { url: {} },
361 fs: { readTextFile: false, writeTextFile: false },
362 terminal: false,
363 },
364 clientInfo: { name: "t3-code-provider-test", version: "0.0.0" },
365 authenticateOnAuthRequired: false,
366 onInitialized: (initializeResult) =>
367 Ref.set(
368 authMethodsRef,
369 normalizeAcpRegistryAuthMethods(initializeResult.authMethods, {
370 command: resolved.spawn.command,
371 args: resolved.spawn.args,
372 }),
373 ),
374 ...(input.settings.authMethodId ? { authMethodId: input.settings.authMethodId } : {}),
375 }).pipe(
376 Layer.provide(
377 Layer.mergeAll(
378 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
379 Layer.succeed(SecureRandom.SecureRandom, random),
380 ),
381 ),
382 ),
383 );
384 const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
385 Effect.provide(runtimeContext),
386 );
387 const commandsRef = yield* Ref.make<AcpRegistryAvailableCommands>(
388 emptyAcpRegistryAvailableCommands(),
389 );
390 const commandsAdvertised = yield* Deferred.make<void>();
391 yield* runtime.handleElicitation((request) =>
392 Effect.gen(function* () {
393 if (
394 request.mode !== "url" ||
395 !("url" in request) ||
396 !("elicitationId" in request) ||
397 typeof request.url !== "string" ||
398 typeof request.elicitationId !== "string"
399 ) {
400 return { action: "decline" } as const;
401 }
402 const url = normalizeAcpRegistryWebUrl(request.url);
403 const elicitationId = boundedOpaqueValue(request.elicitationId, MAX_ID_LENGTH);
404 if (url === undefined || elicitationId === undefined) {
405 return { action: "decline" } as const;
406 }
407 const action: AcpRegistryUrlAuthAction = {
408 elicitationId,
409 url,
410 message: boundedText(request.message, MAX_DESCRIPTION_LENGTH),
411 };
412 yield* Ref.set(authActionRef, action);
413 const accepted = yield* Option.match(runtimeCoordinator, {
414 onNone: () => Effect.succeed(false),
415 onSome: (coordinator) => coordinator.requestUrlAuthentication(input.instanceId, action),
416 });
417 return accepted ? ({ action: "accept" } as const) : ({ action: "decline" } as const);
418 }),
419 );
420 yield* runtime.handleSessionUpdate((notification) => {
421 const update = notification.update;
422 return update.sessionUpdate === "available_commands_update"
423 ? Ref.set(commandsRef, normalizeAcpRegistryCommands(update.availableCommands)).pipe(
424 Effect.andThen(Deferred.succeed(commandsAdvertised, undefined)),
425 Effect.asVoid,
426 )
427 : Effect.void;
428 });
429 const startResult = yield* Effect.result(runtime.start());
430 if (Result.isFailure(startResult)) {
431 return yield* acpRegistryProbeFailure(
432 startResult.failure,
433 yield* Ref.get(authMethodsRef),
434 yield* Ref.get(authActionRef),
435 );
436 }
437 const started = startResult.success;
438 // Agents that persist sessions (OpenCode) would otherwise keep one empty session per probe.
439 // The runtime only sends session/delete when the agent advertises it.
440 yield* Effect.addFinalizer(() =>
441 runtime
442 .deleteSession(started.sessionId)
443 .pipe(Effect.timeoutOption(PROBE_SESSION_DELETE_TIMEOUT), Effect.ignore),
444 );
445 yield* Deferred.await(commandsAdvertised).pipe(
446 Effect.timeoutOption(COMMAND_ADVERTISEMENT_GRACE),
447 );
448 return { resolved, started, commands: yield* Ref.get(commandsRef) };
449 }).pipe(
450 Effect.scoped,
451 Effect.timeoutOrElse({
452 duration: probeTimeoutMs,
453 orElse: () =>
454 Effect.fail(
455 new AcpRegistryOperationError({
456 reason: "probe_failed",
457 message: `The ACP agent did not resolve and create a test session within ${probeTimeoutMs / 1_000} seconds. Package installation or agent startup may be slow; this check retries on the next provider refresh.`,
458 }),
459 ),
460 }),
461 Effect.mapError((error) =>
462 error._tag === "AcpRegistryOperationError" ? error : acpRegistryProbeFailure(error),
463 ),
464 );
465 return {
466 probe: acpRegistryProbeResult(
467 input.instanceId,
468 result.started,
469 result.resolved.agent?.icon ?? null,
470 {
471 command: result.resolved.spawn.command,
472 args: result.resolved.spawn.args,
473 },
474 ),
475 slashCommands: result.commands.slashCommands,
476 skills: result.commands.skills,
477 };
478 },
479);
480​
481const MANAGEMENT_TIMEOUT = Duration.seconds(60);
482​
483const makeAcpRegistryManagementRuntime = Effect.fn("AcpRegistryProbe.makeManagementRuntime")(
484 function* (input: AcpRegistryManagementInput) {
485 const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog;
486 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
487 const random = yield* SecureRandom.SecureRandom;
488 const runtimeCoordinator = yield* Effect.serviceOption(
489 AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator,
490 );
491 const resolved = yield* catalog
492 .resolve(input.settings, input.cwd, input.environment)
493 .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError));
494 const runtimeContext = yield* Layer.build(
495 AcpSessionRuntime.layer({
496 spawn: resolved.spawn,
497 cwd: input.cwd,
498 clientCapabilities: {
499 auth: { terminal: false },
500 elicitation: { url: {} },
501 fs: { readTextFile: false, writeTextFile: false },
502 terminal: false,
503 },
504 clientInfo: { name: "t3-code-session-manager", version: "0.0.0" },
505 authenticateOnAuthRequired: false,
506 ...(input.settings.authMethodId ? { authMethodId: input.settings.authMethodId } : {}),
507 }).pipe(
508 Layer.provide(
509 Layer.mergeAll(
510 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
511 Layer.succeed(SecureRandom.SecureRandom, random),
512 ),
513 ),
514 ),
515 );
516 const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
517 Effect.provide(runtimeContext),
518 );
519 yield* runtime.handleElicitation((request) =>
520 Effect.gen(function* () {
521 if (
522 request.mode !== "url" ||
523 !("url" in request) ||
524 !("elicitationId" in request) ||
525 typeof request.url !== "string" ||
526 typeof request.elicitationId !== "string"
527 ) {
528 return { action: "decline" } as const;
529 }
530 const url = normalizeAcpRegistryWebUrl(request.url);
531 const elicitationId = boundedOpaqueValue(request.elicitationId, MAX_ID_LENGTH);
532 if (url === undefined || elicitationId === undefined) {
533 return { action: "decline" } as const;
534 }
535 const action: AcpRegistryUrlAuthAction = {
536 elicitationId,
537 url,
538 message: boundedText(request.message, MAX_DESCRIPTION_LENGTH),
539 };
540 const accepted = yield* Option.match(runtimeCoordinator, {
541 onNone: () => Effect.succeed(false),
542 onSome: (coordinator) => coordinator.requestUrlAuthentication(input.instanceId, action),
543 });
544 return accepted ? ({ action: "accept" } as const) : ({ action: "decline" } as const);
545 }),
546 );
547 return runtime;
548 },
549);
550​
551function managementFailure(
552 operation:
553 | "list"
554 | "delete"
555 | "providers_list"
556 | "providers_set"
557 | "providers_disable"
558 | "logout",
559 error: EffectAcpErrors.AcpError | AcpRegistryOperationError,
560): AcpRegistryOperationError {
561 if (error._tag === "AcpRegistryOperationError") return error;
562 if (error._tag === "AcpRequestError" && error.code === -32601) {
563 if (operation === "list") {
564 return new AcpRegistryOperationError({
565 reason: "session_list_unsupported",
566 message: "The ACP agent does not advertise session listing.",
567 cause: error,
568 });
569 }
570 if (operation === "delete") {
571 return new AcpRegistryOperationError({
572 reason: "session_delete_unsupported",
573 message: "The ACP agent does not advertise session deletion.",
574 cause: error,
575 });
576 }
577 if (operation.startsWith("providers_")) {
578 return new AcpRegistryOperationError({
579 reason: "providers_unsupported",
580 message: "The ACP agent does not advertise provider configuration.",
581 cause: error,
582 });
583 }
584 return new AcpRegistryOperationError({
585 reason: "logout_unsupported",
586 message: "The ACP agent does not advertise logout.",
587 cause: error,
588 });
589 }
590 const classified = acpRegistryProbeFailure(error);
591 if (classified.reason === "authentication_failed") return classified;
592 const failure =
593 operation === "delete"
594 ? { reason: "session_delete_failed" as const, message: "Could not delete the ACP session." }
595 : operation === "providers_list"
596 ? { reason: "providers_list_failed" as const, message: "Could not list ACP providers." }
597 : operation === "providers_set" || operation === "providers_disable"
598 ? {
599 reason: "provider_configuration_failed" as const,
600 message: "Could not update the ACP provider configuration.",
601 }
602 : undefined;
603 return failure === undefined
604 ? classified
605 : new AcpRegistryOperationError({ ...failure, cause: error });
607​
608export const listAcpRegistrySessions = Effect.fn("AcpRegistryProbe.listSessions")(
609 function* (input: AcpRegistryManagementInput & { readonly cursor?: string }) {
610 const runtime = yield* makeAcpRegistryManagementRuntime(input);
611 const initialized = yield* runtime.initialize();
612 const listed = yield* runtime.listSessions(input.cursor);
613 const sessions = listed.sessions.slice(0, 256).flatMap((session) => {
614 const sessionId = boundedOpaqueValue(session.sessionId, 1_024);
615 const cwd = boundedOpaqueValue(session.cwd, 4_096);
616 if (sessionId === undefined || cwd === undefined) return [];
617 return [
618 {
619 sessionId,
620 cwd,
621 additionalDirectories: (session.additionalDirectories ?? [])
622 .flatMap((directory) => {
623 const normalized = boundedOpaqueValue(directory, 4_096);
624 return normalized === undefined ? [] : [normalized];
625 })
626 .slice(0, 32),
627 title:
628 session.title === undefined || session.title === null
629 ? null
630 : boundedText(session.title, 1_024) || null,
631 updatedAt:
632 session.updatedAt === undefined || session.updatedAt === null
633 ? null
634 : boundedText(session.updatedAt, 128) || null,
635 importedThreadId: null,
636 },
637 ];
638 });
639 return AcpRegistryListSessionsResult.make({
640 sessions,
641 nextCursor:
642 listed.nextCursor === undefined || listed.nextCursor === null
643 ? null
644 : boundedText(listed.nextCursor, 2_048) || null,
645 canLoad: initialized.agentCapabilities?.loadSession === true,
646 canResume: initialized.agentCapabilities?.sessionCapabilities?.resume != null,
647 canDelete: initialized.agentCapabilities?.sessionCapabilities?.delete != null,
648 });
649 },
650 (effect) =>
651 effect.pipe(
652 Effect.scoped,
653 Effect.timeoutOrElse({
654 duration: MANAGEMENT_TIMEOUT,
655 orElse: () =>
656 Effect.fail(
657 new AcpRegistryOperationError({
658 reason: "probe_failed",
659 message: "The ACP session list request timed out.",
660 }),
661 ),
662 }),
663 Effect.mapError((error) => managementFailure("list", error)),
664 ),
665);
666​
667export const deleteAcpRegistrySession = Effect.fn("AcpRegistryProbe.deleteSession")(
668 function* (input: AcpRegistryManagementInput & { readonly sessionId: string }) {
669 const runtime = yield* makeAcpRegistryManagementRuntime(input);
670 yield* runtime.deleteSession(input.sessionId);
671 },
672 (effect) =>
673 effect.pipe(
674 Effect.scoped,
675 Effect.timeoutOrElse({
676 duration: MANAGEMENT_TIMEOUT,
677 orElse: () =>
678 Effect.fail(
679 new AcpRegistryOperationError({
680 reason: "session_delete_failed",
681 message: "The ACP session delete request timed out.",
682 }),
683 ),
684 }),
685 Effect.mapError((error) => managementFailure("delete", error)),
686 ),
687);
688​
689export const listAcpRegistryProviders = Effect.fn("AcpRegistryProbe.listProviders")(
690 function* (input: AcpRegistryManagementInput) {
691 const runtime = yield* makeAcpRegistryManagementRuntime(input);
692 const listed = yield* runtime.listProviders;
693 return AcpRegistryListProvidersResult.make({
694 providers: listed.providers.slice(0, 64).flatMap((provider) => {
695 const providerId = boundedOpaqueValue(provider.providerId, 256);
696 if (providerId === undefined) return [];
697 const supported = provider.supported
698 .flatMap((protocol) => {
699 const value = boundedOpaqueValue(protocol, 64);
700 return value === undefined ? [] : [value];
701 })
702 .slice(0, 16);
703 const current = provider.current;
704 const currentApiType =
705 current == null ? undefined : boundedOpaqueValue(current.apiType, 64);
706 const currentBaseUrl =
707 current == null ? undefined : normalizeAcpRegistryWebUrl(current.baseUrl);
708 return [
709 {
710 providerId,
711 supported,
712 required: provider.required,
713 current:
714 currentApiType === undefined || currentBaseUrl === undefined
715 ? null
716 : { apiType: currentApiType, baseUrl: currentBaseUrl },
717 },
718 ];
719 }),
720 });
721 },
722 (effect) =>
723 effect.pipe(
724 Effect.scoped,
725 Effect.timeoutOrElse({
726 duration: MANAGEMENT_TIMEOUT,
727 orElse: () =>
728 Effect.fail(
729 new AcpRegistryOperationError({
730 reason: "providers_list_failed",
731 message: "The ACP provider list request timed out.",
732 }),
733 ),
734 }),
735 Effect.mapError((error) => managementFailure("providers_list", error)),
736 ),
737);
738​
739export const setAcpRegistryProvider = Effect.fn("AcpRegistryProbe.setProvider")(
740 function* (input: AcpRegistryManagementInput & EffectAcpSchema.SetProviderRequest) {
741 const runtime = yield* makeAcpRegistryManagementRuntime(input);
742 yield* runtime.setProvider({
743 providerId: input.providerId,
744 apiType: input.apiType,
745 baseUrl: input.baseUrl,
746 ...(input.headers === undefined ? {} : { headers: input.headers }),
747 });
748 },
749 (effect) =>
750 effect.pipe(
751 Effect.scoped,
752 Effect.timeoutOrElse({
753 duration: MANAGEMENT_TIMEOUT,
754 orElse: () =>
755 Effect.fail(
756 new AcpRegistryOperationError({
757 reason: "provider_configuration_failed",
758 message: "The ACP provider configuration request timed out.",
759 }),
760 ),
761 }),
762 Effect.mapError((error) => managementFailure("providers_set", error)),
763 ),
764);
765​
766export const disableAcpRegistryProvider = Effect.fn("AcpRegistryProbe.disableProvider")(
767 function* (input: AcpRegistryManagementInput & { readonly providerId: string }) {
768 const runtime = yield* makeAcpRegistryManagementRuntime(input);
769 yield* runtime.disableProvider(input.providerId);
770 },
771 (effect) =>
772 effect.pipe(
773 Effect.scoped,
774 Effect.timeoutOrElse({
775 duration: MANAGEMENT_TIMEOUT,
776 orElse: () =>
777 Effect.fail(
778 new AcpRegistryOperationError({
779 reason: "provider_configuration_failed",
780 message: "The ACP provider disable request timed out.",
781 }),
782 ),
783 }),
784 Effect.mapError((error) => managementFailure("providers_disable", error)),
785 ),
786);
787​
788export const logoutAcpRegistry = Effect.fn("AcpRegistryProbe.logout")(
789 function* (input: AcpRegistryManagementInput) {
790 const runtime = yield* makeAcpRegistryManagementRuntime(input);
791 yield* runtime.logout;
792 },
793 (effect) =>
794 effect.pipe(
795 Effect.scoped,
796 Effect.timeoutOrElse({
797 duration: MANAGEMENT_TIMEOUT,
798 orElse: () =>
799 Effect.fail(
800 new AcpRegistryOperationError({
801 reason: "probe_failed",
802 message: "The ACP logout request timed out.",
803 }),
804 ),
805 }),
806 Effect.mapError((error) => managementFailure("logout", error)),
807 ),
808);