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.
- agentdeploy/__init__.py +32 -0
- agentdeploy/adapters/__init__.py +6 -0
- agentdeploy/adapters/base.py +46 -0
- agentdeploy/adapters/registry.py +221 -0
- agentdeploy/cli/__init__.py +5 -0
- agentdeploy/cli/main.py +266 -0
- agentdeploy/core/__init__.py +1 -0
- agentdeploy/core/app.py +104 -0
- agentdeploy/core/deploy.py +96 -0
- agentdeploy/core/hitl.py +203 -0
- agentdeploy/core/observability.py +160 -0
- agentdeploy/targets/__init__.py +17 -0
- agentdeploy/targets/cloud_run.py +128 -0
- agentdeploy/targets/docker_compose.py +145 -0
- agentdeploy/targets/kubernetes.py +344 -0
- agentdeploy/targets/lambda_target.py +160 -0
- agentdeploy-0.1.1.dist-info/METADATA +337 -0
- agentdeploy-0.1.1.dist-info/RECORD +21 -0
- agentdeploy-0.1.1.dist-info/WHEEL +4 -0
- agentdeploy-0.1.1.dist-info/entry_points.txt +2 -0
- agentdeploy-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|
agentdeploy/core/hitl.py
ADDED
|
@@ -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]
|