Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ uv tool install git+ssh://git@github.com/datadog-labs/dispatch_agents_cli.git@vX
```bash
# Initialize and run a single agent
dispatch agent init
dispatch agent run # auto-builds if needed
dispatch agent send-event --topic "test" --payload "Hello"
dispatch agent dev # run locally with hot reload

# Multi-agent setup
cd my-first-agent/ && dispatch agent init && dispatch agent register --topics "data-analysis"
Expand All @@ -41,7 +40,6 @@ Configuration is stored in `dispatch.yaml` (generated by `dispatch agent init`).
namespace: my-namespace # Deployment namespace (contact your org admin)
agent_name: my-agent # Agent identifier
entrypoint: agent.py # Python file with @on/@fn decorated handlers
base_image: python:3.13-slim # Docker base image

# Optional fields
system_packages: # Additional system packages to install (apt)
Expand Down
2 changes: 1 addition & 1 deletion RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Added support for org-wide network egress allow list configuration
Custom base_image configurations are now explicitly rejected with an error instead of being silently ignored, ensuring misconfigurations are surfaced immediately.
5 changes: 5 additions & 0 deletions dispatch-local-ui/pack/dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
const webpack = require('webpack');
const WebpackDevServer = require('webpack-dev-server');
const path = require('path');
const getRouterPort = require('./get-router-port');

// Import webpack configuration
const config = require('./webpack.config.js');
Expand All @@ -24,11 +25,15 @@ const runServer = async () => {
console.log('🌐 Starting development server...');
await server.start();

const routerPort = getRouterPort();
console.log('✅ Development server running at:');
console.log(` http://localhost:${devServerOptions.port}`);
console.log('');
console.log('📝 This is a development preview of the Local UI.');
console.log(' Changes will be automatically reflected.');
console.log(` Proxying API requests to router on port ${routerPort}`);
console.log(' Override with: LOCAL_ROUTER_PORT=<port> npm run dev');
console.log('');
console.log(' To build for CLI integration, run: npm run build');
};

Expand Down
28 changes: 28 additions & 0 deletions dispatch-local-ui/pack/get-router-port.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const fs = require('fs');
const os = require('os');
const path = require('path');

function getRouterPort() {
// 1. Explicit override via env var
if (process.env.LOCAL_ROUTER_PORT) {
const p = parseInt(process.env.LOCAL_ROUTER_PORT, 10);
if (Number.isInteger(p) && p > 0) return p;
console.warn(`Invalid LOCAL_ROUTER_PORT=${process.env.LOCAL_ROUTER_PORT}, falling back to tracking dir`);
}
// 2. Auto-detect from the most recently started router's tracking file
const trackingDir = path.join(os.homedir(), '.dispatch', 'routers');
try {
const files = fs.readdirSync(trackingDir)
.filter(f => f.endsWith('.json'))
.map(f => ({ f, mtime: fs.statSync(path.join(trackingDir, f)).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime);
if (files.length > 0) {
const data = JSON.parse(fs.readFileSync(path.join(trackingDir, files[0].f), 'utf8'));
if (Number.isInteger(data.port) && data.port > 0) return data.port;
}
} catch (_) {}
// 3. Fall back to the CLI router's default port
return 8080;
}

module.exports = getRouterPort;
9 changes: 9 additions & 0 deletions dispatch-local-ui/pack/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const getRouterPort = require('./get-router-port');

const isProduction = process.env.NODE_ENV === 'production';
const routerPort = getRouterPort();
const outputPath = path.resolve(__dirname, '../../dispatch_cli/router/static');

module.exports = {
Expand Down Expand Up @@ -116,6 +118,13 @@ module.exports = {
hot: true,
open: true,
historyApiFallback: true,
proxy: [
{
context: ['/api', '/health', '/system', '/ui'],
target: `http://localhost:${routerPort}`,
changeOrigin: true,
}
],
},

// Optimization for production builds
Expand Down
110 changes: 72 additions & 38 deletions dispatch_cli/commands/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import typer
from dispatch_agents.models import AgentContainerStatus
from rich.console import Console
from rich.markup import escape
from rich.progress import (
BarColumn,
Progress,
Expand Down Expand Up @@ -50,19 +51,20 @@
from dispatch_cli.secrets import print_secret_sources
from dispatch_cli.utils import (
DEFAULT_BASE_IMAGE,
DEFAULT_PYTHON_VERSION,
DISPATCH_API_BASE,
DISPATCH_DIR,
DISPATCH_LISTENER_FILE,
LLM_PROVIDER_KEY_NAMES,
LOCAL_ROUTER_PORT,
LOCAL_ROUTER_URL,
SUPPORTED_BASE_IMAGES,
check_dotenv_has_all_secrets,
check_env_secrets_not_in_config,
configure_dispatch_project,
derive_agent_name,
extract_local_deps_from_pyproject,
get_sdk_dependency,
get_unsupported_base_image,
has_python_reqs,
load_dispatch_config,
validate_dispatch_project,
Expand Down Expand Up @@ -531,9 +533,9 @@ def validate_python_version_compatibility(
True if compatible or no pyproject.toml, False if incompatible (when warn_only=False)
"""

default_python_version = SUPPORTED_BASE_IMAGES.get(DEFAULT_BASE_IMAGE, "3.13")

import tomlkit
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import Version

pyproject_path = os.path.join(project_path, "pyproject.toml")
if not os.path.exists(pyproject_path):
Expand All @@ -542,25 +544,38 @@ def validate_python_version_compatibility(
try:
with open(pyproject_path) as f:
doc = tomlkit.parse(f.read())
requires = doc.get("project", {}).get("requires-python", "")
if requires and isinstance(requires, str):
# Check if it excludes the default Python version
if (
f"<{default_python_version}" in requires
or f"<3.{default_python_version.split('.')[1]}" in requires
):
logger = get_logger()
msg = (
f"requires-python '{requires}' may not work with default base image (Python {default_python_version}). "
f"Consider updating requires-python or setting base_image in .dispatch.yaml"
)
if warn_only:
logger.warning(msg)
return True
logger.error(msg)
except Exception:
pass # Don't fail on parse errors
return True
return True # Don't fail on parse errors

requires = doc.get("project", {}).get("requires-python", "")
if not requires or not isinstance(requires, str):
return True

try:
spec = SpecifierSet(requires)
except InvalidSpecifier:
logger = get_logger()
logger.warning(
f"Could not parse requires-python='{requires}' in pyproject.toml; "
f"skipping Python-version compatibility check."
)
return True

runtime = Version(DEFAULT_PYTHON_VERSION)
if runtime in spec:
return True

logger = get_logger()
msg = (
f"requires-python='{requires}' in pyproject.toml excludes Python "
f"{DEFAULT_PYTHON_VERSION}, which is the only runtime the platform "
f"supports. Update requires-python to include {DEFAULT_PYTHON_VERSION}."
)
if warn_only:
logger.warning(msg)
return True
logger.error(msg)
return False


@agent_app.command("init")
Expand Down Expand Up @@ -594,21 +609,16 @@ def init(
# Run 'uv init --bare' to create a minimal pyproject.toml
logger.debug("Creating minimal pyproject.toml using 'uv init --bare'...")

# Get Python version from DEFAULT_BASE_IMAGE for requires-python

default_python_version = SUPPORTED_BASE_IMAGES.get(
DEFAULT_BASE_IMAGE, "3.13"
)

# Use -p flag to set Python version
# Use -p flag to set Python version to match the platform's
# only-supported runtime (DEFAULT_PYTHON_VERSION).
subprocess.run(
[
"uv",
"init",
"--bare",
"--no-workspace",
"-p",
default_python_version,
DEFAULT_PYTHON_VERSION,
],
check=True,
cwd=path,
Expand All @@ -624,11 +634,11 @@ def init(
if "project" not in doc:
doc["project"] = cast(dict, {})
project = cast(dict, doc["project"])
project["requires-python"] = f"~={default_python_version}.0"
project["requires-python"] = f"~={DEFAULT_PYTHON_VERSION}.0"
with open(pyproject_path, "w") as f:
f.write(tomlkit.dumps(doc))
logger.info(
f"Set requires-python = '~={default_python_version}.0' to match base image"
f"Set requires-python = '~={DEFAULT_PYTHON_VERSION}.0' to match the platform runtime"
)

sdk_dep = get_sdk_dependency()
Expand Down Expand Up @@ -1873,10 +1883,11 @@ def deploy(
typer.Option(
"--force",
help=(
"Skip CLI-side checks (unconfigured-secret block, schema "
"compatibility validation) and deploy anyway. Does NOT "
"drop egress selectors that violate the org allow list — "
"use --allow-egress-drop for that."
"Skip CLI-side checks (unconfigured-secret block, "
"unsupported base_image block, schema compatibility "
"validation) and deploy anyway. Does NOT drop egress "
"selectors that violate the org allow list — use "
"--allow-egress-drop for that."
),
),
] = False,
Expand Down Expand Up @@ -1919,6 +1930,25 @@ def deploy(
)
raise typer.Exit(1)

# Only DEFAULT_BASE_IMAGE is currently supported; block other values
# early (the backend rejects them too). Omitting the field uses the default.
unsupported_base_image = get_unsupported_base_image(config)
if unsupported_base_image:
if not force:
logger.error(
f"Deployment blocked: base_image '{unsupported_base_image}' is "
"not supported."
)
logger.info(
f"Set base_image to {DEFAULT_BASE_IMAGE} (or omit it), or use "
"--force to deploy anyway."
)
raise typer.Exit(1)
logger.warning(
f"base_image '{unsupported_base_image}' is not supported and will be "
"rejected by the backend. Deploying anyway because --force was passed."
)

# Warn if dispatch.yaml secrets include LLM provider API keys
# These work as fallback credentials but the preferred approach is the LLM gateway
config_secrets = config.get("secrets", []) or []
Expand Down Expand Up @@ -2210,10 +2240,14 @@ def deploy(
rendered = (
sel.get("match_name") or sel.get("match_pattern") or ""
)
logger.error(f"egress domain '{rendered}' violates org policy")
logger.error(error)
# escape(): server strings may contain "[...]" which the
# Rich-backed logger would otherwise parse as markup tags.
logger.error(
f"egress domain '{escape(rendered)}' violates org policy"
)
logger.error(escape(error))
raise typer.Exit(2)
logger.error(f"Deployment failed: {error}")
logger.error(f"Deployment failed: {escape(error)}")
raise typer.Exit(1)
elif job_status == "cancelled":
logger.warning("Deployment was cancelled.")
Expand Down
1 change: 0 additions & 1 deletion dispatch_cli/mcp/operator/prompts/fork-session.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ Replace the generated `dispatch.yaml` with:
```yaml
namespace: <namespace>
entrypoint: agent.py
base_image: python:3.13-slim
agent_name: fork-claude-<username>
system_packages: []
secrets:
Expand Down
1 change: 0 additions & 1 deletion dispatch_cli/mcp/operator/prompts/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,5 @@ With `dispatch.yaml`:
```yaml
namespace: my-namespace
entrypoint: agent.py
base_image: python:3.13-slim
agent_name: my-agent
```
4 changes: 3 additions & 1 deletion dispatch_cli/mcp/operator/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,9 @@ async def cleanup_all_agent_processes(base_dir: str | None = None) -> int:
- `agent_name`: Unique identifier for the agent
- `namespace`: Organization namespace (e.g., "skunkworks")
- `entrypoint`: Python file with @fn() decorated functions (default: "agent.py")
- `base_image`: Docker base image (default: "python:3.13-slim")

Note: `base_image` currently only supports python:3.13-slim (the default when
omitted). Other values are rejected at deploy time.

### Optional Features

Expand Down
34 changes: 0 additions & 34 deletions dispatch_cli/router/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -651,40 +651,6 @@ async def subscribe(body: SubscriptionBody, request: Request):
)


@api_router.post("/events/unsubscribe", response_model=SubscriptionResponse)
async def unsubscribe(body: SubscriptionBody):
"""Backend-compatible unsubscribe endpoint."""
if not body.topics or not body.agent_name:
raise HTTPException(
status_code=400, detail="Both topics and agent_name are required"
)

topics = [t for t in set(body.topics) if isinstance(t, str) and t.strip()]
remaining_counts: dict[str, int] = {}

async with _subscriptions_lock:
for topic in topics:
if (
topic in _subscriptions_by_topic
and body.agent_name in _subscriptions_by_topic[topic]
):
_subscriptions_by_topic[topic].discard(body.agent_name)
if not _subscriptions_by_topic[topic]:
_subscriptions_by_topic.pop(topic)
remaining_counts[topic] = 0
else:
remaining_counts[topic] = len(_subscriptions_by_topic[topic])
else:
remaining_counts[topic] = len(_subscriptions_by_topic.get(topic, set()))

return SubscriptionResponse(
message="Unsubscribed",
topics=topics,
agent_name=body.agent_name,
subscribers=remaining_counts,
)


async def route_message_to_agents_with_invocations(
topic: str, message: Message
) -> list[str]:
Expand Down
Loading
Loading