1import * as ces from "@distilled.cloud/gcp/ces_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 collectPages,
12 expandApp,
13 forEachApp,
14 normalizeLocation,
15 parseResourceName,
16 replaceOnIdentity,
17 retryTransient,
18 sameJson,
19 sameText,
20 toPhysicalId,
21 toolKind,
22 unstampToolDescription,
23 updateMaskOf,
24 waitUntilGone,
25} from "./internal.ts";
26
27export type AppsToolProps = {
28 /**
29 * Parent CES app. Full name
30 * `projects/{project}/locations/{location}/apps/{app}` or the app id
31 * (combined with `location`). Immutable — changing it replaces the
32 * tool.
33 */
34 app: string;
35 /**
36 * Region used when `app` is a bare id.
37 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
38 */
39 location?: string;
40 /**
41 * Tool id. If omitted, a unique name is generated. Immutable —
42 * changing it replaces the tool.
43 */
44 toolId?: string;
45 /**
46 * Execution type (`SYNCHRONOUS`, `ASYNCHRONOUS`).
47 */
48 executionType?: string;
49 /**
50 * Tool timeout (e.g. `"30s"`).
51 */
52 timeout?: string;
53 /**
54 * Fake-mode configuration.
55 */
56 toolFakeConfig?: ces.ToolFakeConfig;
57 /**
58 * Client-side function. Used when no other tool variant is set.
59 */
60 clientFunction?: ces.ClientFunction;
61 /** Python function tool. */
62 pythonFunction?: ces.PythonFunction;
63 /** Google Search grounding tool. */
64 googleSearchTool?: ces.GoogleSearchTool;
65 /** OpenAPI tool. */
66 openApiTool?: ces.OpenApiTool;
67 /** Widget tool. */
68 widgetTool?: ces.WidgetTool;
69 /** File-search tool. */
70 fileSearchTool?: ces.FileSearchTool;
71 /** Vertex AI Search data-store tool. */
72 dataStoreTool?: ces.DataStoreTool;
73 /** Nested-agent tool. */
74 agentTool?: ces.AgentTool;
75 /** Remote A2A agent tool. */
76 remoteAgentTool?: ces.RemoteAgentTool;
77 /** Integration Connectors tool. */
78 connectorTool?: ces.ConnectorTool;
79 /** Built-in system tool. */
80 systemTool?: ces.SystemTool;
81};
82
83export type AppsTool = Resource<
84 "GCP.CES.AppsTool",
85 AppsToolProps,
86 {
87 /** Full resource name `.../apps/{app}/tools/{tool}`. */
88 name: string;
89 /** Tool id (last path segment). */
90 toolId: string;
91 /** Parent app resource name. */
92 app: string;
93 /** Location id. */
94 location: string;
95 /** Project id. */
96 project: string;
97 /** Server-derived display name. */
98 displayName: string | undefined;
99 /** Execution type. */
100 executionType: string | undefined;
101 /** Timeout. */
102 timeout: string | undefined;
103 /** Fake-mode configuration. */
104 toolFakeConfig: ces.ToolFakeConfig | undefined;
105 /** Client function (ownership prefix stripped from description). */
106 clientFunction: ces.ClientFunction | undefined;
107 /** Python function. */
108 pythonFunction: ces.PythonFunction | undefined;
109 /** Google Search tool. */
110 googleSearchTool: ces.GoogleSearchTool | undefined;
111 /** OpenAPI tool. */
112 openApiTool: ces.OpenApiTool | undefined;
113 /** Widget tool. */
114 widgetTool: ces.WidgetTool | undefined;
115 /** File-search tool. */
116 fileSearchTool: ces.FileSearchTool | undefined;
117 /** Data-store tool. */
118 dataStoreTool: ces.DataStoreTool | undefined;
119 /** Nested-agent tool. */
120 agentTool: ces.AgentTool | undefined;
121 /** Remote agent tool. */
122 remoteAgentTool: ces.RemoteAgentTool | undefined;
123 /** Connector tool. */
124 connectorTool: ces.ConnectorTool | undefined;
125 /** System tool. */
126 systemTool: ces.SystemTool | undefined;
127 /** RFC3339 creation timestamp. */
128 createTime: string | undefined;
129 /** RFC3339 last-update timestamp. */
130 updateTime: string | undefined;
131 /** Server-assigned etag. */
132 etag: string | undefined;
133 },
134 never,
135 Providers
136>;
137
138/**
139 * A Customer Engagement Suite tool inside an app (client function,
140 * Python, OpenAPI, Google Search, widget, and related variants).
141 *
142 * Tools have no labels field, and a tool's description tells the model
143 * when to call it, so Alchemy writes no ownership marker: a generated
144 * tool id identifies the tool, and `read` reports an explicit id it has no
145 * state for as unowned. Parent app, location, tool id, and tool kind are
146 * immutable.
147 *
148 * ### Creating a Tool
149 * **Example:** Client function
150 * ```typescript
151 * const tool = yield* GCP.CES.AppsTool("Lookup", {
152 * app: app.name,
153 * clientFunction: {
154 * name: "lookup_order",
155 * description: "Look up an order by id.",
156 * parameters: {
157 * type: "OBJECT",
158 * properties: { orderId: { type: "STRING" } },
159 * },
160 * },
161 * });
162 * ```
163 *
164 * @resource
165 * @category CES
166 */
167export const AppsTool = Resource<AppsTool>("GCP.CES.AppsTool");
168
169export class AppsToolNotResolved extends Data.TaggedError(
170 "GCP.CES.AppsToolNotResolved",
171)<{
172 name: string;
173}> {}
174
175const resourceName = (app: string, toolId: string) => `${app}/tools/${toolId}`;
176
177const variantOf = (tool: ces.Tool, kind: ReturnType<typeof toolKind>) => {
178 switch (kind) {
179 case "fileSearchTool":
180 return tool.fileSearchTool;
181 case "pythonFunction":
182 return tool.pythonFunction;
183 case "googleSearchTool":
184 return tool.googleSearchTool;
185 case "mcpTool":
186 return tool.mcpTool;
187 case "remoteAgentTool":
188 return tool.remoteAgentTool;
189 case "widgetTool":
190 return tool.widgetTool;
191 case "systemTool":
192 return tool.systemTool;
193 case "openApiTool":
194 return tool.openApiTool;
195 case "connectorTool":
196 return tool.connectorTool;
197 case "dataStoreTool":
198 return tool.dataStoreTool;
199 case "agentTool":
200 return tool.agentTool;
201 default:
202 return tool.clientFunction;
203 }
204};
205
206const toBody = (news: AppsToolProps): ces.Tool => ({
207 executionType: news.executionType,
208 timeout: news.timeout,
209 toolFakeConfig: news.toolFakeConfig,
210 clientFunction: news.clientFunction,
211 pythonFunction: news.pythonFunction,
212 googleSearchTool: news.googleSearchTool,
213 openApiTool: news.openApiTool,
214 widgetTool: news.widgetTool,
215 fileSearchTool: news.fileSearchTool,
216 dataStoreTool: news.dataStoreTool,
217 agentTool: news.agentTool,
218 remoteAgentTool: news.remoteAgentTool,
219 connectorTool: news.connectorTool,
220 systemTool: news.systemTool,
221});
222
223const toAttrs = (
224 tool: ces.Tool,
225 project: string,
226 region: string,
227 appHint?: string,
228) => {
229 const name = tool.name ?? "";
230 const parsed = parseResourceName(name, "tools", region);
231 const unstamped = unstampToolDescription(tool);
232 return {
233 name,
234 toolId: parsed.id,
235 app: name.includes("/tools/") ? parsed.app : (appHint ?? parsed.parent),
236 location: parsed.location,
237 project: parsed.project || project,
238 displayName: tool.displayName,
239 executionType: tool.executionType,
240 timeout: tool.timeout,
241 toolFakeConfig: tool.toolFakeConfig,
242 clientFunction: unstamped.clientFunction,
243 pythonFunction: unstamped.pythonFunction,
244 googleSearchTool: unstamped.googleSearchTool,
245 openApiTool: unstamped.openApiTool,
246 widgetTool: unstamped.widgetTool,
247 fileSearchTool: unstamped.fileSearchTool,
248 dataStoreTool: unstamped.dataStoreTool,
249 agentTool: unstamped.agentTool,
250 remoteAgentTool: unstamped.remoteAgentTool,
251 connectorTool: unstamped.connectorTool,
252 systemTool: unstamped.systemTool,
253 createTime: tool.createTime,
254 updateTime: tool.updateTime,
255 etag: tool.etag,
256 };
257};
258
259const getByName = (name: string) =>
260 name.length === 0
261 ? Effect.succeed(undefined)
262 : ces
263 .getProjectsLocationsAppsTools({ name })
264 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
265
266const listAt = (parent: string, project: string, region: string) =>
267 collectPages(
268 ces.listProjectsLocationsAppsTools.pages({ parent, pageSize: 100 }),
269 (page) => page.tools,
270 ).pipe(
271 Effect.map((tools) =>
272 tools.map((tool) => toAttrs(tool, project, region, parent)),
273 ),
274 );
275
276export const AppsToolProvider = () =>
277 Provider.succeed(AppsTool, {
278 stables: ["name", "toolId", "app", "location", "project", "createTime"],
279
280 diff: Effect.fn(function* ({ news, olds, output }) {
281 if (!isResolved(news)) return undefined;
282 const previousKind = toolKind(olds ?? output ?? {});
283 const nextKind = toolKind(news);
284 return replaceOnIdentity({
285 previousId: olds?.toolId ?? output?.toolId,
286 nextId: news.toolId,
287 previousParent: olds?.app ?? output?.app,
288 nextParent: news.app,
289 extra: previousKind !== nextKind && previousKind !== "clientFunction",
290 });
291 }),
292
293 read: Effect.fn(function* ({ id, olds, output }) {
294 const env = yield* GcpEnvironment.current;
295 const location = normalizeLocation(
296 olds?.location ?? output?.location,
297 env.region,
298 );
299 const app = olds?.app
300 ? expandApp(olds.app, env.project, location)
301 : output?.app;
302 const toolId = yield* toPhysicalId(id, olds?.toolId, output?.toolId);
303 const name =
304 output?.name ?? (app !== undefined ? resourceName(app, toolId) : "");
305 const existing = yield* getByName(name);
306 if (existing === undefined) return undefined;
307 const attrs = toAttrs(existing, env.project, env.region, app);
308 return output !== undefined || olds?.toolId === undefined
309 ? attrs
310 : Unowned(attrs);
311 }),
312
313 list: () =>
314 Effect.gen(function* () {
315 const env = yield* GcpEnvironment.current;
316 return yield* forEachApp(
317 env.project,
318 (parent) => listAt(parent, env.project, env.region),
319 env.region,
320 );
321 }),
322
323 reconcile: Effect.fn(function* ({ id, news, output }) {
324 const env = yield* GcpEnvironment.current;
325 const location = normalizeLocation(
326 news.location ?? output?.location,
327 env.region,
328 );
329 const app = expandApp(news.app, env.project, location);
330 const toolId = yield* toPhysicalId(id, news.toolId, output?.toolId);
331 const name = output?.name ?? resourceName(app, toolId);
332 const body = toBody(news);
333 const kind = toolKind(body);
334
335 let current = yield* getByName(name);
336
337 if (current === undefined) {
338 const created = yield* retryTransient(
339 ces.createProjectsLocationsAppsTools({
340 parent: app,
341 toolId,
342 body,
343 }),
344 ).pipe(Effect.catchTag("Conflict", () => getByName(name)));
345 current = created ?? undefined;
346 }
347
348 if (current === undefined) {
349 return yield* new AppsToolNotResolved({ name });
350 }
351
352 const currentName = current.name ?? name;
353 const executionChanged = !sameText(
354 current.executionType,
355 body.executionType,
356 );
357 const timeoutChanged = !sameText(current.timeout, body.timeout);
358 const fakeChanged = !sameJson(
359 current.toolFakeConfig,
360 body.toolFakeConfig,
361 );
362 const variantChanged = !sameJson(
363 variantOf(current, kind),
364 variantOf(body, kind),
365 );
366
367 if (executionChanged || timeoutChanged || fakeChanged || variantChanged) {
368 current = yield* retryTransient(
369 ces.patchProjectsLocationsAppsTools({
370 name: currentName,
371 updateMask: updateMaskOf(
372 executionChanged ? "execution_type" : undefined,
373 timeoutChanged ? "timeout" : undefined,
374 fakeChanged ? "tool_fake_config" : undefined,
375 variantChanged
376 ? kind === "clientFunction"
377 ? "client_function"
378 : kind === "pythonFunction"
379 ? "python_function"
380 : kind === "googleSearchTool"
381 ? "google_search_tool"
382 : kind === "openApiTool"
383 ? "open_api_tool"
384 : kind === "widgetTool"
385 ? "widget_tool"
386 : kind === "fileSearchTool"
387 ? "file_search_tool"
388 : kind === "dataStoreTool"
389 ? "data_store_tool"
390 : kind === "agentTool"
391 ? "agent_tool"
392 : kind === "remoteAgentTool"
393 ? "remote_agent_tool"
394 : kind === "connectorTool"
395 ? "connector_tool"
396 : kind === "systemTool"
397 ? "system_tool"
398 : "mcp_tool"
399 : undefined,
400 ),
401 body,
402 }),
403 );
404 }
405
406 return toAttrs(current, env.project, env.region, app);
407 }),
408
409 delete: Effect.fn(function* ({ output }) {
410 if (!output.name) return;
411 yield* retryTransient(
412 ces.deleteProjectsLocationsAppsTools({
413 name: output.name,
414 force: true,
415 }),
416 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
417 yield* waitUntilGone(getByName(output.name));
418 }),
419 });