1import * as bedrock from "@distilled.cloud/aws/bedrock-agent";
2import * as iam from "@distilled.cloud/aws/iam";
3import type * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
13import { toWireDays, toWireSeconds } from "../../Util/Duration.ts";
14import { AWSEnvironment } from "../Environment.ts";
15import { bedrockModelArns } from "./ModelArns.ts";
16
17/**
18 * A guardrail attached to an agent to apply content filters, denied topics,
19 * word filters, and sensitive-information policies to model interactions.
20 */
21export interface AgentGuardrailConfiguration {
22 /** The unique identifier of the guardrail. */
23 guardrailIdentifier?: string;
24 /** The version of the guardrail. */
25 guardrailVersion?: string;
26}
27
28/**
29 * Long-term memory configuration for an agent — lets the agent retain
30 * conversational context across sessions as asynchronously generated
31 * session summaries, readable at runtime via the `GetAgentMemory` binding.
32 */
33export interface AgentMemoryConfiguration {
34 /**
35 * The types of memory to enable. `"SESSION_SUMMARY"` is currently the
36 * only supported type.
37 */
38 enabledMemoryTypes: bedrock.MemoryType[];
39 /**
40 * How long the agent retains memory (e.g. `"30 days"` or
41 * `Duration.days(30)`; a bare number is milliseconds). Between 1 and
42 * 365 days. Sent to the API as whole days (`storageDays`).
43 * @default 30 days
44 */
45 storage?: Duration.Input;
46 /**
47 * Configuration for `SESSION_SUMMARY` memory.
48 */
49 sessionSummaryConfiguration?: {
50 /** The maximum number of recent session summaries to include. */
51 maxRecentSessions?: number;
52 };
53}
54
55export interface AgentProps {
56 /**
57 * Name of the agent (1-100 characters; letters, digits, and the
58 * characters `_-`). If omitted, a deterministic physical name is
59 * generated from the app, stage, and logical ID. Changing the name
60 * triggers a replacement.
61 */
62 agentName?: string;
63 /**
64 * The foundation model or inference-profile id the agent uses for
65 * orchestration — a foundation-model id
66 * (`anthropic.claude-3-5-sonnet-20240620-v1:0`), a cross-region inference
67 * profile id (`us.anthropic.claude-3-5-sonnet-20240620-v1:0`), or a full
68 * Bedrock ARN. Model access must be enabled in the account.
69 */
70 foundationModel: string;
71 /**
72 * Instructions that tell the agent what it should do and how it should
73 * interact with users. Must be at least 40 characters for the agent to be
74 * preparable.
75 */
76 instruction: string;
77 /**
78 * A description of the agent.
79 */
80 description?: string;
81 /**
82 * The ARN of an existing IAM role for the agent to assume. When omitted,
83 * an execution role is created automatically with `bedrock.amazonaws.com`
84 * trust and `bedrock:InvokeModel` granted on {@link AgentProps.foundationModel}.
85 */
86 agentResourceRoleArn?: string;
87 /**
88 * How long the agent retains an idle session before it is ended (e.g.
89 * `"30 minutes"` or `Duration.minutes(30)`; a bare number is milliseconds).
90 * Between 1 minute and 1 hour. Sent to the API as whole seconds
91 * (`idleSessionTTLInSeconds`).
92 * @default 600 seconds
93 */
94 idleSessionTTL?: Duration.Input;
95 /**
96 * The ARN of a KMS key to encrypt the agent with.
97 */
98 customerEncryptionKeyArn?: string;
99 /**
100 * A guardrail to apply to the agent's model interactions.
101 */
102 guardrailConfiguration?: AgentGuardrailConfiguration;
103 /**
104 * Long-term memory configuration. When enabled the agent summarizes each
105 * session after it ends and retains the summaries for
106 * {@link AgentMemoryConfiguration.storage}, making them available to
107 * later sessions that share the same memory id (and to the
108 * `GetAgentMemory` / `DeleteAgentMemory` runtime bindings).
109 */
110 memoryConfiguration?: AgentMemoryConfiguration;
111 /**
112 * Whether to prepare the agent (compile the DRAFT version) after every
113 * create and update so it is invocable and can back an
114 * {@link AgentAlias}. Preparation is polled to completion.
115 * @default true
116 */
117 prepare?: boolean;
118 /**
119 * Tags to apply to the agent. Merged with internal Alchemy tags.
120 */
121 tags?: Record<string, string>;
122}
123
124export interface Agent extends Resource<
125 "AWS.Bedrock.Agent",
126 AgentProps,
127 {
128 /**
129 * The unique identifier of the agent.
130 */
131 agentId: string;
132 /**
133 * The ARN of the agent.
134 */
135 agentArn: string;
136 /**
137 * Name of the agent.
138 */
139 agentName: string;
140 /** The DRAFT version identifier (always `"DRAFT"`). */
141 agentVersion: string;
142 /** The ARN of the role the agent assumes. */
143 agentResourceRoleArn: string;
144 /**
145 * Name of the auto-created execution role. `undefined` when an explicit
146 * {@link AgentProps.agentResourceRoleArn} is used.
147 */
148 roleName: string | undefined;
149 }
150> {}
151
152/**
153 * An Amazon Bedrock agent — a foundation model driven by natural-language
154 * instructions that can orchestrate multi-step tasks.
155 *
156 * `Agent` owns the lifecycle of the agent's DRAFT version. An IAM execution
157 * role is created automatically (trusted by `bedrock.amazonaws.com`, granted
158 * `bedrock:InvokeModel` on the foundation model) unless an explicit
159 * `agentResourceRoleArn` is supplied. After every create/update the agent is
160 * prepared (unless `prepare: false`) so it is immediately invocable and can
161 * back an {@link AgentAlias}.
162 *
163 * ### Creating Agents
164 * **Example:** Minimal Agent
165 * ```typescript
166 * import * as Bedrock from "alchemy/AWS/Bedrock";
167 *
168 * const agent = yield* Bedrock.Agent("assistant", {
169 * foundationModel: "us.anthropic.claude-3-5-sonnet-20240620-v1:0",
170 * instruction:
171 * "You are a helpful assistant that answers questions concisely.",
172 * });
173 * ```
174 *
175 * **Example:** Agent with a Guardrail and Custom Session TTL
176 * ```typescript
177 * const agent = yield* Bedrock.Agent("assistant", {
178 * foundationModel: "us.anthropic.claude-3-5-sonnet-20240620-v1:0",
179 * instruction: "You are a careful, policy-compliant support agent.",
180 * idleSessionTTL: "30 minutes",
181 * guardrailConfiguration: {
182 * guardrailIdentifier: guardrail.guardrailId,
183 * guardrailVersion: "DRAFT",
184 * },
185 * });
186 * ```
187 *
188 * **Example:** Agent with Long-Term Memory
189 * ```typescript
190 * // Session summaries are retained for 30 days and readable at runtime
191 * // through the GetAgentMemory binding.
192 * const agent = yield* Bedrock.Agent("assistant", {
193 * foundationModel: "us.anthropic.claude-3-5-sonnet-20240620-v1:0",
194 * instruction: "You are a helpful assistant that remembers past sessions.",
195 * memoryConfiguration: {
196 * enabledMemoryTypes: ["SESSION_SUMMARY"],
197 * storage: "30 days",
198 * },
199 * });
200 * ```
201 *
202 * @resource
203 */
204export const Agent = Resource<Agent>("AWS.Bedrock.Agent");
205
206/** Map the declared memory props onto the wire `MemoryConfiguration`. */
207const toWireMemoryConfiguration = (
208 memory: AgentMemoryConfiguration | undefined,
209): bedrock.MemoryConfiguration | undefined =>
210 memory === undefined
211 ? undefined
212 : {
213 enabledMemoryTypes: [...memory.enabledMemoryTypes],
214 storageDays: toWireDays(memory.storage),
215 sessionSummaryConfiguration: memory.sessionSummaryConfiguration,
216 };
217
218/** Agent status values from which no further transition is pending. */
219const AGENT_SETTLED = new Set(["NOT_PREPARED", "PREPARED", "FAILED"]);
220
221/** Agent status values indicating an in-flight transition to wait out. */
222const AGENT_TRANSIENT = new Set([
223 "CREATING",
224 "UPDATING",
225 "PREPARING",
226 "VERSIONING",
227]);
228
229/**
230 * A freshly created IAM role is eventually consistent; `createAgent` can
231 * transiently reject a role it cannot yet assume with a `ValidationException`.
232 * Wrapped in an explicitly-typed generic helper so the `Effect.retry`
233 * conditional return type does not leak into declaration emit and widen the
234 * provider layer's requirement to `unknown` (see PATTERNS §7).
235 */
236const retryWhileRoleAssumeFails = <A, E extends { _tag: string }, R>(
237 self: Effect.Effect<A, E, R>,
238): Effect.Effect<A, E, R> =>
239 Effect.retry(self, {
240 while: (e) =>
241 e._tag === "ValidationException" &&
242 "message" in e &&
243 typeof e.message === "string" &&
244 e.message.toLowerCase().includes("unable to assume"),
245 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(10)]),
246 });
247
248export const AgentProvider = () =>
249 Provider.effect(
250 Agent,
251 Effect.gen(function* () {
252 const createName = Effect.fn(function* (id: string, props: AgentProps) {
253 return (
254 props.agentName ?? (yield* createPhysicalName({ id, maxLength: 100 }))
255 );
256 });
257
258 const createRoleName = (id: string) =>
259 createPhysicalName({ id, maxLength: 64 });
260
261 const getAgentOrUndefined = Effect.fn(function* (agentId: string) {
262 return yield* bedrock.getAgent({ agentId }).pipe(
263 Effect.map((r) => r.agent),
264 Effect.catchTag("ResourceNotFoundException", () =>
265 Effect.succeed(undefined),
266 ),
267 );
268 });
269
270 const findByName = Effect.fn(function* (name: string) {
271 const pages = yield* bedrock.listAgents
272 .pages({})
273 .pipe(Stream.runCollect);
274 const summary = Array.from(pages)
275 .flatMap((page) => page.agentSummaries ?? [])
276 .find((s) => s.agentName === name);
277 return summary?.agentId;
278 });
279
280 const fetchObservedTags = Effect.fn(function* (resourceArn: string) {
281 return yield* bedrock.listTagsForResource({ resourceArn }).pipe(
282 Effect.map((r) => (r.tags ?? {}) as Record<string, string>),
283 Effect.catchTag("ResourceNotFoundException", () =>
284 Effect.succeed({} as Record<string, string>),
285 ),
286 );
287 });
288
289 // Poll the agent to a settled (non-transient) status. Bounded: creation
290 // and preparation both complete within seconds to ~1 minute.
291 const waitForSettled = Effect.fn(function* (agentId: string) {
292 return yield* bedrock.getAgent({ agentId }).pipe(
293 Effect.map((r) => r.agent),
294 Effect.catchTag("ResourceNotFoundException", () =>
295 Effect.succeed(undefined),
296 ),
297 Effect.repeat({
298 schedule: Schedule.fixed("3 seconds"),
299 until: (agent) =>
300 agent === undefined || !AGENT_TRANSIENT.has(agent.agentStatus),
301 times: 40,
302 }),
303 );
304 });
305
306 const ensureExecutionRole = Effect.fn(function* ({
307 id,
308 roleName,
309 region,
310 accountId,
311 foundationModel,
312 }: {
313 id: string;
314 roleName: string;
315 region: string;
316 accountId: string;
317 foundationModel: string;
318 }) {
319 const tags = yield* createInternalTags(id);
320 const role = yield* iam
321 .createRole({
322 RoleName: roleName,
323 AssumeRolePolicyDocument: JSON.stringify({
324 Version: "2012-10-17",
325 Statement: [
326 {
327 Effect: "Allow",
328 Principal: { Service: "bedrock.amazonaws.com" },
329 Action: "sts:AssumeRole",
330 Condition: {
331 StringEquals: { "aws:SourceAccount": accountId },
332 ArnLike: {
333 "aws:SourceArn": `arn:aws:bedrock:${region}:${accountId}:agent/*`,
334 },
335 },
336 },
337 ],
338 }),
339 Tags: Object.entries(tags).map(([Key, Value]) => ({ Key, Value })),
340 })
341 .pipe(
342 Effect.catchTag("EntityAlreadyExistsException", () =>
343 iam.getRole({ RoleName: roleName }),
344 ),
345 );
346
347 yield* iam.putRolePolicy({
348 RoleName: roleName,
349 PolicyName: "BedrockAgentInvokeModel",
350 PolicyDocument: JSON.stringify({
351 Version: "2012-10-17",
352 Statement: [
353 {
354 Effect: "Allow",
355 Action: [
356 "bedrock:InvokeModel",
357 "bedrock:InvokeModelWithResponseStream",
358 ],
359 Resource: bedrockModelArns(region, accountId, foundationModel),
360 },
361 ],
362 }),
363 });
364
365 return role.Role.Arn;
366 });
367
368 return Agent.Provider.of({
369 stables: ["agentId", "agentArn", "agentName"],
370
371 list: () =>
372 Effect.gen(function* () {
373 const { region, accountId } = yield* AWSEnvironment.current;
374 const pages = yield* bedrock.listAgents
375 .pages({})
376 .pipe(Stream.runCollect);
377 const summaries = Array.from(pages).flatMap(
378 (page) => page.agentSummaries ?? [],
379 );
380 return summaries.map((s) => ({
381 agentId: s.agentId,
382 agentArn: `arn:aws:bedrock:${region}:${accountId}:agent/${s.agentId}`,
383 agentName: s.agentName,
384 agentVersion: "DRAFT",
385 agentResourceRoleArn: "",
386 roleName: undefined as string | undefined,
387 }));
388 }),
389
390 read: Effect.fn(function* ({ id, olds, output }) {
391 const agentId =
392 output?.agentId ??
393 (yield* findByName(
394 output?.agentName ??
395 (yield* createName(id, olds ?? ({} as AgentProps))),
396 ));
397 if (agentId === undefined) return undefined;
398 const agent = yield* getAgentOrUndefined(agentId);
399 if (agent === undefined || agent.agentStatus === "DELETING") {
400 return undefined;
401 }
402 const attrs = {
403 agentId: agent.agentId,
404 agentArn: agent.agentArn,
405 agentName: agent.agentName,
406 agentVersion: agent.agentVersion ?? "DRAFT",
407 agentResourceRoleArn: agent.agentResourceRoleArn,
408 roleName: output?.roleName,
409 };
410 const tags = yield* fetchObservedTags(agent.agentArn);
411 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
412 }),
413
414 diff: Effect.fn(function* ({ id, news, olds }) {
415 if (!isResolved(news)) return undefined;
416 const oldName = yield* createName(id, olds ?? ({} as AgentProps));
417 const newName = yield* createName(id, news ?? ({} as AgentProps));
418 if (oldName !== newName) {
419 return { action: "replace" } as const;
420 }
421 // foundationModel, instruction, description, guardrail, TTL, and
422 // tags all converge via updateAgent.
423 }),
424
425 reconcile: Effect.fn(function* ({
426 id,
427 news = {} as AgentProps,
428 output,
429 session,
430 }) {
431 const { accountId, region } = yield* AWSEnvironment.current;
432 const name = output?.agentName ?? (yield* createName(id, news));
433 const internalTags = yield* createInternalTags(id);
434 const desiredTags = { ...news.tags, ...internalTags };
435
436 // Ensure the execution role first — the agent cannot exist without
437 // one. Managed unless an explicit agentResourceRoleArn is provided.
438 let roleArn = news.agentResourceRoleArn;
439 let roleName = output?.roleName;
440 if (roleArn === undefined) {
441 roleName = roleName ?? (yield* createRoleName(id));
442 roleArn = yield* ensureExecutionRole({
443 id,
444 roleName,
445 region,
446 accountId,
447 foundationModel: news.foundationModel,
448 });
449 }
450
451 // 1. OBSERVE — cloud state is authoritative; output is only an id
452 // cache. Fall back to a name lookup after state loss.
453 let agent = output?.agentId
454 ? yield* getAgentOrUndefined(output.agentId)
455 : undefined;
456 if (agent === undefined) {
457 const foundId = yield* findByName(name);
458 if (foundId !== undefined) {
459 agent = yield* getAgentOrUndefined(foundId);
460 }
461 }
462
463 if (agent === undefined) {
464 // 2. ENSURE — create.
465 const created = yield* retryWhileRoleAssumeFails(
466 bedrock.createAgent({
467 agentName: name,
468 foundationModel: news.foundationModel,
469 instruction: news.instruction,
470 description: news.description,
471 agentResourceRoleArn: roleArn,
472 idleSessionTTLInSeconds: toWireSeconds(news.idleSessionTTL),
473 customerEncryptionKeyArn: news.customerEncryptionKeyArn,
474 guardrailConfiguration: news.guardrailConfiguration,
475 memoryConfiguration: toWireMemoryConfiguration(
476 news.memoryConfiguration,
477 ),
478 tags: desiredTags,
479 }),
480 );
481 agent = created.agent;
482 agent = (yield* waitForSettled(agent.agentId)) ?? agent;
483 } else {
484 // 3. SYNC — wait for any in-flight transition, then update.
485 agent = (yield* waitForSettled(agent.agentId)) ?? agent;
486 yield* bedrock.updateAgent({
487 agentId: agent.agentId,
488 agentName: name,
489 foundationModel: news.foundationModel,
490 instruction: news.instruction,
491 description: news.description,
492 agentResourceRoleArn: roleArn,
493 idleSessionTTLInSeconds: toWireSeconds(news.idleSessionTTL),
494 customerEncryptionKeyArn: news.customerEncryptionKeyArn,
495 guardrailConfiguration: news.guardrailConfiguration,
496 memoryConfiguration: toWireMemoryConfiguration(
497 news.memoryConfiguration,
498 ),
499 });
500 agent = (yield* waitForSettled(agent.agentId)) ?? agent;
501 }
502
503 const agentId = agent.agentId;
504 const agentArn = agent.agentArn;
505
506 // 3b. SYNC TAGS — diff against OBSERVED cloud tags (create-time tags
507 // only apply on first create; adoption may carry foreign tags).
508 const observedTags = yield* fetchObservedTags(agentArn);
509 const { upsert, removed } = diffTags(observedTags, desiredTags);
510 if (upsert.length > 0) {
511 yield* bedrock.tagResource({
512 resourceArn: agentArn,
513 tags: Object.fromEntries(
514 upsert.map(({ Key, Value }) => [Key, Value]),
515 ),
516 });
517 }
518 if (removed.length > 0) {
519 yield* bedrock.untagResource({
520 resourceArn: agentArn,
521 tagKeys: removed,
522 });
523 }
524
525 // 4. PREPARE — compile the DRAFT so the agent is invocable and can
526 // back an alias. Bounded poll to PREPARED/FAILED.
527 if (news.prepare !== false) {
528 yield* bedrock.prepareAgent({ agentId });
529 agent = (yield* waitForSettled(agentId)) ?? agent;
530 }
531
532 yield* session.note(agentArn);
533 return {
534 agentId,
535 agentArn,
536 agentName: name,
537 agentVersion: agent.agentVersion ?? "DRAFT",
538 agentResourceRoleArn: roleArn,
539 roleName,
540 };
541 }),
542
543 delete: Effect.fn(function* ({ output }) {
544 yield* bedrock
545 .deleteAgent({
546 agentId: output.agentId,
547 skipResourceInUseCheck: true,
548 })
549 .pipe(
550 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
551 );
552
553 // Tear down the managed execution role (absent when an explicit
554 // agentResourceRoleArn was supplied). Every step tolerates a
555 // partially or fully removed role.
556 if (output.roleName !== undefined) {
557 const roleName = output.roleName;
558 yield* iam
559 .deleteRolePolicy({
560 RoleName: roleName,
561 PolicyName: "BedrockAgentInvokeModel",
562 })
563 .pipe(
564 Effect.catchTag("NoSuchEntityException", () => Effect.void),
565 );
566 yield* iam
567 .deleteRole({ RoleName: roleName })
568 .pipe(
569 Effect.catchTag("NoSuchEntityException", () => Effect.void),
570 );
571 }
572 }),
573 });
574 }),
575 );