agentdeploy 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,145 @@
1
+ """
2
+ DockerComposeTarget — generates docker-compose.yml for local development
3
+ and staging. Includes the agent container, an optional Redis sidecar
4
+ for inter-agent state, and an OTEL collector sidecar.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ import yaml
13
+
14
+ from agentdeploy.core.app import AgentApp
15
+
16
+
17
+ @dataclass
18
+ class DockerComposeTarget:
19
+ app: AgentApp
20
+ output_dir: str = "."
21
+ network: str = "agent-net"
22
+
23
+ _redis: bool = field(default=False, init=False)
24
+ _otel_collector: bool = field(default=False, init=False)
25
+ _extra_services: dict = field(default_factory=dict, init=False)
26
+
27
+ def with_redis(self) -> DockerComposeTarget:
28
+ """Add a Redis sidecar for shared agent state / queue."""
29
+ self._redis = True
30
+ return self
31
+
32
+ def with_otel_collector(self) -> DockerComposeTarget:
33
+ """Add an OpenTelemetry collector sidecar for local trace collection."""
34
+ self._otel_collector = True
35
+ return self
36
+
37
+ def with_service(self, name: str, definition: dict) -> DockerComposeTarget:
38
+ """Add an arbitrary extra service to the compose file."""
39
+ self._extra_services[name] = definition
40
+ return self
41
+
42
+ def build(self) -> DockerComposeResult:
43
+ cfg = self.app.to_config()
44
+ adapter = self.app._adapter
45
+ out = Path(self.output_dir) / cfg.name
46
+ out.mkdir(parents=True, exist_ok=True)
47
+
48
+ dockerfile = self._generate_dockerfile(cfg, adapter)
49
+ (out / "Dockerfile").write_text(dockerfile)
50
+
51
+ server_code = adapter.entrypoint_code(cfg.name, self.app._port)
52
+ (out / "server.py").write_text(server_code)
53
+
54
+ compose = self._compose_manifest(cfg)
55
+ compose_path = out / "docker-compose.yml"
56
+ compose_path.write_text(yaml.dump(compose, default_flow_style=False))
57
+
58
+ return DockerComposeResult(
59
+ app_name=cfg.name,
60
+ output_dir=str(out),
61
+ compose_path=str(compose_path),
62
+ next_steps=[
63
+ f"cd {out}",
64
+ "docker compose up --build",
65
+ f"curl http://localhost:{self.app._port}{self.app._health_path}",
66
+ ],
67
+ )
68
+
69
+ def _generate_dockerfile(self, cfg, adapter) -> str:
70
+ extras = adapter.pip_extras()
71
+ pip_install = " ".join(extras) if extras else ""
72
+ return f"""FROM python:3.11-slim
73
+ WORKDIR /app
74
+ RUN pip install --no-cache-dir fastapi uvicorn{(" " + pip_install) if pip_install else ""}
75
+ COPY . .
76
+ EXPOSE {self.app._port}
77
+ CMD ["python", "server.py"]
78
+ """
79
+
80
+ def _compose_manifest(self, cfg) -> dict:
81
+ depends_on = []
82
+ services: dict = {}
83
+
84
+ agent_env = dict(cfg.env_vars)
85
+ if self._redis:
86
+ agent_env["REDIS_URL"] = "redis://redis:6379"
87
+ depends_on.append("redis")
88
+ if self._otel_collector:
89
+ agent_env["OTEL_EXPORTER_OTLP_ENDPOINT"] = "http://otel-collector:4317"
90
+ depends_on.append("otel-collector")
91
+
92
+ probe_url = f"http://localhost:{self.app._port}{self.app._health_path}"
93
+ # Stdlib urllib probe — no curl required, no apt-get layer in the image.
94
+ probe_script = (
95
+ "import urllib.request,sys; "
96
+ f"sys.exit(0 if urllib.request.urlopen('{probe_url}',timeout=5)"
97
+ ".status==200 else 1)"
98
+ )
99
+ agent_service: dict = {
100
+ "build": ".",
101
+ "ports": [f"{self.app._port}:{self.app._port}"],
102
+ "environment": agent_env,
103
+ "networks": [self.network],
104
+ "restart": "unless-stopped",
105
+ "healthcheck": {
106
+ "test": ["CMD", "python", "-c", probe_script],
107
+ "interval": "30s",
108
+ "timeout": "10s",
109
+ "retries": 3,
110
+ },
111
+ }
112
+ if depends_on:
113
+ agent_service["depends_on"] = depends_on
114
+
115
+ services[cfg.name] = agent_service
116
+
117
+ if self._redis:
118
+ services["redis"] = {
119
+ "image": "redis:7-alpine",
120
+ "networks": [self.network],
121
+ "restart": "unless-stopped",
122
+ }
123
+
124
+ if self._otel_collector:
125
+ services["otel-collector"] = {
126
+ "image": "otel/opentelemetry-collector-contrib:latest",
127
+ "networks": [self.network],
128
+ "ports": ["4317:4317", "4318:4318"],
129
+ }
130
+
131
+ services.update(self._extra_services)
132
+
133
+ return {
134
+ "version": "3.9",
135
+ "services": services,
136
+ "networks": {self.network: {"driver": "bridge"}},
137
+ }
138
+
139
+
140
+ @dataclass
141
+ class DockerComposeResult:
142
+ app_name: str
143
+ output_dir: str
144
+ compose_path: str
145
+ next_steps: list[str]
@@ -0,0 +1,344 @@
1
+ """
2
+ KubernetesTarget — generates production-ready Kubernetes manifests
3
+ and a Dockerfile from an AgentApp.
4
+
5
+ Outputs:
6
+ ./deploy/<name>/Dockerfile
7
+ ./deploy/<name>/k8s/deployment.yaml
8
+ ./deploy/<name>/k8s/service.yaml
9
+ ./deploy/<name>/k8s/hpa.yaml
10
+ ./deploy/<name>/k8s/secret.yaml (if secrets declared)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ import yaml
19
+
20
+ from agentdeploy.core.app import AgentApp
21
+ from agentdeploy.core.hitl import HITLConfig
22
+ from agentdeploy.core.observability import OtelConfig
23
+
24
+
25
+ @dataclass
26
+ class KubernetesTarget:
27
+ app: AgentApp
28
+ namespace: str = "default"
29
+ image: str = ""
30
+ registry: str = ""
31
+
32
+ _replicas: int = field(default=1, init=False)
33
+ _autoscale_min: int = field(default=1, init=False)
34
+ _autoscale_max: int = field(default=5, init=False)
35
+ _autoscale_cpu_pct: int = field(default=70, init=False)
36
+ _hitl: HITLConfig | None = field(default=None, init=False)
37
+ _otel: OtelConfig | None = field(default=None, init=False)
38
+ _output_dir: str = field(default="./deploy", init=False)
39
+ _python_version: str = field(default="3.11", init=False)
40
+ _base_image: str = field(default="python:3.11-slim", init=False)
41
+
42
+ def with_replicas(self, count: int) -> KubernetesTarget:
43
+ self._replicas = count
44
+ return self
45
+
46
+ def with_autoscale(
47
+ self,
48
+ *,
49
+ min: int = 1,
50
+ max: int = 10,
51
+ cpu_percent: int = 70,
52
+ ) -> KubernetesTarget:
53
+ self._autoscale_min = min
54
+ self._autoscale_max = max
55
+ self._autoscale_cpu_pct = cpu_percent
56
+ return self
57
+
58
+ def with_hitl_gate(
59
+ self,
60
+ *,
61
+ webhook: str = "",
62
+ slack_channel: str = "",
63
+ timeout_seconds: int = 3600,
64
+ ) -> KubernetesTarget:
65
+ self._hitl = HITLConfig(
66
+ webhook=webhook,
67
+ slack_channel=slack_channel,
68
+ timeout_seconds=timeout_seconds,
69
+ )
70
+ return self
71
+
72
+ def with_telemetry(
73
+ self,
74
+ *,
75
+ endpoint: str = "http://otel-collector:4317",
76
+ service_name: str = "",
77
+ ) -> KubernetesTarget:
78
+ self._otel = OtelConfig(
79
+ endpoint=endpoint,
80
+ service_name=service_name or self.app.name,
81
+ )
82
+ return self
83
+
84
+ def with_output_dir(self, path: str) -> KubernetesTarget:
85
+ self._output_dir = path
86
+ return self
87
+
88
+ def with_base_image(self, image: str) -> KubernetesTarget:
89
+ self._base_image = image
90
+ return self
91
+
92
+ def build(self) -> BuildResult:
93
+ """Generate all deployment artifacts and write them to disk."""
94
+ cfg = self.app.to_config()
95
+ adapter = self.app._adapter
96
+ out = Path(self._output_dir) / cfg.name
97
+ k8s_dir = out / "k8s"
98
+ out.mkdir(parents=True, exist_ok=True)
99
+ k8s_dir.mkdir(parents=True, exist_ok=True)
100
+
101
+ files: list[Path] = []
102
+
103
+ dockerfile = self._generate_dockerfile(cfg, adapter)
104
+ df_path = out / "Dockerfile"
105
+ df_path.write_text(dockerfile)
106
+ files.append(df_path)
107
+
108
+ server_code = adapter.entrypoint_code(cfg.name, self.app._port)
109
+ server_path = out / "server.py"
110
+ server_path.write_text(server_code)
111
+ files.append(server_path)
112
+
113
+ deploy_manifest = self._deployment_manifest(cfg)
114
+ dep_path = k8s_dir / "deployment.yaml"
115
+ dep_path.write_text(yaml.dump(deploy_manifest, default_flow_style=False))
116
+ files.append(dep_path)
117
+
118
+ svc_manifest = self._service_manifest(cfg)
119
+ svc_path = k8s_dir / "service.yaml"
120
+ svc_path.write_text(yaml.dump(svc_manifest, default_flow_style=False))
121
+ files.append(svc_path)
122
+
123
+ hpa_manifest = self._hpa_manifest(cfg)
124
+ hpa_path = k8s_dir / "hpa.yaml"
125
+ hpa_path.write_text(yaml.dump(hpa_manifest, default_flow_style=False))
126
+ files.append(hpa_path)
127
+
128
+ if cfg.secrets:
129
+ secret_manifest = self._secret_placeholder(cfg)
130
+ sec_path = k8s_dir / "secret.yaml"
131
+ sec_path.write_text(yaml.dump(secret_manifest, default_flow_style=False))
132
+ files.append(sec_path)
133
+
134
+ kustomization = self._kustomization(cfg)
135
+ kust_path = k8s_dir / "kustomization.yaml"
136
+ kust_path.write_text(yaml.dump(kustomization, default_flow_style=False))
137
+ files.append(kust_path)
138
+
139
+ return BuildResult(
140
+ app_name=cfg.name,
141
+ target="kubernetes",
142
+ output_dir=str(out),
143
+ files=[str(f) for f in files],
144
+ image=self.image,
145
+ namespace=self.namespace,
146
+ next_steps=self._next_steps(cfg),
147
+ )
148
+
149
+ def _generate_dockerfile(self, cfg, adapter) -> str:
150
+ extras = adapter.pip_extras()
151
+ pip_install = " ".join(extras) if extras else ""
152
+ env_lines = "\n".join(f'ENV {k}="{v}"' for k, v in cfg.env_vars.items())
153
+ # Healthcheck uses stdlib urllib so we don't need curl — keeps the
154
+ # image slim and avoids an apt-get layer + cache invalidation.
155
+ probe_url = f"http://localhost:{self.app._port}{self.app._health_path}"
156
+ healthcheck_probe = (
157
+ 'python -c "import urllib.request,sys; '
158
+ f"sys.exit(0 if urllib.request.urlopen('{probe_url}',timeout=5)"
159
+ '.status==200 else 1)"'
160
+ )
161
+ return f"""FROM {self._base_image}
162
+
163
+ WORKDIR /app
164
+
165
+ COPY requirements.txt* ./
166
+ RUN pip install --no-cache-dir fastapi uvicorn{(" " + pip_install) if pip_install else ""} \\
167
+ $(test -f requirements.txt && echo "-r requirements.txt" || echo "")
168
+
169
+ COPY . .
170
+
171
+ {env_lines}
172
+
173
+ EXPOSE {self.app._port}
174
+
175
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \\
176
+ CMD {healthcheck_probe} || exit 1
177
+
178
+ CMD ["python", "server.py"]
179
+ """
180
+
181
+ def _deployment_manifest(self, cfg) -> dict:
182
+ full_image = f"{self.registry}/{self.image}" if self.registry else self.image
183
+ env_vars = [{"name": k, "value": v} for k, v in cfg.env_vars.items()]
184
+ for secret_name in cfg.secrets:
185
+ env_vars.append(
186
+ {
187
+ "name": secret_name.upper().replace("-", "_"),
188
+ "valueFrom": {
189
+ "secretKeyRef": {
190
+ "name": f"{cfg.name}-secrets",
191
+ "key": secret_name,
192
+ }
193
+ },
194
+ }
195
+ )
196
+ return {
197
+ "apiVersion": "apps/v1",
198
+ "kind": "Deployment",
199
+ "metadata": {
200
+ "name": cfg.name,
201
+ "namespace": self.namespace,
202
+ "labels": {"app": cfg.name, "version": cfg.version},
203
+ },
204
+ "spec": {
205
+ "replicas": self._replicas,
206
+ "selector": {"matchLabels": {"app": cfg.name}},
207
+ "template": {
208
+ "metadata": {"labels": {"app": cfg.name, "version": cfg.version}},
209
+ "spec": {
210
+ "containers": [
211
+ {
212
+ "name": cfg.name,
213
+ "image": full_image,
214
+ "imagePullPolicy": "Always",
215
+ "ports": [{"containerPort": self.app._port}],
216
+ "env": env_vars,
217
+ "resources": {
218
+ "requests": {
219
+ "memory": f"{cfg.memory_mb // 2}Mi",
220
+ "cpu": "250m",
221
+ },
222
+ "limits": {
223
+ "memory": f"{cfg.memory_mb}Mi",
224
+ "cpu": "1000m",
225
+ },
226
+ },
227
+ "livenessProbe": {
228
+ "httpGet": {
229
+ "path": self.app._health_path,
230
+ "port": self.app._port,
231
+ },
232
+ "initialDelaySeconds": 15,
233
+ "periodSeconds": 30,
234
+ },
235
+ "readinessProbe": {
236
+ "httpGet": {
237
+ "path": self.app._health_path,
238
+ "port": self.app._port,
239
+ },
240
+ "initialDelaySeconds": 5,
241
+ "periodSeconds": 10,
242
+ },
243
+ }
244
+ ],
245
+ "terminationGracePeriodSeconds": cfg.timeout_seconds,
246
+ },
247
+ },
248
+ },
249
+ }
250
+
251
+ def _service_manifest(self, cfg) -> dict:
252
+ return {
253
+ "apiVersion": "v1",
254
+ "kind": "Service",
255
+ "metadata": {"name": cfg.name, "namespace": self.namespace},
256
+ "spec": {
257
+ "selector": {"app": cfg.name},
258
+ "ports": [{"port": 80, "targetPort": self.app._port, "protocol": "TCP"}],
259
+ "type": "ClusterIP",
260
+ },
261
+ }
262
+
263
+ def _hpa_manifest(self, cfg) -> dict:
264
+ return {
265
+ "apiVersion": "autoscaling/v2",
266
+ "kind": "HorizontalPodAutoscaler",
267
+ "metadata": {"name": f"{cfg.name}-hpa", "namespace": self.namespace},
268
+ "spec": {
269
+ "scaleTargetRef": {
270
+ "apiVersion": "apps/v1",
271
+ "kind": "Deployment",
272
+ "name": cfg.name,
273
+ },
274
+ "minReplicas": self._autoscale_min,
275
+ "maxReplicas": self._autoscale_max,
276
+ "metrics": [
277
+ {
278
+ "type": "Resource",
279
+ "resource": {
280
+ "name": "cpu",
281
+ "target": {
282
+ "type": "Utilization",
283
+ "averageUtilization": self._autoscale_cpu_pct,
284
+ },
285
+ },
286
+ }
287
+ ],
288
+ },
289
+ }
290
+
291
+ def _secret_placeholder(self, cfg) -> dict:
292
+ return {
293
+ "apiVersion": "v1",
294
+ "kind": "Secret",
295
+ "metadata": {
296
+ "name": f"{cfg.name}-secrets",
297
+ "namespace": self.namespace,
298
+ },
299
+ "type": "Opaque",
300
+ "stringData": {s: f"<FILL_IN_{s.upper()}>" for s in cfg.secrets},
301
+ }
302
+
303
+ def _kustomization(self, cfg) -> dict:
304
+ resources = ["deployment.yaml", "service.yaml", "hpa.yaml"]
305
+ if cfg.secrets:
306
+ resources.append("secret.yaml")
307
+ return {
308
+ "apiVersion": "kustomize.config.k8s.io/v1beta1",
309
+ "kind": "Kustomization",
310
+ "resources": resources,
311
+ }
312
+
313
+ def _next_steps(self, cfg) -> list[str]:
314
+ full_image = f"{self.registry}/{self.image}" if self.registry else self.image
315
+ steps = [
316
+ f"1. docker build -t {full_image} ./deploy/{cfg.name}",
317
+ f"2. docker push {full_image}",
318
+ f"3. kubectl apply -k ./deploy/{cfg.name}/k8s/",
319
+ f"4. kubectl rollout status deployment/{cfg.name} -n {self.namespace}",
320
+ ]
321
+ if cfg.secrets:
322
+ steps.insert(2, f" (fill in ./deploy/{cfg.name}/k8s/secret.yaml before applying)")
323
+ return steps
324
+
325
+
326
+ @dataclass
327
+ class BuildResult:
328
+ app_name: str
329
+ target: str
330
+ output_dir: str
331
+ files: list[str]
332
+ image: str
333
+ namespace: str
334
+ next_steps: list[str]
335
+
336
+ def __repr__(self) -> str:
337
+ files_str = "\n ".join(self.files)
338
+ steps_str = "\n ".join(self.next_steps)
339
+ return (
340
+ f"\nBuildResult for '{self.app_name}' -> {self.target}\n"
341
+ f"Output: {self.output_dir}\n"
342
+ f"Files:\n {files_str}\n"
343
+ f"Next steps:\n {steps_str}\n"
344
+ )
@@ -0,0 +1,160 @@
1
+ """
2
+ LambdaTarget — generates a Lambda-compatible handler + SAM template.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+
12
+ from agentdeploy.core.app import AgentApp
13
+
14
+
15
+ @dataclass
16
+ class LambdaTarget:
17
+ app: AgentApp
18
+ region: str = "us-east-1"
19
+ function_name: str = ""
20
+ role_arn: str = ""
21
+
22
+ _memory_mb: int = field(default=0, init=False)
23
+ _timeout_seconds: int = field(default=0, init=False)
24
+ _output_dir: str = field(default="./deploy", init=False)
25
+
26
+ def with_output_dir(self, path: str) -> LambdaTarget:
27
+ self._output_dir = path
28
+ return self
29
+
30
+ def build(self) -> LambdaResult:
31
+ # Fail fast at build time rather than shipping a placeholder ARN that
32
+ # turns into an obscure AWS deploy failure later.
33
+ if not self.role_arn:
34
+ raise ValueError(
35
+ "LambdaTarget requires an IAM role ARN. Pass it via "
36
+ "deploy(app).to_lambda(role_arn='arn:aws:iam::ACCOUNT:role/...'). "
37
+ "The role must allow lambda.amazonaws.com to assume it and "
38
+ "include AWSLambdaBasicExecutionRole at minimum."
39
+ )
40
+ if not self.role_arn.startswith("arn:aws:iam::"):
41
+ raise ValueError(
42
+ f"role_arn must be a full IAM role ARN starting with "
43
+ f"'arn:aws:iam::ACCOUNT:role/...', got: {self.role_arn!r}"
44
+ )
45
+
46
+ cfg = self.app.to_config()
47
+ adapter = self.app._adapter
48
+ mem = self._memory_mb or cfg.memory_mb
49
+ timeout = self._timeout_seconds or min(cfg.timeout_seconds, 900)
50
+
51
+ out = Path(self._output_dir) / cfg.name
52
+ out.mkdir(parents=True, exist_ok=True)
53
+
54
+ handler_code = self._handler_code(cfg, adapter)
55
+ (out / "handler.py").write_text(handler_code)
56
+
57
+ sam = self._sam_template(cfg, mem, timeout)
58
+ sam_path = out / "template.yaml"
59
+ sam_path.write_text(yaml.dump(sam, default_flow_style=False))
60
+
61
+ dockerfile = self._dockerfile(cfg, adapter)
62
+ (out / "Dockerfile").write_text(dockerfile)
63
+
64
+ return LambdaResult(
65
+ app_name=cfg.name,
66
+ output_dir=str(out),
67
+ next_steps=[
68
+ f"cd {out}",
69
+ "sam build",
70
+ f"sam deploy --region {self.region} --stack-name {self.function_name}",
71
+ ],
72
+ )
73
+
74
+ def _handler_code(self, cfg, adapter) -> str:
75
+ extras = adapter.pip_extras()
76
+ return f"""
77
+ import json, os, asyncio
78
+
79
+ # Framework imports injected by adapter: {", ".join(extras)}
80
+ from agent import agent # your agent module
81
+
82
+ def handler(event, context):
83
+ \"\"\"AWS Lambda entrypoint. Accepts API Gateway or direct invocation.\"\"\"
84
+ body = event.get("body", event)
85
+ if isinstance(body, str):
86
+ body = json.loads(body)
87
+
88
+ user_input = body.get("input", body)
89
+
90
+ try:
91
+ result = asyncio.run(_invoke(user_input))
92
+ return {{
93
+ "statusCode": 200,
94
+ "headers": {{"Content-Type": "application/json"}},
95
+ "body": json.dumps({{"result": result}}),
96
+ }}
97
+ except Exception as e:
98
+ return {{
99
+ "statusCode": 500,
100
+ "body": json.dumps({{"error": str(e)}}),
101
+ }}
102
+
103
+ async def _invoke(user_input):
104
+ if hasattr(agent, "ainvoke"):
105
+ return await agent.ainvoke(user_input)
106
+ elif hasattr(agent, "kickoff"):
107
+ return str(agent.kickoff(inputs=user_input))
108
+ elif callable(agent):
109
+ result = agent(user_input)
110
+ if asyncio.iscoroutine(result):
111
+ return await result
112
+ return result
113
+ raise RuntimeError("Agent has no invocable method.")
114
+ """
115
+
116
+ def _sam_template(self, cfg, mem: int, timeout: int) -> dict:
117
+ env = {k: v for k, v in cfg.env_vars.items()}
118
+ return {
119
+ "AWSTemplateFormatVersion": "2010-09-09",
120
+ "Transform": "AWS::Serverless-2016-10-31",
121
+ "Description": cfg.description or cfg.name,
122
+ "Resources": {
123
+ cfg.name.replace("-", ""): {
124
+ "Type": "AWS::Serverless::Function",
125
+ "Properties": {
126
+ "FunctionName": self.function_name or cfg.name,
127
+ "Handler": "handler.handler",
128
+ "Runtime": "python3.11",
129
+ "MemorySize": mem,
130
+ "Timeout": timeout,
131
+ "Role": self.role_arn,
132
+ "Environment": {"Variables": env},
133
+ "Events": {
134
+ "Api": {
135
+ "Type": "Api",
136
+ "Properties": {
137
+ "Path": "/invoke",
138
+ "Method": "post",
139
+ },
140
+ }
141
+ },
142
+ },
143
+ }
144
+ },
145
+ }
146
+
147
+ def _dockerfile(self, cfg, adapter) -> str:
148
+ extras = " ".join(adapter.pip_extras())
149
+ return f"""FROM public.ecr.aws/lambda/python:3.11
150
+ RUN pip install --no-cache-dir {extras or ""}
151
+ COPY . ${{LAMBDA_TASK_ROOT}}
152
+ CMD ["handler.handler"]
153
+ """
154
+
155
+
156
+ @dataclass
157
+ class LambdaResult:
158
+ app_name: str
159
+ output_dir: str
160
+ next_steps: list[str]