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,96 @@
1
+ """
2
+ deploy() — the fluent builder that returns the right DeployTarget.
3
+
4
+ Usage:
5
+ deploy(app).to_kubernetes(...).build()
6
+ deploy(app).to_docker_compose(...).build()
7
+ deploy(app).to_lambda(...).build()
8
+ deploy(app).to_cloud_run(...).build()
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from agentdeploy.core.app import AgentApp
14
+ from agentdeploy.targets.cloud_run import CloudRunTarget
15
+ from agentdeploy.targets.docker_compose import DockerComposeTarget
16
+ from agentdeploy.targets.kubernetes import KubernetesTarget
17
+ from agentdeploy.targets.lambda_target import LambdaTarget
18
+
19
+
20
+ class DeployBuilder:
21
+ """
22
+ Fluent entry point returned by deploy(app).
23
+ Choose a target then chain configuration methods.
24
+ """
25
+
26
+ def __init__(self, app: AgentApp) -> None:
27
+ self._app = app
28
+
29
+ def to_kubernetes(
30
+ self,
31
+ *,
32
+ namespace: str = "default",
33
+ image: str = "",
34
+ registry: str = "",
35
+ ) -> KubernetesTarget:
36
+ return KubernetesTarget(
37
+ app=self._app,
38
+ namespace=namespace,
39
+ image=image or f"{self._app.name}:{self._app.version}",
40
+ registry=registry,
41
+ )
42
+
43
+ def to_docker_compose(
44
+ self,
45
+ *,
46
+ output_dir: str = ".",
47
+ network: str = "agent-net",
48
+ ) -> DockerComposeTarget:
49
+ return DockerComposeTarget(
50
+ app=self._app,
51
+ output_dir=output_dir,
52
+ network=network,
53
+ )
54
+
55
+ def to_lambda(
56
+ self,
57
+ *,
58
+ region: str = "us-east-1",
59
+ function_name: str = "",
60
+ role_arn: str = "",
61
+ ) -> LambdaTarget:
62
+ return LambdaTarget(
63
+ app=self._app,
64
+ region=region,
65
+ function_name=function_name or self._app.name,
66
+ role_arn=role_arn,
67
+ )
68
+
69
+ def to_cloud_run(
70
+ self,
71
+ *,
72
+ project: str,
73
+ region: str = "us-central1",
74
+ service_name: str = "",
75
+ ) -> CloudRunTarget:
76
+ return CloudRunTarget(
77
+ app=self._app,
78
+ project=project,
79
+ region=region,
80
+ service_name=service_name or self._app.name,
81
+ )
82
+
83
+
84
+ def deploy(app: AgentApp) -> DeployBuilder:
85
+ """
86
+ Start a deployment pipeline for an AgentApp.
87
+
88
+ Returns a DeployBuilder — call .to_kubernetes(), .to_docker_compose(),
89
+ .to_lambda(), or .to_cloud_run() to select your target.
90
+ """
91
+ if app._agent is None:
92
+ raise ValueError(
93
+ f"AgentApp '{app.name}' has no wrapped agent. "
94
+ "Call app.wrap(your_agent) before deploying."
95
+ )
96
+ return DeployBuilder(app)
@@ -0,0 +1,203 @@
1
+ """
2
+ HITLGate — Human-in-the-Loop gate primitive.
3
+
4
+ Agents call gate.checkpoint(state) at decision points.
5
+ If the gate is armed, execution pauses and waits for a human
6
+ decision (approve / reject / modify) before continuing.
7
+
8
+ Delivery channels:
9
+ - Webhook (any HTTP endpoint)
10
+ - Slack (via incoming webhook URL)
11
+ - Console (for local dev — prints and waits for stdin)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import contextlib
18
+ import json
19
+ import logging
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from enum import StrEnum
23
+ from typing import Any
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class HITLDecision(StrEnum):
29
+ APPROVE = "approve"
30
+ REJECT = "reject"
31
+ MODIFY = "modify"
32
+
33
+
34
+ @dataclass
35
+ class HITLConfig:
36
+ webhook: str = ""
37
+ slack_channel: str = ""
38
+ timeout_seconds: int = 3600
39
+
40
+
41
+ @dataclass
42
+ class CheckpointResult:
43
+ decision: HITLDecision
44
+ modified_state: Any = None
45
+ reviewer: str = ""
46
+ reason: str = ""
47
+ reviewed_at: float = field(default_factory=time.time)
48
+
49
+
50
+ class HITLGate:
51
+ """
52
+ Add human oversight to any agent at a checkpoint.
53
+
54
+ Usage:
55
+ gate = HITLGate(webhook="https://yourapp.com/approve")
56
+
57
+ # Inside your agent or orchestrator:
58
+ result = await gate.checkpoint(
59
+ state=current_state,
60
+ description="Agent is about to call the payments API",
61
+ )
62
+ if result.decision == HITLDecision.REJECT:
63
+ return "Cancelled by reviewer"
64
+ state = result.modified_state or current_state
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ *,
70
+ webhook: str = "",
71
+ slack_webhook: str = "",
72
+ timeout_seconds: int = 3600,
73
+ auto_approve_after: int | None = None,
74
+ console_fallback: bool = True,
75
+ ) -> None:
76
+ self.webhook = webhook
77
+ self.slack_webhook = slack_webhook
78
+ self.timeout_seconds = timeout_seconds
79
+ self.auto_approve_after = auto_approve_after
80
+ self.console_fallback = console_fallback
81
+ self._pending: dict[str, asyncio.Future] = {}
82
+
83
+ async def checkpoint(
84
+ self,
85
+ state: Any,
86
+ *,
87
+ description: str = "",
88
+ run_id: str = "",
89
+ ) -> CheckpointResult:
90
+ """
91
+ Pause execution and wait for a human decision.
92
+
93
+ Returns a CheckpointResult with the decision and
94
+ optionally a modified state from the reviewer.
95
+ """
96
+ checkpoint_id = run_id or f"cp-{int(time.time() * 1000)}"
97
+ payload = {
98
+ "checkpoint_id": checkpoint_id,
99
+ "description": description,
100
+ "state": state if isinstance(state, (str, int, float, bool)) else str(state),
101
+ }
102
+
103
+ if self.webhook:
104
+ return await self._wait_for_webhook(checkpoint_id, payload)
105
+ elif self.slack_webhook:
106
+ return await self._notify_slack(checkpoint_id, payload)
107
+ elif self.console_fallback:
108
+ return await self._console_prompt(payload)
109
+ else:
110
+ logger.warning("HITLGate has no delivery channel — auto-approving.")
111
+ return CheckpointResult(decision=HITLDecision.APPROVE)
112
+
113
+ async def resolve(self, checkpoint_id: str, result: CheckpointResult) -> None:
114
+ """
115
+ Called by your webhook handler when the reviewer responds.
116
+ Use this in your FastAPI/Flask route that receives approvals.
117
+
118
+ Example:
119
+ @app.post("/approve/{checkpoint_id}")
120
+ async def approve(checkpoint_id: str, body: ApproveBody):
121
+ result = CheckpointResult(decision=HITLDecision.APPROVE, reviewer=body.user)
122
+ await gate.resolve(checkpoint_id, result)
123
+ """
124
+ future = self._pending.get(checkpoint_id)
125
+ if future and not future.done():
126
+ future.set_result(result)
127
+
128
+ async def _wait_for_webhook(self, checkpoint_id: str, payload: dict) -> CheckpointResult:
129
+ import httpx
130
+
131
+ loop = asyncio.get_event_loop()
132
+ future: asyncio.Future[CheckpointResult] = loop.create_future()
133
+ self._pending[checkpoint_id] = future
134
+
135
+ try:
136
+ async with httpx.AsyncClient() as client:
137
+ await client.post(
138
+ self.webhook,
139
+ json={**payload, "callback_id": checkpoint_id},
140
+ timeout=10,
141
+ )
142
+ except Exception as e:
143
+ logger.error(f"HITLGate webhook delivery failed: {e}")
144
+
145
+ try:
146
+ return await asyncio.wait_for(future, timeout=self.timeout_seconds)
147
+ except TimeoutError:
148
+ logger.warning(f"HITLGate checkpoint {checkpoint_id} timed out.")
149
+ if self.auto_approve_after and self.timeout_seconds >= self.auto_approve_after:
150
+ return CheckpointResult(
151
+ decision=HITLDecision.APPROVE,
152
+ reason="auto-approved after timeout",
153
+ )
154
+ return CheckpointResult(
155
+ decision=HITLDecision.REJECT,
156
+ reason="timed out waiting for human review",
157
+ )
158
+ finally:
159
+ self._pending.pop(checkpoint_id, None)
160
+
161
+ async def _notify_slack(self, checkpoint_id: str, payload: dict) -> CheckpointResult:
162
+ import httpx
163
+
164
+ message = {
165
+ "text": f"*HITL checkpoint* `{checkpoint_id}`\n{payload.get('description', '')}\n"
166
+ f"State: ```{payload.get('state', '')}```\n"
167
+ f"Reply with `/approve {checkpoint_id}` or `/reject {checkpoint_id}`"
168
+ }
169
+ try:
170
+ async with httpx.AsyncClient() as client:
171
+ await client.post(self.slack_webhook, json=message, timeout=10)
172
+ except Exception as e:
173
+ logger.error(f"Slack delivery failed: {e}")
174
+
175
+ loop = asyncio.get_event_loop()
176
+ future: asyncio.Future[CheckpointResult] = loop.create_future()
177
+ self._pending[checkpoint_id] = future
178
+ try:
179
+ return await asyncio.wait_for(future, timeout=self.timeout_seconds)
180
+ except TimeoutError:
181
+ return CheckpointResult(
182
+ decision=HITLDecision.REJECT,
183
+ reason="timed out waiting for Slack response",
184
+ )
185
+ finally:
186
+ self._pending.pop(checkpoint_id, None)
187
+
188
+ async def _console_prompt(self, payload: dict) -> CheckpointResult:
189
+ print(f"\n[HITLGate] Checkpoint: {payload.get('description', '')}")
190
+ print(f"State: {payload.get('state', '')}")
191
+ choice = input("Decision [approve/reject/modify]: ").strip().lower()
192
+ if choice.startswith("a"):
193
+ return CheckpointResult(decision=HITLDecision.APPROVE, reviewer="console")
194
+ elif choice.startswith("m"):
195
+ modified: Any = input("Enter modified state (JSON or string): ").strip()
196
+ with contextlib.suppress(json.JSONDecodeError):
197
+ modified = json.loads(modified)
198
+ return CheckpointResult(
199
+ decision=HITLDecision.MODIFY,
200
+ modified_state=modified,
201
+ reviewer="console",
202
+ )
203
+ return CheckpointResult(decision=HITLDecision.REJECT, reviewer="console")
@@ -0,0 +1,160 @@
1
+ """
2
+ Telemetry — wraps OpenTelemetry tracing for agent runs.
3
+
4
+ Automatically instruments:
5
+ - Per-run spans with agent name, framework, input/output tokens
6
+ - Cost estimation per provider
7
+ - Failure traces with exception capture
8
+
9
+ Usage:
10
+ telemetry = Telemetry(service_name="my-agent")
11
+
12
+ async with telemetry.trace("invoke", input=user_input) as span:
13
+ result = await my_agent.invoke(user_input)
14
+ span.set_tokens(prompt=512, completion=128, model="gpt-4o")
15
+ # Span is ended automatically, cost is calculated and logged
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import time
21
+ from collections.abc import AsyncGenerator
22
+ from contextlib import asynccontextmanager
23
+ from dataclasses import dataclass
24
+ from typing import Any
25
+
26
+ try:
27
+ from opentelemetry import trace
28
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
29
+ from opentelemetry.sdk.trace import TracerProvider
30
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
31
+
32
+ OTEL_AVAILABLE = True
33
+ except ImportError:
34
+ OTEL_AVAILABLE = False
35
+
36
+
37
+ COST_PER_1K_TOKENS: dict[str, dict[str, float]] = {
38
+ "gpt-4o": {"prompt": 0.005, "completion": 0.015},
39
+ "gpt-4o-mini": {"prompt": 0.00015, "completion": 0.0006},
40
+ "claude-sonnet-4-6": {"prompt": 0.003, "completion": 0.015},
41
+ "claude-opus-4-6": {"prompt": 0.015, "completion": 0.075},
42
+ "gemini-1.5-pro": {"prompt": 0.0035, "completion": 0.0105},
43
+ }
44
+
45
+
46
+ @dataclass
47
+ class OtelConfig:
48
+ endpoint: str = "http://localhost:4317"
49
+ service_name: str = "agentdeploy"
50
+
51
+
52
+ class AgentSpan:
53
+ """Thin wrapper around an OTel span with agent-specific helpers."""
54
+
55
+ def __init__(self, span: Any, start_time: float) -> None:
56
+ self._span = span
57
+ self._start_time = start_time
58
+ self._prompt_tokens = 0
59
+ self._completion_tokens = 0
60
+ self._model = ""
61
+
62
+ def set_tokens(
63
+ self,
64
+ *,
65
+ prompt: int = 0,
66
+ completion: int = 0,
67
+ model: str = "",
68
+ ) -> None:
69
+ self._prompt_tokens = prompt
70
+ self._completion_tokens = completion
71
+ self._model = model
72
+ if self._span and OTEL_AVAILABLE:
73
+ self._span.set_attribute("llm.prompt_tokens", prompt)
74
+ self._span.set_attribute("llm.completion_tokens", completion)
75
+ self._span.set_attribute("llm.model", model)
76
+ cost = self._estimate_cost(prompt, completion, model)
77
+ if cost is not None:
78
+ self._span.set_attribute("llm.cost_usd", round(cost, 6))
79
+
80
+ def set_attribute(self, key: str, value: Any) -> None:
81
+ if self._span and OTEL_AVAILABLE:
82
+ self._span.set_attribute(key, value)
83
+
84
+ def record_exception(self, exc: Exception) -> None:
85
+ if self._span and OTEL_AVAILABLE:
86
+ self._span.record_exception(exc)
87
+
88
+ @property
89
+ def elapsed_ms(self) -> float:
90
+ return (time.perf_counter() - self._start_time) * 1000
91
+
92
+ def _estimate_cost(self, prompt: int, completion: int, model: str) -> float | None:
93
+ rates = COST_PER_1K_TOKENS.get(model)
94
+ if not rates:
95
+ return None
96
+ return (prompt / 1000 * rates["prompt"]) + (completion / 1000 * rates["completion"])
97
+
98
+
99
+ class Telemetry:
100
+ """
101
+ Agent telemetry wrapper. Initialise once per service, then
102
+ use as an async context manager around agent invocations.
103
+ """
104
+
105
+ def __init__(
106
+ self,
107
+ service_name: str,
108
+ *,
109
+ otel_endpoint: str = "",
110
+ enabled: bool = True,
111
+ ) -> None:
112
+ self.service_name = service_name
113
+ self.enabled = enabled and OTEL_AVAILABLE
114
+ self._tracer = None
115
+
116
+ if self.enabled and otel_endpoint:
117
+ provider = TracerProvider()
118
+ exporter = OTLPSpanExporter(endpoint=otel_endpoint, insecure=True)
119
+ provider.add_span_processor(BatchSpanProcessor(exporter))
120
+ trace.set_tracer_provider(provider)
121
+ self._tracer = trace.get_tracer(service_name)
122
+
123
+ @asynccontextmanager
124
+ async def trace(
125
+ self,
126
+ operation: str,
127
+ *,
128
+ input: Any = None,
129
+ agent_name: str = "",
130
+ framework: str = "",
131
+ ) -> AsyncGenerator[AgentSpan, None]:
132
+ """
133
+ Async context manager that wraps an agent operation in a span.
134
+
135
+ async with telemetry.trace("invoke", input=user_msg) as span:
136
+ result = await agent.run(user_msg)
137
+ span.set_tokens(prompt=100, completion=50, model="claude-sonnet-4-6")
138
+ """
139
+ start = time.perf_counter()
140
+
141
+ if self.enabled and self._tracer:
142
+ with self._tracer.start_as_current_span(
143
+ f"{self.service_name}.{operation}"
144
+ ) as otel_span:
145
+ if agent_name:
146
+ otel_span.set_attribute("agent.name", agent_name)
147
+ if framework:
148
+ otel_span.set_attribute("agent.framework", framework)
149
+ agent_span = AgentSpan(otel_span, start)
150
+ try:
151
+ yield agent_span
152
+ except Exception as e:
153
+ agent_span.record_exception(e)
154
+ raise
155
+ else:
156
+ agent_span = AgentSpan(None, start)
157
+ try:
158
+ yield agent_span
159
+ except Exception:
160
+ raise
@@ -0,0 +1,17 @@
1
+ """Deployment targets: Kubernetes, Docker Compose, AWS Lambda, Google Cloud Run."""
2
+
3
+ from agentdeploy.targets.cloud_run import CloudRunResult, CloudRunTarget
4
+ from agentdeploy.targets.docker_compose import DockerComposeResult, DockerComposeTarget
5
+ from agentdeploy.targets.kubernetes import BuildResult, KubernetesTarget
6
+ from agentdeploy.targets.lambda_target import LambdaResult, LambdaTarget
7
+
8
+ __all__ = [
9
+ "KubernetesTarget",
10
+ "BuildResult",
11
+ "DockerComposeTarget",
12
+ "DockerComposeResult",
13
+ "LambdaTarget",
14
+ "LambdaResult",
15
+ "CloudRunTarget",
16
+ "CloudRunResult",
17
+ ]
@@ -0,0 +1,128 @@
1
+ """
2
+ CloudRunTarget — generates a Dockerfile and Cloud Run service YAML
3
+ for GCP serverless deployment.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+
13
+ from agentdeploy.core.app import AgentApp
14
+
15
+
16
+ @dataclass
17
+ class CloudRunTarget:
18
+ app: AgentApp
19
+ project: str = ""
20
+ region: str = "us-central1"
21
+ service_name: str = ""
22
+
23
+ _max_instances: int = field(default=10, init=False)
24
+ _min_instances: int = field(default=0, init=False)
25
+ _concurrency: int = field(default=80, init=False)
26
+ _output_dir: str = field(default="./deploy", init=False)
27
+
28
+ def with_scaling(
29
+ self,
30
+ *,
31
+ min_instances: int = 0,
32
+ max_instances: int = 10,
33
+ concurrency: int = 80,
34
+ ) -> CloudRunTarget:
35
+ self._min_instances = min_instances
36
+ self._max_instances = max_instances
37
+ self._concurrency = concurrency
38
+ return self
39
+
40
+ def with_output_dir(self, path: str) -> CloudRunTarget:
41
+ self._output_dir = path
42
+ return self
43
+
44
+ def build(self) -> CloudRunResult:
45
+ cfg = self.app.to_config()
46
+ adapter = self.app._adapter
47
+ svc = self.service_name or cfg.name
48
+ image = f"gcr.io/{self.project}/{svc}:{cfg.version}"
49
+
50
+ out = Path(self._output_dir) / cfg.name
51
+ out.mkdir(parents=True, exist_ok=True)
52
+
53
+ extras = " ".join(adapter.pip_extras())
54
+ dockerfile = f"""FROM python:3.11-slim
55
+ WORKDIR /app
56
+ RUN pip install --no-cache-dir fastapi uvicorn {extras}
57
+ COPY . .
58
+ EXPOSE {self.app._port}
59
+ CMD ["python", "server.py"]
60
+ """
61
+ (out / "Dockerfile").write_text(dockerfile)
62
+ server_code = adapter.entrypoint_code(cfg.name, self.app._port)
63
+ (out / "server.py").write_text(server_code)
64
+
65
+ service_yaml = self._service_manifest(cfg, svc, image)
66
+ svc_path = out / "service.yaml"
67
+ svc_path.write_text(yaml.dump(service_yaml, default_flow_style=False))
68
+
69
+ return CloudRunResult(
70
+ app_name=cfg.name,
71
+ output_dir=str(out),
72
+ image=image,
73
+ next_steps=[
74
+ f"gcloud builds submit --tag {image}",
75
+ f"gcloud run services replace {svc_path} --region {self.region}",
76
+ f"gcloud run services add-iam-policy-binding {svc} "
77
+ f"--region {self.region} --member allUsers --role roles/run.invoker",
78
+ ],
79
+ )
80
+
81
+ def _service_manifest(self, cfg, svc: str, image: str) -> dict:
82
+ env_vars = [{"name": k, "value": v} for k, v in cfg.env_vars.items()]
83
+ return {
84
+ "apiVersion": "serving.knative.dev/v1",
85
+ "kind": "Service",
86
+ "metadata": {
87
+ "name": svc,
88
+ "annotations": {
89
+ "run.googleapis.com/ingress": "all",
90
+ "run.googleapis.com/region": self.region,
91
+ },
92
+ },
93
+ "spec": {
94
+ "template": {
95
+ "metadata": {
96
+ "annotations": {
97
+ "autoscaling.knative.dev/minScale": str(self._min_instances),
98
+ "autoscaling.knative.dev/maxScale": str(self._max_instances),
99
+ }
100
+ },
101
+ "spec": {
102
+ "containerConcurrency": self._concurrency,
103
+ "timeoutSeconds": cfg.timeout_seconds,
104
+ "containers": [
105
+ {
106
+ "image": image,
107
+ "ports": [{"containerPort": self.app._port}],
108
+ "env": env_vars,
109
+ "resources": {
110
+ "limits": {
111
+ "memory": f"{cfg.memory_mb}Mi",
112
+ "cpu": "1000m",
113
+ }
114
+ },
115
+ }
116
+ ],
117
+ },
118
+ }
119
+ },
120
+ }
121
+
122
+
123
+ @dataclass
124
+ class CloudRunResult:
125
+ app_name: str
126
+ output_dir: str
127
+ image: str
128
+ next_steps: list[str]