1import * as dialogflow from "@distilled.cloud/gcp/dialogflow_v3";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 collectionParent,
13 DEFAULT_LOCATION,
14 encodeOwnership,
15 encodeOwnershipLine,
16 expandAgent,
17 fingerprint,
18 hasOwnershipMarker,
19 internalLabels,
20 lastSegment,
21 locationOf,
22 namedAgents,
23 normalizeLocation,
24 ownedByAlchemy,
25 parentOf,
26 parseOwnership,
27 projectOf,
28 toResourceId,
29 updateMaskOf,
30 retryQuota,
31} from "./internal.ts";
32
33export type ToolFunctionSpec = {
34 /** JSON Schema for the tool input. */
35 inputSchema?: Record<string, unknown>;
36 /** JSON Schema for the tool output. */
37 outputSchema?: Record<string, unknown>;
38};
39
40export type ToolOpenApiSpec = {
41 /** OpenAPI schema as YAML or JSON text. */
42 textSchema?: string;
43};
44
45export type ToolDataStoreSpec = {
46 /** Data store connections. */
47 dataStoreConnections?: Array<{
48 dataStore?: string;
49 dataStoreType?: string;
50 documentProcessingMode?: string;
51 }>;
52};
53
54export type AgentsToolProps = {
55 /**
56 * Parent agent resource name
57 * `projects/{project}/locations/{location}/agents/{agent}`.
58 * Immutable — changing it replaces the tool.
59 */
60 agent: string;
61 /**
62 * Tool id (the `{tool}` segment). Server-assigned when omitted.
63 * Immutable — changing it replaces the tool.
64 */
65 toolId?: string;
66 /**
67 * Location used when `agent` is an id rather than a resource name.
68 * Immutable — changing it replaces the tool.
69 * @default "global"
70 */
71 location?: string;
72 /**
73 * Human-readable name. Tools have no labels field, so Alchemy stamps
74 * ownership into `displayName` and `description`.
75 */
76 displayName?: string;
77 /**
78 * Human-readable description. Required by the API; Alchemy stamps
79 * ownership into this field.
80 */
81 description?: string;
82 /**
83 * Client-side function spec. Used when neither `openApiSpec` nor
84 * `dataStoreSpec` is set.
85 */
86 functionSpec?: ToolFunctionSpec;
87 /** OpenAPI tool spec. Mutually exclusive with the other specs. */
88 openApiSpec?: ToolOpenApiSpec;
89 /** Data-store tool spec. Mutually exclusive with the other specs. */
90 dataStoreSpec?: ToolDataStoreSpec;
91};
92
93export type AgentsTool = Resource<
94 "GCP.Dialogflow.AgentsTool",
95 AgentsToolProps,
96 {
97 /** Full resource name `.../agents/{agent}/tools/{tool}`. */
98 name: string;
99 /** Tool id (last path segment). */
100 toolId: string;
101 /** Parent agent resource name. */
102 agent: string;
103 /** Location id. */
104 location: string;
105 /** Project id. */
106 project: string;
107 /** User display name with the Alchemy ownership prefix stripped. */
108 displayName: string | undefined;
109 /** User description with the Alchemy ownership prefix stripped. */
110 description: string | undefined;
111 /** Function spec. */
112 functionSpec: ToolFunctionSpec | undefined;
113 /** OpenAPI spec. */
114 openApiSpec: ToolOpenApiSpec | undefined;
115 /** Data-store spec. */
116 dataStoreSpec: ToolDataStoreSpec | undefined;
117 /** Tool type (`CUSTOMIZED_TOOL`, `BUILTIN_TOOL`). */
118 toolType: string | undefined;
119 },
120 never,
121 Providers
122>;
123
124/**
125 * A Dialogflow CX tool (function, OpenAPI, or data-store).
126 *
127 * Tools have no labels field — Alchemy stamps ownership into
128 * `displayName` and `description` so `list` / nuke can find them. Parent
129 * agent, id, and spec kind are immutable. Display name, description, and
130 * spec payload update in place.
131 *
132 * ### Creating a Tool
133 * **Example:** Function tool
134 * ```typescript
135 * const tool = yield* GCP.Dialogflow.AgentsTool("Lookup", {
136 * agent: agentName,
137 * displayName: "lookup",
138 * description: "Look up an order.",
139 * functionSpec: {
140 * inputSchema: {
141 * type: "object",
142 * properties: { orderId: { type: "string" } },
143 * },
144 * outputSchema: {
145 * type: "object",
146 * properties: { status: { type: "string" } },
147 * },
148 * },
149 * });
150 * ```
151 *
152 * @resource
153 * @category Dialogflow
154 */
155export const AgentsTool = Resource<AgentsTool>("GCP.Dialogflow.AgentsTool");
156
157export class AgentsToolNotResolved extends Data.TaggedError(
158 "GCP.Dialogflow.AgentsToolNotResolved",
159)<{
160 name: string;
161}> {}
162
163const DEFAULT_FUNCTION_SPEC: ToolFunctionSpec = {
164 inputSchema: {
165 type: "object",
166 properties: { query: { type: "string" } },
167 },
168 outputSchema: {
169 type: "object",
170 properties: { result: { type: "string" } },
171 },
172};
173
174const resourceName = (agent: string, toolId: string) =>
175 `${agent}/tools/${toolId}`;
176
177const specKind = (props: {
178 openApiSpec?: unknown;
179 dataStoreSpec?: unknown;
180 functionSpec?: unknown;
181}) => {
182 if (props.openApiSpec) return "openapi";
183 if (props.dataStoreSpec) return "datastore";
184 return "function";
185};
186
187const functionSpecOf = (
188 spec: dialogflow.GoogleCloudDialogflowCxV3ToolFunctionTool | undefined,
189): ToolFunctionSpec | undefined => {
190 if (spec === undefined) return undefined;
191 return {
192 inputSchema: spec.inputSchema as Record<string, unknown> | undefined,
193 outputSchema: spec.outputSchema as Record<string, unknown> | undefined,
194 };
195};
196
197const toAttrs = (
198 tool: dialogflow.GoogleCloudDialogflowCxV3Tool,
199 project: string,
200 agentHint?: string,
201) => {
202 const name = tool.name ?? "";
203 return {
204 name,
205 toolId: lastSegment(name),
206 agent: name.includes("/tools/")
207 ? collectionParent(name, "agents")
208 : (agentHint ?? parentOf(name)),
209 location: locationOf(name),
210 project: projectOf(name) || project,
211 displayName: parseOwnership(tool.displayName).text,
212 description: parseOwnership(tool.description).text,
213 functionSpec: functionSpecOf(tool.functionSpec),
214 openApiSpec: tool.openApiSpec
215 ? { textSchema: tool.openApiSpec.textSchema }
216 : undefined,
217 dataStoreSpec: tool.dataStoreSpec
218 ? { dataStoreConnections: tool.dataStoreSpec.dataStoreConnections }
219 : undefined,
220 toolType: tool.toolType,
221 };
222};
223
224const getByName = (name: string) =>
225 name.length === 0
226 ? Effect.succeed(undefined)
227 : dialogflow
228 .getProjectsLocationsAgentsTools({ name })
229 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
230
231const listAt = (parent: string, project: string) =>
232 dialogflow.listProjectsLocationsAgentsTools
233 .pages({ parent, pageSize: 100 })
234 .pipe(
235 Stream.flatMap((page) => Stream.fromIterable(page.tools ?? [])),
236 Stream.filter(
237 (tool) =>
238 hasOwnershipMarker(tool.displayName) ||
239 hasOwnershipMarker(tool.description),
240 ),
241 Stream.map((tool) => toAttrs(tool, project, parent)),
242 Stream.runCollect,
243 Effect.map((chunk) => Array.from(chunk)),
244 Effect.catchTag("NotFound", () => Effect.succeed([])),
245 );
246
247const findByDisplayName = (parent: string, displayName: string) =>
248 dialogflow.listProjectsLocationsAgentsTools
249 .pages({ parent, pageSize: 100 })
250 .pipe(
251 Stream.flatMap((page) => Stream.fromIterable(page.tools ?? [])),
252 Stream.filter((tool) => tool.displayName === displayName),
253 Stream.runHead,
254 Effect.map((option) =>
255 option._tag === "Some" ? option.value : undefined,
256 ),
257 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
258 );
259
260const toBody = (
261 news: AgentsToolProps,
262 displayName: string,
263 description: string,
264): dialogflow.GoogleCloudDialogflowCxV3Tool => {
265 const kind = specKind(news);
266 return {
267 displayName,
268 description,
269 functionSpec:
270 kind === "function"
271 ? (news.functionSpec ?? DEFAULT_FUNCTION_SPEC)
272 : undefined,
273 openApiSpec: kind === "openapi" ? news.openApiSpec : undefined,
274 dataStoreSpec: kind === "datastore" ? news.dataStoreSpec : undefined,
275 };
276};
277
278export const AgentsToolProvider = () =>
279 Provider.succeed(AgentsTool, {
280 stables: ["name", "toolId", "agent", "location", "project"],
281
282 diff: Effect.fn(function* ({ news, olds, output }) {
283 if (!isResolved(news)) return undefined;
284 const previousAgent = olds?.agent ?? output?.agent;
285 if (previousAgent !== undefined && news.agent !== previousAgent) {
286 return { action: "replace" as const, deleteFirst: false };
287 }
288 const previousId = olds?.toolId ?? output?.toolId;
289 if (
290 previousId !== undefined &&
291 news.toolId !== undefined &&
292 news.toolId !== previousId
293 ) {
294 return { action: "replace" as const, deleteFirst: false };
295 }
296 const previousKind = specKind(olds ?? output ?? {});
297 const nextKind = specKind(news);
298 if (previousKind !== nextKind && previousKind !== "function") {
299 return { action: "replace" as const, deleteFirst: true };
300 }
301 return undefined;
302 }),
303
304 read: Effect.fn(function* ({ id, olds, output }) {
305 const env = yield* GcpEnvironment.current;
306 const location = normalizeLocation(olds?.location ?? output?.location);
307 const agent = olds?.agent
308 ? expandAgent(olds.agent, env.project, location)
309 : output?.agent;
310 const toolId = yield* toResourceId(id, olds?.toolId, output?.toolId);
311 const name =
312 output?.name ??
313 (agent !== undefined ? resourceName(agent, toolId) : "");
314 let existing = yield* getByName(name);
315 if (existing === undefined && agent !== undefined) {
316 const ownership = yield* internalLabels(id);
317 existing = yield* findByDisplayName(
318 agent,
319 encodeOwnershipLine(ownership, olds?.displayName),
320 );
321 }
322 if (existing === undefined) return undefined;
323 const attrs = toAttrs(existing, env.project, agent);
324 const named = yield* ownedByAlchemy(id, existing.displayName);
325 const described = yield* ownedByAlchemy(id, existing.description);
326 return named || described ? attrs : Unowned(attrs);
327 }),
328
329 list: () =>
330 Effect.gen(function* () {
331 const env = yield* GcpEnvironment.current;
332 const agents = yield* namedAgents(env.project);
333 const pages = yield* Effect.forEach(
334 agents,
335 (agent) => listAt(agent.name, env.project),
336 { concurrency: 4 },
337 );
338 return pages.flat();
339 }),
340
341 reconcile: Effect.fn(function* ({ id, news, output }) {
342 const env = yield* GcpEnvironment.current;
343 const location = normalizeLocation(
344 news.location ?? output?.location ?? DEFAULT_LOCATION,
345 );
346 const agent = expandAgent(news.agent, env.project, location);
347 const toolId = yield* toResourceId(id, news.toolId, output?.toolId);
348 const name = output?.name ?? resourceName(agent, toolId);
349 const ownership = yield* internalLabels(id);
350 const displayName = encodeOwnershipLine(ownership, news.displayName);
351 const description = encodeOwnership(ownership, news.description);
352 const body = toBody(news, displayName, description);
353
354 let current = yield* getByName(output?.name ?? name);
355 if (current === undefined) {
356 current = yield* findByDisplayName(agent, displayName);
357 }
358
359 if (current === undefined) {
360 const created = yield* dialogflow
361 .createProjectsLocationsAgentsTools({
362 parent: agent,
363 body,
364 })
365 .pipe(
366 Effect.catchTag("Conflict", () =>
367 findByDisplayName(agent, displayName),
368 ),
369 );
370 current = created ?? undefined;
371 }
372
373 if (current === undefined) {
374 return yield* new AgentsToolNotResolved({ name });
375 }
376
377 const currentName = current.name ?? name;
378 const displayChanged = (current.displayName ?? "") !== displayName;
379 const descriptionChanged = (current.description ?? "") !== description;
380 const functionChanged =
381 fingerprint(functionSpecOf(current.functionSpec)) !==
382 fingerprint(body.functionSpec);
383 const openApiChanged =
384 fingerprint(current.openApiSpec) !== fingerprint(body.openApiSpec);
385 const dataStoreChanged =
386 fingerprint(current.dataStoreSpec) !== fingerprint(body.dataStoreSpec);
387
388 if (
389 displayChanged ||
390 descriptionChanged ||
391 functionChanged ||
392 openApiChanged ||
393 dataStoreChanged
394 ) {
395 current = yield* dialogflow.patchProjectsLocationsAgentsTools({
396 name: currentName,
397 updateMask: updateMaskOf(
398 displayChanged ? "display_name" : undefined,
399 descriptionChanged ? "description" : undefined,
400 functionChanged && body.functionSpec ? "function_spec" : undefined,
401 openApiChanged && body.openApiSpec ? "open_api_spec" : undefined,
402 dataStoreChanged && body.dataStoreSpec
403 ? "data_store_spec"
404 : undefined,
405 ),
406 body: { ...body, name: currentName },
407 });
408 }
409
410 return toAttrs(current, env.project, agent);
411 }, retryQuota),
412
413 delete: Effect.fn(function* ({ output }) {
414 yield* dialogflow
415 .deleteProjectsLocationsAgentsTools({
416 name: output.name,
417 force: true,
418 })
419 .pipe(Effect.catchTag("NotFound", () => Effect.void));
420 }, retryQuota),
421 });