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 encodeOwnershipLine,
15 expandAgent,
16 fingerprint,
17 hasOwnershipMarker,
18 internalLabels,
19 lastSegment,
20 locationOf,
21 namedAgents,
22 normalizeLocation,
23 ownedByAlchemy,
24 parentOf,
25 parseOwnership,
26 projectOf,
27 sameText,
28 toResourceId,
29 updateMaskOf,
30 retryQuota,
31} from "./internal.ts";
32
33export type PlaybookType = "PLAYBOOK_TYPE_UNSPECIFIED" | "TASK" | "ROUTINE";
34
35export type PlaybookStep = {
36 /** Instruction text for this step. */
37 text?: string;
38 /** Nested steps. */
39 steps?: PlaybookStep[];
40};
41
42export type PlaybookInstruction = {
43 /** Free-form guidelines. */
44 guidelines?: string;
45 /** Ordered steps. */
46 steps?: PlaybookStep[];
47};
48
49export type PlaybookParameterDefinition = {
50 /** Parameter name. */
51 name?: string;
52 /** Parameter type (`STRING`, `NUMBER`, …). */
53 type?: string;
54 /** Human-readable description. */
55 description?: string;
56};
57
58export type PlaybookLlmModelSettings = {
59 /** LLM model id. */
60 model?: string;
61 /** Model prompt text. */
62 promptText?: string;
63};
64
65export type AgentsPlaybookProps = {
66 /**
67 * Parent agent resource name
68 * `projects/{project}/locations/{location}/agents/{agent}`.
69 * Immutable — changing it replaces the playbook.
70 */
71 agent: string;
72 /**
73 * Playbook id (the `{playbook}` segment). Server-assigned when omitted.
74 * Immutable — changing it replaces the playbook.
75 */
76 playbookId?: string;
77 /**
78 * Location used when `agent` is an id rather than a resource name.
79 * Immutable — changing it replaces the playbook.
80 * @default "global"
81 */
82 location?: string;
83 /**
84 * Human-readable name. Playbooks have no labels field, so Alchemy
85 * stamps ownership into this field and strips it from attributes.
86 */
87 displayName?: string;
88 /**
89 * High-level goal of the playbook.
90 * @default "Handle the user request."
91 */
92 goal?: string;
93 /** Instruction block (guidelines and steps). */
94 instruction?: PlaybookInstruction;
95 /**
96 * Playbook kind.
97 * @default "TASK"
98 */
99 playbookType?: PlaybookType | (string & {});
100 /** Referenced tool resource names. */
101 referencedTools?: string[];
102 /** Referenced playbook resource names. */
103 referencedPlaybooks?: string[];
104 /** Referenced flow resource names. */
105 referencedFlows?: string[];
106 /** LLM model settings. */
107 llmModelSettings?: PlaybookLlmModelSettings;
108 /** Inline code block. */
109 codeBlock?: { code?: string };
110 /** Input parameter definitions. */
111 inputParameterDefinitions?: PlaybookParameterDefinition[];
112 /** Output parameter definitions. */
113 outputParameterDefinitions?: PlaybookParameterDefinition[];
114};
115
116export type AgentsPlaybook = Resource<
117 "GCP.Dialogflow.AgentsPlaybook",
118 AgentsPlaybookProps,
119 {
120 /** Full resource name `.../agents/{agent}/playbooks/{playbook}`. */
121 name: string;
122 /** Playbook id (last path segment). */
123 playbookId: string;
124 /** Parent agent resource name. */
125 agent: string;
126 /** Location id. */
127 location: string;
128 /** Project id. */
129 project: string;
130 /** User display name with the Alchemy ownership prefix stripped. */
131 displayName: string | undefined;
132 /** Goal. */
133 goal: string | undefined;
134 /** Instruction block. */
135 instruction: PlaybookInstruction | undefined;
136 /** Playbook kind. */
137 playbookType: string | undefined;
138 /** Referenced tools. */
139 referencedTools: string[];
140 /** Referenced playbooks. */
141 referencedPlaybooks: string[];
142 /** Referenced flows. */
143 referencedFlows: string[];
144 /** LLM model settings. */
145 llmModelSettings: PlaybookLlmModelSettings | undefined;
146 /** Token count. */
147 tokenCount: string | undefined;
148 /** RFC3339 create timestamp. */
149 createTime: string | undefined;
150 /** RFC3339 update timestamp. */
151 updateTime: string | undefined;
152 },
153 never,
154 Providers
155>;
156
157/**
158 * A Dialogflow CX playbook (goal-driven LLM workflow).
159 *
160 * Playbooks have no labels field — Alchemy stamps ownership into
161 * `displayName` so `list` / nuke can find them. Parent agent and id are
162 * immutable. Display name, goal, instruction, type, references, and LLM
163 * settings update in place.
164 *
165 * ### Creating a Playbook
166 * **Example:** Task playbook
167 * ```typescript
168 * const playbook = yield* GCP.Dialogflow.AgentsPlaybook("Support", {
169 * agent: agentName,
170 * displayName: "support",
171 * goal: "Answer the user's support question.",
172 * });
173 * ```
174 *
175 * ### Updating a Playbook
176 * Change props on the same logical id; the engine keeps the physical id.
177 *
178 * **Example:** Narrow the goal
179 * ```typescript
180 * const playbook = yield* GCP.Dialogflow.AgentsPlaybook("Support", {
181 * agent: agentName,
182 * displayName: "support",
183 * goal: "Reset the user's password.",
184 * });
185 * ```
186 *
187 * @resource
188 * @category Dialogflow
189 */
190export const AgentsPlaybook = Resource<AgentsPlaybook>(
191 "GCP.Dialogflow.AgentsPlaybook",
192);
193
194export class AgentsPlaybookNotResolved extends Data.TaggedError(
195 "GCP.Dialogflow.AgentsPlaybookNotResolved",
196)<{
197 name: string;
198}> {}
199
200const DEFAULT_GOAL = "Handle the user request.";
201const DEFAULT_TYPE: PlaybookType = "TASK";
202
203const resourceName = (agent: string, playbookId: string) =>
204 `${agent}/playbooks/${playbookId}`;
205
206const stepsOf = (
207 steps: dialogflow.GoogleCloudDialogflowCxV3PlaybookStepList | undefined,
208): PlaybookStep[] | undefined => {
209 if (steps === undefined) return undefined;
210 return steps.map((step) => ({
211 text: step.text,
212 steps: stepsOf(step.steps),
213 }));
214};
215
216const instructionOf = (
217 instruction:
218 | dialogflow.GoogleCloudDialogflowCxV3PlaybookInstruction
219 | undefined,
220): PlaybookInstruction | undefined => {
221 if (instruction === undefined) return undefined;
222 return {
223 guidelines: instruction.guidelines,
224 steps: stepsOf(instruction.steps),
225 };
226};
227
228const toAttrs = (
229 playbook: dialogflow.GoogleCloudDialogflowCxV3Playbook,
230 project: string,
231 agentHint?: string,
232) => {
233 const name = playbook.name ?? "";
234 return {
235 name,
236 playbookId: lastSegment(name),
237 agent: name.includes("/playbooks/")
238 ? collectionParent(name, "agents")
239 : (agentHint ?? parentOf(name)),
240 location: locationOf(name),
241 project: projectOf(name) || project,
242 displayName: parseOwnership(playbook.displayName).text,
243 goal: playbook.goal,
244 instruction: instructionOf(playbook.instruction),
245 playbookType: playbook.playbookType,
246 referencedTools: [...(playbook.referencedTools ?? [])],
247 referencedPlaybooks: [...(playbook.referencedPlaybooks ?? [])],
248 referencedFlows: [...(playbook.referencedFlows ?? [])],
249 llmModelSettings: playbook.llmModelSettings
250 ? {
251 model: playbook.llmModelSettings.model,
252 promptText: playbook.llmModelSettings.promptText,
253 }
254 : undefined,
255 tokenCount: playbook.tokenCount,
256 createTime: playbook.createTime,
257 updateTime: playbook.updateTime,
258 };
259};
260
261const getByName = (name: string) =>
262 name.length === 0
263 ? Effect.succeed(undefined)
264 : dialogflow
265 .getProjectsLocationsAgentsPlaybooks({ name })
266 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
267
268const listAt = (parent: string, project: string) =>
269 dialogflow.listProjectsLocationsAgentsPlaybooks
270 .pages({ parent, pageSize: 100 })
271 .pipe(
272 Stream.flatMap((page) => Stream.fromIterable(page.playbooks ?? [])),
273 Stream.filter((playbook) => hasOwnershipMarker(playbook.displayName)),
274 Stream.map((playbook) => toAttrs(playbook, project, parent)),
275 Stream.runCollect,
276 Effect.map((chunk) => Array.from(chunk)),
277 Effect.catchTag("NotFound", () => Effect.succeed([])),
278 );
279
280const findByDisplayName = (parent: string, displayName: string) =>
281 dialogflow.listProjectsLocationsAgentsPlaybooks
282 .pages({ parent, pageSize: 100 })
283 .pipe(
284 Stream.flatMap((page) => Stream.fromIterable(page.playbooks ?? [])),
285 Stream.filter((playbook) => playbook.displayName === displayName),
286 Stream.runHead,
287 Effect.map((option) =>
288 option._tag === "Some" ? option.value : undefined,
289 ),
290 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
291 );
292
293export const AgentsPlaybookProvider = () =>
294 Provider.succeed(AgentsPlaybook, {
295 stables: [
296 "name",
297 "playbookId",
298 "agent",
299 "location",
300 "project",
301 "createTime",
302 ],
303
304 diff: Effect.fn(function* ({ news, olds, output }) {
305 if (!isResolved(news)) return undefined;
306 const previousAgent = olds?.agent ?? output?.agent;
307 if (previousAgent !== undefined && news.agent !== previousAgent) {
308 return { action: "replace" as const, deleteFirst: false };
309 }
310 const previousId = olds?.playbookId ?? output?.playbookId;
311 if (
312 previousId !== undefined &&
313 news.playbookId !== undefined &&
314 news.playbookId !== previousId
315 ) {
316 return { action: "replace" as const, deleteFirst: false };
317 }
318 return undefined;
319 }),
320
321 read: Effect.fn(function* ({ id, olds, output }) {
322 const env = yield* GcpEnvironment.current;
323 const location = normalizeLocation(olds?.location ?? output?.location);
324 const agent = olds?.agent
325 ? expandAgent(olds.agent, env.project, location)
326 : output?.agent;
327 const playbookId = yield* toResourceId(
328 id,
329 olds?.playbookId,
330 output?.playbookId,
331 );
332 const name =
333 output?.name ??
334 (agent !== undefined ? resourceName(agent, playbookId) : "");
335 let existing = yield* getByName(name);
336 if (existing === undefined && agent !== undefined) {
337 const ownership = yield* internalLabels(id);
338 existing = yield* findByDisplayName(
339 agent,
340 encodeOwnershipLine(ownership, olds?.displayName),
341 );
342 }
343 if (existing === undefined) return undefined;
344 const attrs = toAttrs(existing, env.project, agent);
345 return (yield* ownedByAlchemy(id, existing.displayName))
346 ? attrs
347 : Unowned(attrs);
348 }),
349
350 list: () =>
351 Effect.gen(function* () {
352 const env = yield* GcpEnvironment.current;
353 const agents = yield* namedAgents(env.project);
354 const pages = yield* Effect.forEach(
355 agents,
356 (agent) => listAt(agent.name, env.project),
357 { concurrency: 4 },
358 );
359 return pages.flat();
360 }),
361
362 reconcile: Effect.fn(function* ({ id, news, output }) {
363 const env = yield* GcpEnvironment.current;
364 const location = normalizeLocation(
365 news.location ?? output?.location ?? DEFAULT_LOCATION,
366 );
367 const agent = expandAgent(news.agent, env.project, location);
368 const playbookId = yield* toResourceId(
369 id,
370 news.playbookId,
371 output?.playbookId,
372 );
373 const name = output?.name ?? resourceName(agent, playbookId);
374 const ownership = yield* internalLabels(id);
375 const displayName = encodeOwnershipLine(ownership, news.displayName);
376 const goal = news.goal ?? DEFAULT_GOAL;
377 const playbookType = news.playbookType ?? DEFAULT_TYPE;
378 const body: dialogflow.GoogleCloudDialogflowCxV3Playbook = {
379 displayName,
380 goal,
381 instruction: news.instruction,
382 playbookType,
383 referencedTools: news.referencedTools,
384 referencedPlaybooks: news.referencedPlaybooks,
385 referencedFlows: news.referencedFlows,
386 llmModelSettings: news.llmModelSettings,
387 codeBlock: news.codeBlock,
388 inputParameterDefinitions: news.inputParameterDefinitions,
389 outputParameterDefinitions: news.outputParameterDefinitions,
390 };
391
392 let current = yield* getByName(output?.name ?? name);
393 if (current === undefined) {
394 current = yield* findByDisplayName(agent, displayName);
395 }
396
397 if (current === undefined) {
398 const created = yield* dialogflow
399 .createProjectsLocationsAgentsPlaybooks({
400 parent: agent,
401 body,
402 })
403 .pipe(
404 Effect.catchTag("Conflict", () =>
405 findByDisplayName(agent, displayName),
406 ),
407 );
408 current = created ?? undefined;
409 }
410
411 if (current === undefined) {
412 return yield* new AgentsPlaybookNotResolved({ name });
413 }
414
415 const currentName = current.name ?? name;
416 const displayChanged = (current.displayName ?? "") !== displayName;
417 const goalChanged = !sameText(current.goal, goal);
418 const instructionChanged =
419 fingerprint(instructionOf(current.instruction)) !==
420 fingerprint(news.instruction);
421 const typeChanged =
422 (current.playbookType ?? DEFAULT_TYPE) !== playbookType;
423 const toolsChanged =
424 fingerprint([...(current.referencedTools ?? [])]) !==
425 fingerprint(news.referencedTools);
426 const playbooksChanged =
427 fingerprint([...(current.referencedPlaybooks ?? [])]) !==
428 fingerprint(news.referencedPlaybooks);
429 const flowsChanged =
430 fingerprint([...(current.referencedFlows ?? [])]) !==
431 fingerprint(news.referencedFlows);
432 const llmChanged =
433 fingerprint(current.llmModelSettings) !==
434 fingerprint(news.llmModelSettings);
435 const codeChanged =
436 fingerprint(current.codeBlock) !== fingerprint(news.codeBlock);
437 const inputChanged =
438 fingerprint(current.inputParameterDefinitions) !==
439 fingerprint(news.inputParameterDefinitions);
440 const outputChanged =
441 fingerprint(current.outputParameterDefinitions) !==
442 fingerprint(news.outputParameterDefinitions);
443
444 if (
445 displayChanged ||
446 goalChanged ||
447 instructionChanged ||
448 typeChanged ||
449 toolsChanged ||
450 playbooksChanged ||
451 flowsChanged ||
452 llmChanged ||
453 codeChanged ||
454 inputChanged ||
455 outputChanged
456 ) {
457 current = yield* dialogflow.patchProjectsLocationsAgentsPlaybooks({
458 name: currentName,
459 updateMask: updateMaskOf(
460 displayChanged ? "display_name" : undefined,
461 goalChanged ? "goal" : undefined,
462 instructionChanged ? "instruction" : undefined,
463 typeChanged ? "playbook_type" : undefined,
464 toolsChanged ? "referenced_tools" : undefined,
465 playbooksChanged ? "referenced_playbooks" : undefined,
466 flowsChanged ? "referenced_flows" : undefined,
467 llmChanged ? "llm_model_settings" : undefined,
468 codeChanged ? "code_block" : undefined,
469 inputChanged ? "input_parameter_definitions" : undefined,
470 outputChanged ? "output_parameter_definitions" : undefined,
471 ),
472 body: { ...body, name: currentName },
473 });
474 }
475
476 return toAttrs(current, env.project, agent);
477 }, retryQuota),
478
479 delete: Effect.fn(function* ({ output }) {
480 yield* dialogflow
481 .deleteProjectsLocationsAgentsPlaybooks({ name: output.name })
482 .pipe(Effect.catchTag("NotFound", () => Effect.void));
483 }, retryQuota),
484 });