dispatch_agents 0.9.0__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.
- agentservice/__init__.py +0 -0
- agentservice/py.typed +0 -0
- agentservice/v1/__init__.py +0 -0
- agentservice/v1/message_pb2.py +41 -0
- agentservice/v1/message_pb2.pyi +22 -0
- agentservice/v1/message_pb2_grpc.py +4 -0
- agentservice/v1/request_response_pb2.py +46 -0
- agentservice/v1/request_response_pb2.pyi +54 -0
- agentservice/v1/request_response_pb2_grpc.py +4 -0
- agentservice/v1/service_pb2.py +43 -0
- agentservice/v1/service_pb2.pyi +6 -0
- agentservice/v1/service_pb2_grpc.py +129 -0
- dispatch_agents/__init__.py +281 -0
- dispatch_agents/agent_service.py +135 -0
- dispatch_agents/config.py +490 -0
- dispatch_agents/contrib/__init__.py +1 -0
- dispatch_agents/contrib/claude/__init__.py +246 -0
- dispatch_agents/contrib/openai/__init__.py +167 -0
- dispatch_agents/events.py +986 -0
- dispatch_agents/grpc_server.py +565 -0
- dispatch_agents/instrument.py +217 -0
- dispatch_agents/integrations/__init__.py +1 -0
- dispatch_agents/integrations/github/README.md +9 -0
- dispatch_agents/integrations/github/__init__.py +4268 -0
- dispatch_agents/invocation.py +25 -0
- dispatch_agents/llm.py +1017 -0
- dispatch_agents/llm_langchain.py +394 -0
- dispatch_agents/logging_config.py +133 -0
- dispatch_agents/mcp.py +266 -0
- dispatch_agents/memory.py +264 -0
- dispatch_agents/models.py +748 -0
- dispatch_agents/proxy/__init__.py +6 -0
- dispatch_agents/proxy/server.py +1137 -0
- dispatch_agents/proxy/sse_utils.py +76 -0
- dispatch_agents/py.typed +0 -0
- dispatch_agents/resources.py +68 -0
- dispatch_agents/version.py +19 -0
- dispatch_agents-0.9.0.dist-info/METADATA +20 -0
- dispatch_agents-0.9.0.dist-info/RECORD +43 -0
- dispatch_agents-0.9.0.dist-info/WHEEL +4 -0
- dispatch_agents-0.9.0.dist-info/licenses/LICENSE +191 -0
- dispatch_agents-0.9.0.dist-info/licenses/LICENSE-3rdparty.csv +12 -0
- dispatch_agents-0.9.0.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""gRPC client for the AgentService.
|
|
2
|
+
|
|
3
|
+
This module provides a high-level async client for invoking agents via gRPC.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import grpc
|
|
7
|
+
|
|
8
|
+
from agentservice.v1 import (
|
|
9
|
+
request_response_pb2,
|
|
10
|
+
service_pb2_grpc,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AgentServiceClient",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AgentServiceClient:
|
|
19
|
+
"""Async gRPC client for the AgentService.
|
|
20
|
+
|
|
21
|
+
This client wraps the generated gRPC stub to provide a clean, typed interface
|
|
22
|
+
for invoking agents.
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
>>> from agentservice.v1 import request_response_pb2
|
|
26
|
+
>>> from google.protobuf import json_format, struct_pb2
|
|
27
|
+
>>> async with AgentServiceClient("localhost:50051") as client:
|
|
28
|
+
... payload = struct_pb2.Value()
|
|
29
|
+
... json_format.ParseDict({"query": "Hello"}, payload)
|
|
30
|
+
... request = request_response_pb2.InvokeRequest(
|
|
31
|
+
... agent_name="my-agent",
|
|
32
|
+
... org_id="my-org",
|
|
33
|
+
... topic="user.query",
|
|
34
|
+
... payload=payload,
|
|
35
|
+
... uid="msg-123",
|
|
36
|
+
... trace_id="trace-456",
|
|
37
|
+
... sender_id="user-1",
|
|
38
|
+
... ts="2025-01-01T00:00:00Z",
|
|
39
|
+
... )
|
|
40
|
+
... response = await client.invoke(request)
|
|
41
|
+
... print(response.result)
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
target: str,
|
|
47
|
+
*,
|
|
48
|
+
insecure: bool = False,
|
|
49
|
+
credentials: grpc.ChannelCredentials | None = None,
|
|
50
|
+
):
|
|
51
|
+
"""Initialize the AgentService client.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
target: The gRPC server address (e.g., "localhost:50051")
|
|
55
|
+
insecure: If True, use an insecure channel (for development only)
|
|
56
|
+
credentials: Optional gRPC channel credentials for TLS/mTLS
|
|
57
|
+
"""
|
|
58
|
+
self.target = target
|
|
59
|
+
self.insecure = insecure
|
|
60
|
+
self.credentials = credentials
|
|
61
|
+
self._channel: grpc.aio.Channel | None = None
|
|
62
|
+
self._stub: service_pb2_grpc.AgentServiceStub | None = None
|
|
63
|
+
|
|
64
|
+
async def __aenter__(self) -> "AgentServiceClient":
|
|
65
|
+
"""Enter async context and establish gRPC channel."""
|
|
66
|
+
if self.insecure:
|
|
67
|
+
self._channel = grpc.aio.insecure_channel(self.target)
|
|
68
|
+
else:
|
|
69
|
+
if self.credentials is None:
|
|
70
|
+
# Default to SSL with system root certificates
|
|
71
|
+
self.credentials = grpc.ssl_channel_credentials()
|
|
72
|
+
self._channel = grpc.aio.secure_channel(self.target, self.credentials)
|
|
73
|
+
|
|
74
|
+
self._stub = service_pb2_grpc.AgentServiceStub(self._channel)
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
78
|
+
"""Exit async context and close gRPC channel."""
|
|
79
|
+
if self._channel:
|
|
80
|
+
await self._channel.close()
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
async def invoke(
|
|
84
|
+
self,
|
|
85
|
+
request: request_response_pb2.InvokeRequest,
|
|
86
|
+
*,
|
|
87
|
+
timeout: float | None = None,
|
|
88
|
+
) -> request_response_pb2.InvokeResponse:
|
|
89
|
+
"""Invoke an agent and await the result.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
request: The InvokeRequest protobuf message
|
|
93
|
+
timeout: Optional timeout in seconds for the RPC call
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
InvokeResponse protobuf message containing the agent's result
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
grpc.RpcError: If the gRPC call fails
|
|
100
|
+
"""
|
|
101
|
+
if self._stub is None:
|
|
102
|
+
raise RuntimeError(
|
|
103
|
+
"Client not initialized. Use 'async with AgentServiceClient(...)' context manager."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Make the gRPC call
|
|
107
|
+
response = await self._stub.Invoke(request, timeout=timeout)
|
|
108
|
+
return response
|
|
109
|
+
|
|
110
|
+
async def health_check(
|
|
111
|
+
self,
|
|
112
|
+
request: request_response_pb2.HealthCheckRequest,
|
|
113
|
+
*,
|
|
114
|
+
timeout: float | None = None,
|
|
115
|
+
) -> request_response_pb2.HealthCheckResponse:
|
|
116
|
+
"""Check agent health status.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
request: The HealthCheckRequest protobuf message
|
|
120
|
+
timeout: Optional timeout in seconds for the RPC call
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
HealthCheckResponse protobuf message containing the agent's health status
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
grpc.RpcError: If the gRPC call fails
|
|
127
|
+
"""
|
|
128
|
+
if self._stub is None:
|
|
129
|
+
raise RuntimeError(
|
|
130
|
+
"Client not initialized. Use 'async with AgentServiceClient(...)' context manager."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Make the gRPC call
|
|
134
|
+
response = await self._stub.HealthCheck(request, timeout=timeout)
|
|
135
|
+
return response
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
"""Configuration models for dispatch.yaml files.
|
|
2
|
+
|
|
3
|
+
This module defines the schema for agent deployment configuration,
|
|
4
|
+
shared between CLI and backend.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
12
|
+
|
|
13
|
+
from dispatch_agents.resources import _parse_cpu, _parse_memory
|
|
14
|
+
|
|
15
|
+
# Env var names managed by the platform — users must not override these.
|
|
16
|
+
RESERVED_ENV_VARS: frozenset[str] = frozenset(
|
|
17
|
+
{
|
|
18
|
+
"BACKEND_URL",
|
|
19
|
+
"DISPATCH_API_KEY",
|
|
20
|
+
"DISPATCH_NAMESPACE",
|
|
21
|
+
"DISPATCH_AGENT_NAME",
|
|
22
|
+
"DISPATCH_AGENT_VERSION",
|
|
23
|
+
"MCP_CONFIG_JSON",
|
|
24
|
+
"MCP_GATEWAY_URL",
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class VolumeMode(StrEnum):
|
|
30
|
+
"""Volume access mode for persistent storage.
|
|
31
|
+
|
|
32
|
+
Currently only read_write_many is supported, but this enum allows
|
|
33
|
+
for future expansion to other modes like read_only, read_write_once, etc.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
READ_WRITE_MANY = "read_write_many"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class VolumeConfig(BaseModel):
|
|
40
|
+
"""Configuration for a persistent storage volume.
|
|
41
|
+
|
|
42
|
+
Volumes provide persistent storage that survives container restarts
|
|
43
|
+
and redeployments. Data is isolated per-agent.
|
|
44
|
+
|
|
45
|
+
Example:
|
|
46
|
+
volumes:
|
|
47
|
+
- name: plans
|
|
48
|
+
mountPath: /data/plans
|
|
49
|
+
mode: read_write_many
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
name: str = Field(
|
|
53
|
+
...,
|
|
54
|
+
description="Unique name for the volume (used for identification and cleanup)",
|
|
55
|
+
min_length=1,
|
|
56
|
+
max_length=63,
|
|
57
|
+
pattern=r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$",
|
|
58
|
+
)
|
|
59
|
+
mount_path: str = Field(
|
|
60
|
+
...,
|
|
61
|
+
alias="mountPath",
|
|
62
|
+
description="Path where the volume will be mounted inside the container (must be within /data)",
|
|
63
|
+
)
|
|
64
|
+
mode: VolumeMode = Field(
|
|
65
|
+
...,
|
|
66
|
+
description="Access mode for the volume (required)",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@field_validator("mount_path")
|
|
70
|
+
@classmethod
|
|
71
|
+
def _validate_mount_path(cls, v: str) -> str:
|
|
72
|
+
"""Ensure mount path is within /data directory."""
|
|
73
|
+
if not v.startswith("/data"):
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"mountPath must be within /data directory, got: {v}. "
|
|
76
|
+
"Example: /data/plans or /data"
|
|
77
|
+
)
|
|
78
|
+
# Normalize path (remove trailing slashes, etc.)
|
|
79
|
+
return os.path.normpath(v)
|
|
80
|
+
|
|
81
|
+
model_config = {"populate_by_name": True}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class SecretConfig(BaseModel):
|
|
85
|
+
"""Configuration for a secret to be injected as an environment variable.
|
|
86
|
+
|
|
87
|
+
Secrets are retrieved from the secrets manager and injected into the
|
|
88
|
+
container as environment variables at runtime.
|
|
89
|
+
|
|
90
|
+
Example:
|
|
91
|
+
secrets:
|
|
92
|
+
- name: OPENAI_API_KEY
|
|
93
|
+
secret_id: /shared/openai-api-key
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
name: str = Field(
|
|
97
|
+
...,
|
|
98
|
+
description="Environment variable name for the secret",
|
|
99
|
+
min_length=1,
|
|
100
|
+
)
|
|
101
|
+
secret_id: str = Field(
|
|
102
|
+
...,
|
|
103
|
+
description="Path to the secret in secrets manager",
|
|
104
|
+
min_length=1,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class MCPServerConfig(BaseModel):
|
|
109
|
+
"""Configuration for an MCP server to connect to.
|
|
110
|
+
|
|
111
|
+
Example:
|
|
112
|
+
mcp_servers:
|
|
113
|
+
- server: datadog
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
server: str = Field(
|
|
117
|
+
...,
|
|
118
|
+
description="MCP server installation name from the registry",
|
|
119
|
+
min_length=1,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _get_valid_memory_for_cpu(cpu_units: int) -> list[int] | None:
|
|
124
|
+
"""Get valid memory values (in MB) for a given CPU value.
|
|
125
|
+
|
|
126
|
+
Returns None if the CPU value is not valid.
|
|
127
|
+
|
|
128
|
+
Valid CPU/Memory combinations for AWS ECS Fargate:
|
|
129
|
+
https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-cpu-memory-error.html
|
|
130
|
+
"""
|
|
131
|
+
# CPU units: 1024 = 1 vCPU, Memory: MB
|
|
132
|
+
valid_combinations: dict[int, list[int]] = {
|
|
133
|
+
256: [512, 1024, 2048],
|
|
134
|
+
512: [1024, 2048, 3072, 4096],
|
|
135
|
+
1024: [2048, 3072, 4096, 5120, 6144, 7168, 8192],
|
|
136
|
+
2048: [
|
|
137
|
+
4096,
|
|
138
|
+
5120,
|
|
139
|
+
6144,
|
|
140
|
+
7168,
|
|
141
|
+
8192,
|
|
142
|
+
9216,
|
|
143
|
+
10240,
|
|
144
|
+
11264,
|
|
145
|
+
12288,
|
|
146
|
+
13312,
|
|
147
|
+
14336,
|
|
148
|
+
15360,
|
|
149
|
+
16384,
|
|
150
|
+
],
|
|
151
|
+
4096: [
|
|
152
|
+
8192,
|
|
153
|
+
9216,
|
|
154
|
+
10240,
|
|
155
|
+
11264,
|
|
156
|
+
12288,
|
|
157
|
+
13312,
|
|
158
|
+
14336,
|
|
159
|
+
15360,
|
|
160
|
+
16384,
|
|
161
|
+
17408,
|
|
162
|
+
18432,
|
|
163
|
+
19456,
|
|
164
|
+
20480,
|
|
165
|
+
21504,
|
|
166
|
+
22528,
|
|
167
|
+
23552,
|
|
168
|
+
24576,
|
|
169
|
+
25600,
|
|
170
|
+
26624,
|
|
171
|
+
27648,
|
|
172
|
+
28672,
|
|
173
|
+
29696,
|
|
174
|
+
30720,
|
|
175
|
+
],
|
|
176
|
+
8192: list(range(16384, 61441, 4096)), # 16GB to 60GB in 4GB increments
|
|
177
|
+
16384: list(range(32768, 122881, 8192)), # 32GB to 120GB in 8GB increments
|
|
178
|
+
}
|
|
179
|
+
return valid_combinations.get(cpu_units)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _get_valid_cpu_values() -> list[int]:
|
|
183
|
+
"""Get list of valid CPU values (in internal units where 1024 = 1 vCPU)."""
|
|
184
|
+
return [256, 512, 1024, 2048, 4096, 8192, 16384]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _format_cpu(cpu_units: int) -> str:
|
|
188
|
+
"""Format internal CPU units to Kubernetes-style string.
|
|
189
|
+
|
|
190
|
+
Examples:
|
|
191
|
+
256 -> "250m"
|
|
192
|
+
512 -> "500m"
|
|
193
|
+
1024 -> "1"
|
|
194
|
+
2048 -> "2"
|
|
195
|
+
"""
|
|
196
|
+
if cpu_units >= 1024 and cpu_units % 1024 == 0:
|
|
197
|
+
# Whole cores
|
|
198
|
+
return str(cpu_units // 1024)
|
|
199
|
+
else:
|
|
200
|
+
# Millicores
|
|
201
|
+
millicores = int(cpu_units * 1000 / 1024)
|
|
202
|
+
return f"{millicores}m"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _format_memory(memory_mb: int) -> str:
|
|
206
|
+
"""Format memory in MB to Kubernetes-style string.
|
|
207
|
+
|
|
208
|
+
Examples:
|
|
209
|
+
512 -> "512Mi"
|
|
210
|
+
1024 -> "1Gi"
|
|
211
|
+
2048 -> "2Gi"
|
|
212
|
+
"""
|
|
213
|
+
if memory_mb >= 1024 and memory_mb % 1024 == 0:
|
|
214
|
+
# Use Gi for whole gibibytes
|
|
215
|
+
return f"{memory_mb // 1024}Gi"
|
|
216
|
+
else:
|
|
217
|
+
return f"{memory_mb}Mi"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class ResourceLimits(BaseModel):
|
|
221
|
+
"""CPU and memory limits for a container.
|
|
222
|
+
|
|
223
|
+
CPU is specified in Kubernetes format:
|
|
224
|
+
- Millicores: "250m", "500m", "1000m"
|
|
225
|
+
- Cores: "0.25", "0.5", "1", "2"
|
|
226
|
+
|
|
227
|
+
Memory is specified in Kubernetes format:
|
|
228
|
+
- Mebibytes: "512Mi", "1024Mi"
|
|
229
|
+
- Gibibytes: "1Gi", "2Gi"
|
|
230
|
+
|
|
231
|
+
Example:
|
|
232
|
+
limits:
|
|
233
|
+
cpu: "500m"
|
|
234
|
+
memory: "1Gi"
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
cpu: str = Field(
|
|
238
|
+
default="250m",
|
|
239
|
+
description="CPU (e.g., '250m', '500m', '1', '2')",
|
|
240
|
+
)
|
|
241
|
+
memory: str = Field(
|
|
242
|
+
default="2Gi",
|
|
243
|
+
description="Memory (e.g., '512Mi', '1Gi', '2Gi')",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
@field_validator("cpu")
|
|
247
|
+
@classmethod
|
|
248
|
+
def _validate_cpu(cls, v: str) -> str:
|
|
249
|
+
"""Parse and validate CPU value."""
|
|
250
|
+
try:
|
|
251
|
+
cpu_units = _parse_cpu(v)
|
|
252
|
+
except (ValueError, TypeError) as e:
|
|
253
|
+
raise ValueError(f"Invalid CPU format '{v}': {e}") from e
|
|
254
|
+
|
|
255
|
+
valid_cpu_values = _get_valid_cpu_values()
|
|
256
|
+
if cpu_units not in valid_cpu_values:
|
|
257
|
+
valid_cpus = [_format_cpu(c) for c in sorted(valid_cpu_values)]
|
|
258
|
+
raise ValueError(
|
|
259
|
+
f"Invalid CPU value '{v}' ({cpu_units} units). "
|
|
260
|
+
f"Must be one of: {valid_cpus}"
|
|
261
|
+
)
|
|
262
|
+
return v
|
|
263
|
+
|
|
264
|
+
@field_validator("memory")
|
|
265
|
+
@classmethod
|
|
266
|
+
def _validate_memory(cls, v: str) -> str:
|
|
267
|
+
"""Parse and validate memory value."""
|
|
268
|
+
try:
|
|
269
|
+
memory_mb = _parse_memory(v)
|
|
270
|
+
except (ValueError, TypeError) as e:
|
|
271
|
+
raise ValueError(f"Invalid memory format '{v}': {e}") from e
|
|
272
|
+
|
|
273
|
+
# Minimum memory limit: 256 MB (required for sidecar overhead)
|
|
274
|
+
min_memory_mb = 256
|
|
275
|
+
if memory_mb < min_memory_mb:
|
|
276
|
+
raise ValueError(
|
|
277
|
+
f"Memory value '{v}' ({memory_mb} MB) is below minimum allowed "
|
|
278
|
+
f"of {_format_memory(min_memory_mb)}."
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Maximum memory limit: 16GB (16384 MB)
|
|
282
|
+
max_memory_mb = 16384
|
|
283
|
+
if memory_mb > max_memory_mb:
|
|
284
|
+
raise ValueError(
|
|
285
|
+
f"Memory value '{v}' ({memory_mb} MB) exceeds maximum allowed "
|
|
286
|
+
f"of {_format_memory(max_memory_mb)}. "
|
|
287
|
+
"Contact support if you need more resources."
|
|
288
|
+
)
|
|
289
|
+
return v
|
|
290
|
+
|
|
291
|
+
@model_validator(mode="after")
|
|
292
|
+
def _validate_combination(self) -> "ResourceLimits":
|
|
293
|
+
"""Validate that CPU and memory form a valid combination."""
|
|
294
|
+
cpu_units = _parse_cpu(self.cpu)
|
|
295
|
+
memory_mb = _parse_memory(self.memory)
|
|
296
|
+
|
|
297
|
+
valid_memory_values = _get_valid_memory_for_cpu(cpu_units) or []
|
|
298
|
+
if memory_mb not in valid_memory_values:
|
|
299
|
+
valid_memory_strs = [_format_memory(m) for m in valid_memory_values]
|
|
300
|
+
raise ValueError(
|
|
301
|
+
f"Invalid resource combination: CPU {self.cpu} with memory {self.memory}. "
|
|
302
|
+
f"For CPU {self.cpu}, valid memory values are: {valid_memory_strs}"
|
|
303
|
+
)
|
|
304
|
+
return self
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class ResourceConfig(BaseModel):
|
|
308
|
+
"""Configuration for agent container resources.
|
|
309
|
+
|
|
310
|
+
Resources are expressed as limits.
|
|
311
|
+
|
|
312
|
+
Example:
|
|
313
|
+
resources:
|
|
314
|
+
limits:
|
|
315
|
+
cpu: "500m"
|
|
316
|
+
memory: "1Gi"
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
limits: ResourceLimits = Field(
|
|
320
|
+
default_factory=ResourceLimits,
|
|
321
|
+
description="Resource limits (CPU and memory)",
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class DispatchConfig(BaseModel):
|
|
326
|
+
"""Configuration model for dispatch.yaml files.
|
|
327
|
+
|
|
328
|
+
This model defines the complete schema for agent deployment configuration.
|
|
329
|
+
It supports validation, serialization, and provides clear documentation
|
|
330
|
+
for all configuration options.
|
|
331
|
+
|
|
332
|
+
Example dispatch.yaml:
|
|
333
|
+
namespace: skunkworks
|
|
334
|
+
agent_name: my-agent
|
|
335
|
+
entrypoint: agent.py
|
|
336
|
+
base_image: python:3.13-slim
|
|
337
|
+
env:
|
|
338
|
+
LOG_LEVEL: debug
|
|
339
|
+
MY_APP_MODE: production
|
|
340
|
+
volumes:
|
|
341
|
+
- name: data
|
|
342
|
+
mountPath: /data
|
|
343
|
+
mode: read_write_many
|
|
344
|
+
secrets:
|
|
345
|
+
- name: OPENAI_API_KEY
|
|
346
|
+
secret_id: /shared/openai-api-key
|
|
347
|
+
resources:
|
|
348
|
+
limits:
|
|
349
|
+
cpu: "500m"
|
|
350
|
+
memory: "1Gi"
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
namespace: str | None = Field(
|
|
354
|
+
default=None,
|
|
355
|
+
description="Namespace for agent deployment (required for deployment)",
|
|
356
|
+
)
|
|
357
|
+
agent_name: str | None = Field(
|
|
358
|
+
default=None,
|
|
359
|
+
description="Unique name for the agent",
|
|
360
|
+
)
|
|
361
|
+
entrypoint: str | None = Field(
|
|
362
|
+
default=None,
|
|
363
|
+
description="Python file containing agent handlers (default: agent.py)",
|
|
364
|
+
)
|
|
365
|
+
base_image: str | None = Field(
|
|
366
|
+
default=None,
|
|
367
|
+
description="Base Docker image for the agent container",
|
|
368
|
+
)
|
|
369
|
+
system_packages: list[str] | None = Field(
|
|
370
|
+
default=None,
|
|
371
|
+
description="Additional system packages to install (apt packages)",
|
|
372
|
+
)
|
|
373
|
+
local_dependencies: dict[str, str] | None = Field(
|
|
374
|
+
default=None,
|
|
375
|
+
description="Local path dependencies to bundle (name -> path mapping)",
|
|
376
|
+
)
|
|
377
|
+
env: dict[str, str] | None = Field(
|
|
378
|
+
default=None,
|
|
379
|
+
description="Plain environment variables to inject into the container (non-secret)",
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
@field_validator("env", mode="before")
|
|
383
|
+
@classmethod
|
|
384
|
+
def _check_env_values_are_strings(cls, v: dict | None) -> dict[str, str] | None:
|
|
385
|
+
"""Ensure all env values are strings.
|
|
386
|
+
|
|
387
|
+
YAML parses unquoted ``false`` as bool, ``123`` as int, etc.
|
|
388
|
+
Since env vars are always strings, require explicit quoting.
|
|
389
|
+
"""
|
|
390
|
+
if not v or not isinstance(v, dict):
|
|
391
|
+
return v
|
|
392
|
+
non_string = {
|
|
393
|
+
k: type(val).__name__ for k, val in v.items() if not isinstance(val, str)
|
|
394
|
+
}
|
|
395
|
+
if non_string:
|
|
396
|
+
examples = ", ".join(
|
|
397
|
+
f'{k} (got {t}, wrap in quotes: "{v[k]}")'
|
|
398
|
+
for k, t in sorted(non_string.items())
|
|
399
|
+
)
|
|
400
|
+
raise ValueError(f"All env values must be strings. {examples}")
|
|
401
|
+
return v
|
|
402
|
+
|
|
403
|
+
secrets: list[SecretConfig] | None = Field(
|
|
404
|
+
default=None,
|
|
405
|
+
description="Secrets to inject as environment variables",
|
|
406
|
+
)
|
|
407
|
+
volumes: list[VolumeConfig] | None = Field(
|
|
408
|
+
default=None,
|
|
409
|
+
description="Persistent storage volumes to mount",
|
|
410
|
+
)
|
|
411
|
+
mcp_servers: list[MCPServerConfig] | None = Field(
|
|
412
|
+
default=None,
|
|
413
|
+
description="MCP servers to connect to from the registry",
|
|
414
|
+
)
|
|
415
|
+
resources: ResourceConfig = Field(
|
|
416
|
+
default_factory=ResourceConfig,
|
|
417
|
+
description="Container resource limits (CPU and memory)",
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
@field_validator("env")
|
|
421
|
+
@classmethod
|
|
422
|
+
def _validate_env(cls, v: dict[str, str] | None) -> dict[str, str] | None:
|
|
423
|
+
"""Reject env vars that collide with platform-managed names."""
|
|
424
|
+
if not v:
|
|
425
|
+
return v
|
|
426
|
+
collisions = RESERVED_ENV_VARS & v.keys()
|
|
427
|
+
if collisions:
|
|
428
|
+
raise ValueError(
|
|
429
|
+
f"Cannot set reserved environment variable(s): "
|
|
430
|
+
f"{', '.join(sorted(collisions))}. "
|
|
431
|
+
"These are managed by the Dispatch platform."
|
|
432
|
+
)
|
|
433
|
+
return v
|
|
434
|
+
|
|
435
|
+
@model_validator(mode="after")
|
|
436
|
+
def _validate_env_secrets_no_overlap(self) -> "DispatchConfig":
|
|
437
|
+
"""Ensure no env var name also appears as a secret name."""
|
|
438
|
+
if not self.env or not self.secrets:
|
|
439
|
+
return self
|
|
440
|
+
env_names = set(self.env.keys())
|
|
441
|
+
secret_names = {s.name for s in self.secrets}
|
|
442
|
+
overlap = env_names & secret_names
|
|
443
|
+
if overlap:
|
|
444
|
+
raise ValueError(
|
|
445
|
+
f"Environment variable(s) {', '.join(sorted(overlap))} "
|
|
446
|
+
"defined in both 'env' and 'secrets'. "
|
|
447
|
+
"Use 'secrets' for sensitive values or 'env' for non-secret values, not both."
|
|
448
|
+
)
|
|
449
|
+
return self
|
|
450
|
+
|
|
451
|
+
def to_yaml_dict(self) -> dict[str, Any]:
|
|
452
|
+
"""Convert to dictionary suitable for YAML serialization.
|
|
453
|
+
|
|
454
|
+
Excludes None values and converts nested models to dicts.
|
|
455
|
+
"""
|
|
456
|
+
result: dict[str, Any] = {}
|
|
457
|
+
|
|
458
|
+
if self.namespace is not None:
|
|
459
|
+
result["namespace"] = self.namespace
|
|
460
|
+
if self.agent_name is not None:
|
|
461
|
+
result["agent_name"] = self.agent_name
|
|
462
|
+
if self.entrypoint is not None:
|
|
463
|
+
result["entrypoint"] = self.entrypoint
|
|
464
|
+
if self.base_image is not None:
|
|
465
|
+
result["base_image"] = self.base_image
|
|
466
|
+
if self.system_packages:
|
|
467
|
+
result["system_packages"] = self.system_packages
|
|
468
|
+
if self.local_dependencies:
|
|
469
|
+
result["local_dependencies"] = self.local_dependencies
|
|
470
|
+
if self.env:
|
|
471
|
+
result["env"] = dict(self.env)
|
|
472
|
+
if self.secrets:
|
|
473
|
+
result["secrets"] = [
|
|
474
|
+
{"name": s.name, "secret_id": s.secret_id} for s in self.secrets
|
|
475
|
+
]
|
|
476
|
+
if self.volumes:
|
|
477
|
+
result["volumes"] = [
|
|
478
|
+
{"name": v.name, "mountPath": v.mount_path, "mode": v.mode.value}
|
|
479
|
+
for v in self.volumes
|
|
480
|
+
]
|
|
481
|
+
# Always include resources since it has defaults
|
|
482
|
+
limits_dict: dict[str, str] = {
|
|
483
|
+
"cpu": self.resources.limits.cpu,
|
|
484
|
+
"memory": self.resources.limits.memory,
|
|
485
|
+
}
|
|
486
|
+
result["resources"] = {"limits": limits_dict}
|
|
487
|
+
|
|
488
|
+
return result
|
|
489
|
+
|
|
490
|
+
model_config = {"populate_by_name": True}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Contrib packages for integrating with third-party Agent SDKs."""
|