From 01dc60e394ecb56bd5336e447d32caeed8a67ec2 Mon Sep 17 00:00:00 2001 From: Sydney Runkle <54324534+sydney-runkle@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:08:15 -0400 Subject: [PATCH] feat(cli): `deepagents deploy` (#2491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # deepagents deploy ## Project layout ``` src/ AGENTS.md # required — system prompt + read-only /memories/AGENTS.md skills/ # optional — seeded under /skills/ mcp.json # optional — HTTP/SSE MCP servers deepagents.toml ``` ## `deepagents.toml` ```toml [agent] name = "my-agent" model = "anthropic:claude-sonnet-4-6" # [sandbox] is optional — omit to run tools in-process. [sandbox] provider = "langsmith" # none | langsmith | daytona | modal | runloop scope = "thread" # thread | assistant # template = "deepagents-deploy" # image = "python:3" ``` That's the entire surface. Skills, MCP servers, and model deps are auto-detected. ## CLI ```bash deepagents init # scaffold deepagents.toml in cwd deepagents dev --config src/deepagents.toml [--port 2024] deepagents deploy --config src/deepagents.toml [--dry-run] ``` ## Runtime - **System prompt:** `src/AGENTS.md` verbatim, baked in at build time. - **Memories:** `/memories/AGENTS.md` in the LangGraph store, namespace `(assistant_id, "memories")`. Read-only at runtime — edit the source file and redeploy. - **Skills:** `/skills//...` in the store, namespace `(assistant_id, "skills")`. Also read-only. - **Sandbox:** default backend. Per-thread cache by default; set `[sandbox].scope = "assistant"` to share one sandbox across all threads of an assistant. Omit `[sandbox]` entirely to fall back to an in-process `StateBackend`. - **MCP:** HTTP/SSE only. Stdio is rejected at bundle time. ## Gotchas - `/memories/` and `/skills/` are read-only. Edit source files and redeploy. - `deepagents deploy` creates a new revision on every invocation (full cloud rebuild). Use `deepagents dev` for iteration. - The in-process sandbox cache does not survive process restarts; thread-scoped sandboxes get re-provisioned if the server recycles. - Custom Python tools are not supported — use MCP servers. --------- Co-authored-by: Mason Daugherty Co-authored-by: Mason Daugherty --- deepagents-deploy.md | 348 ++++++++++++ examples/deploy-coding-agent/.env.example | 5 + examples/deploy-coding-agent/AGENTS.md | 53 ++ .../deepagents.assistant-scope.toml | 13 + examples/deploy-coding-agent/deepagents.toml | 8 + examples/deploy-coding-agent/mcp.json | 8 + .../skills/code-review/SKILL.md | 50 ++ .../skills/code-review/lint_check.py | 79 +++ .../skills/coding-prefs/SKILL.md | 35 ++ .../skills/planning/SKILL.md | 53 ++ examples/deploy-content-writer/.env.example | 5 + examples/deploy-content-writer/AGENTS.md | 32 ++ .../deploy-content-writer/deepagents.toml | 3 + .../skills/blog-post/SKILL.md | 47 ++ .../skills/social-media/SKILL.md | 37 ++ libs/cli/deepagents_cli/config.py | 4 +- libs/cli/deepagents_cli/deploy/__init__.py | 15 + libs/cli/deepagents_cli/deploy/bundler.py | 259 +++++++++ libs/cli/deepagents_cli/deploy/commands.py | 403 ++++++++++++++ libs/cli/deepagents_cli/deploy/config.py | 381 +++++++++++++ libs/cli/deepagents_cli/deploy/templates.py | 511 ++++++++++++++++++ .../integrations/sandbox_factory.py | 3 +- libs/cli/deepagents_cli/main.py | 18 + libs/cli/deepagents_cli/ui.py | 9 + libs/cli/pyproject.toml | 9 + libs/cli/uv.lock | 5 + 26 files changed, 2390 insertions(+), 3 deletions(-) create mode 100644 deepagents-deploy.md create mode 100644 examples/deploy-coding-agent/.env.example create mode 100644 examples/deploy-coding-agent/AGENTS.md create mode 100644 examples/deploy-coding-agent/deepagents.assistant-scope.toml create mode 100644 examples/deploy-coding-agent/deepagents.toml create mode 100644 examples/deploy-coding-agent/mcp.json create mode 100644 examples/deploy-coding-agent/skills/code-review/SKILL.md create mode 100644 examples/deploy-coding-agent/skills/code-review/lint_check.py create mode 100644 examples/deploy-coding-agent/skills/coding-prefs/SKILL.md create mode 100644 examples/deploy-coding-agent/skills/planning/SKILL.md create mode 100644 examples/deploy-content-writer/.env.example create mode 100644 examples/deploy-content-writer/AGENTS.md create mode 100644 examples/deploy-content-writer/deepagents.toml create mode 100644 examples/deploy-content-writer/skills/blog-post/SKILL.md create mode 100644 examples/deploy-content-writer/skills/social-media/SKILL.md create mode 100644 libs/cli/deepagents_cli/deploy/__init__.py create mode 100644 libs/cli/deepagents_cli/deploy/bundler.py create mode 100644 libs/cli/deepagents_cli/deploy/commands.py create mode 100644 libs/cli/deepagents_cli/deploy/config.py create mode 100644 libs/cli/deepagents_cli/deploy/templates.py diff --git a/deepagents-deploy.md b/deepagents-deploy.md new file mode 100644 index 000000000..4c1dc497d --- /dev/null +++ b/deepagents-deploy.md @@ -0,0 +1,348 @@ +--- +name: deepagents-deploy +description: Deploy a model-agnostic, open source agent harness to production with a single command. +--- + +# Deep Agents Deploy (Beta) + +> **Note:** `deepagents deploy` is currently in beta. APIs, configuration format, and behavior may change between releases. + +Deploy a model-agnostic, open source agent to production with a single command. + +Deep Agents Deploy is built on [Deep Agents](https://github.com/langchain-ai/deepagents) — an open source, model-agnostic agent harness. It handles orchestration, sandboxing, and endpoint setup so you can go from a local agent to a deployed service without managing infrastructure. + +## What you're deploying + +`deepagents deploy` takes your agent configuration and deploys it as a [LangSmith Deployment](https://docs.langchain.com/langsmith/deployment) — a horizontally scalable server with 30+ endpoints including MCP, A2A, Agent Protocol, human-in-the-loop, and memory APIs. + +You configure your agent with a few parameters: + +| Parameter | Description | +| --- | --- | +| **`model`** | The LLM to use. Any provider works — see [Supported Models](#supported-models). | +| **`AGENTS.md`** | The system prompt, loaded at the start of each session. | +| **`skills`** | [Agent Skills](https://agentskills.io/) for specialized knowledge and actions. Skills are synced into the sandbox so the agent can execute them at runtime. See [Skills docs](https://docs.langchain.com/oss/python/deepagents/skills). | +| **`mcp.json`** | MCP tools (HTTPS/SSE). | +| **`sandbox`** | Optional execution environment. See [Sandboxes](#sandboxes). | + +## Project layout + +``` +my-agent/ + .env # API keys and secrets + AGENTS.md # required — system prompt + skills/ # optional — agent skills + mcp.json # optional — HTTP/SSE MCP servers + deepagents.toml # agent configuration +``` + +### `deepagents.toml` + +```toml +[agent] +name = "my-agent" +model = "anthropic:claude-sonnet-4-6" + +# [sandbox] is optional — omit if not needed for skills or code execution. +[sandbox] +provider = "langsmith" # langsmith | daytona | modal | runloop +scope = "thread" # thread | assistant +``` + +Skills, MCP servers, and model dependencies are auto-detected. + +### `.env` + +Place a `.env` file alongside `deepagents.toml` with your API keys: + +```bash +cp .env.example .env +``` + +```bash +# Required — your model provider key +ANTHROPIC_API_KEY=sk-... + +# Required for deploy and LangSmith sandbox +LANGSMITH_API_KEY=lsv2_... + +# Optional — sandbox provider keys (only needed if using that provider) +DAYTONA_API_KEY=... +MODAL_TOKEN_ID=... +MODAL_TOKEN_SECRET=... +RUNLOOP_API_KEY=... +``` + +## CLI + +```bash +deepagents init my-agent # scaffold my-agent/ with full project layout +deepagents dev [--config deepagents.toml] [--port 2024] +deepagents deploy [--config deepagents.toml] [--dry-run] +``` + +### `deepagents init` + +Scaffolds a new agent project with the full layout: + +```bash +deepagents init my-agent +``` + +This creates: + +| File | Purpose | +| --- | --- | +| `deepagents.toml` | Agent config — name, model, optional sandbox | +| `AGENTS.md` | System prompt loaded at session start | +| `.env` | API key template (`ANTHROPIC_API_KEY`, `LANGSMITH_API_KEY`) | +| `mcp.json` | MCP server configuration (empty by default) | +| `skills/` | Directory for [Agent Skills](https://agentskills.io/) | + +After init, edit `AGENTS.md` with your agent's instructions and run `deepagents deploy`. + +## Supported models + +Deep Agents works with any model provider. Use the `provider:model-name` format in `deepagents.toml`. + +### Anthropic + +```toml +model = "anthropic:claude-opus-4-6" +model = "anthropic:claude-sonnet-4-6" +model = "anthropic:claude-haiku-4-5-20251001" +``` + +### OpenAI + +```toml +model = "openai:gpt-5.4" +model = "openai:gpt-5.4-mini" +model = "openai:o3" +model = "openai:o4-mini" +model = "openai:gpt-4.1" +model = "openai:gpt-4o" +``` + +### Google + +```toml +model = "google_genai:gemini-3.1-pro-preview" +model = "google_genai:gemini-3-flash-preview" +model = "google_genai:gemini-2.5-pro" +model = "google_genai:gemini-2.5-flash" +``` + +### Azure OpenAI + +```toml +model = "azure_openai:my-gpt4-deployment" +``` + +### Amazon Bedrock + +```toml +model = "google_vertexai:claude-sonnet-4-6" +``` + +### xAI + +```toml +model = "xai:grok-4" +model = "xai:grok-3-mini-fast" +``` + +### Fireworks + +```toml +model = "fireworks:fireworks/deepseek-v3p2" +model = "fireworks:fireworks/qwen3-vl-235b-a22b-thinking" +model = "fireworks:fireworks/minimax-m2p5" +model = "fireworks:fireworks/kimi-k2p5" +model = "fireworks:fireworks/glm-5" +``` + +### Baseten + +```toml +model = "baseten:Qwen/Qwen3-Coder-480B-A35B-Instruct" +model = "baseten:MiniMaxAI/MiniMax-M2.5" +model = "baseten:moonshotai/Kimi-K2.5" +model = "baseten:nvidia/Nemotron-120B-A12B" +``` + +### Groq + +```toml +model = "groq:qwen/qwen3-32b" +model = "groq:moonshotai/kimi-k2-instruct" +``` + +### NVIDIA + +```toml +model = "nvidia:nvidia/nemotron-3-super-120b-a12b" +``` + +### OpenRouter + +```toml +model = "openrouter:minimax/minimax-m2.7" +model = "openrouter:nvidia/nemotron-3-super-120b-a12b" +``` + +### Ollama (local models) + +```toml +model = "ollama:deepseek-v3.2:cloud" +model = "ollama:qwen3-coder:480b-cloud" +model = "ollama:nemotron-3-super" +model = "ollama:glm-5" +``` + +### Additional providers + +Deep Agents also supports **Cohere**, **DeepSeek**, **Hugging Face**, **IBM**, **LiteLLM**, **Mistral AI**, **Perplexity**, and **Together AI**. Any provider supported by LangChain's `init_chat_model()` works out of the box. + +| Provider | Environment Variable | +| --- | --- | +| Anthropic | `ANTHROPIC_API_KEY` | +| OpenAI | `OPENAI_API_KEY` | +| Google | `GOOGLE_API_KEY` | +| Azure OpenAI | `AZURE_OPENAI_API_KEY` | +| xAI | `XAI_API_KEY` | +| Fireworks | `FIREWORKS_API_KEY` | +| Baseten | `BASETEN_API_KEY` | +| Groq | `GROQ_API_KEY` | +| NVIDIA | `NVIDIA_API_KEY` | +| OpenRouter | `OPENROUTER_API_KEY` | +| Cohere | `COHERE_API_KEY` | +| DeepSeek | `DEEPSEEK_API_KEY` | +| Mistral AI | `MISTRAL_API_KEY` | +| Together AI | `TOGETHER_API_KEY` | +| Perplexity | `PERPLEXITYAI_API_KEY` | + +## Sandboxes + +Sandboxes provide isolated execution environments for your agent to run code and scripts. + +### LangSmith (default) + +No additional setup beyond your LangSmith API key. + +```toml +[sandbox] +provider = "langsmith" +template = "deepagents-cli" +image = "python:3.12" +``` + +```bash +# .env +LANGSMITH_API_KEY=lsv2_... +``` + +Install: `pip install 'deepagents-cli[langsmith]'` + +### Daytona + +Cloud development environments with full workspace isolation. + +```toml +[sandbox] +provider = "daytona" +``` + +```bash +# .env +DAYTONA_API_KEY=... +``` + +Install: `pip install 'deepagents-cli[daytona]'` + +### Modal + +Serverless compute — sandboxes spin up on demand. + +```toml +[sandbox] +provider = "modal" +``` + +```bash +# .env (optional — can also use default Modal auth) +MODAL_TOKEN_ID=... +MODAL_TOKEN_SECRET=... +``` + +Install: `pip install 'deepagents-cli[modal]'` + +### Runloop + +Isolated DevBox environments for agent execution. + +```toml +[sandbox] +provider = "runloop" +``` + +```bash +# .env +RUNLOOP_API_KEY=... +``` + +Install: `pip install 'deepagents-cli[runloop]'` + +### Sandbox scope + +By default, each thread gets its own sandbox (`scope = "thread"`). Set `scope = "assistant"` to share one sandbox across all threads for the same assistant. + +```toml +[sandbox] +provider = "langsmith" +scope = "assistant" # shared across threads +``` + +### No sandbox + +Omit the `[sandbox]` section entirely if not needed for skills or code execution. + +## Deployment endpoints + +The deployed server exposes: + +- **MCP** — call your agent as a tool from other agents +- **A2A** — multi-agent orchestration via [A2A protocol](https://a2a-protocol.org/latest/) +- **[Agent Protocol](https://github.com/langchain-ai/agent-protocol)** — standard API for building UIs +- **[Human-in-the-loop](https://docs.langchain.com/oss/python/deepagents/human-in-the-loop)** — approval gates for sensitive actions +- **[Memory](https://docs.langchain.com/oss/python/deepagents/memory)** — short-term and long-term memory access + +## Open ecosystem + +- **Open source harness** — MIT licensed, available for [Python](https://github.com/langchain-ai/deepagents) and [TypeScript](https://github.com/langchain-ai/deepagentsjs) +- **[AGENTS.md](https://agents.md/)** — open standard for agent instructions +- **[Agent Skills](https://agentskills.io/)** — open standard for agent knowledge and actions +- **Any model, any sandbox** — no provider lock-in +- **Open protocols** — MCP, A2A, Agent Protocol +- **Self-hostable** — LangSmith Deployments can be self-hosted so memory stays in your infrastructure + +## Comparing to Claude Managed Agents + +| | Deep Agents Deploy | Claude Managed Agents | +| --- | --- | --- | +| Model Support | OpenAI, Anthropic, Google, Bedrock, Azure, Fireworks, Baseten, OpenRouter, many more | Anthropic only | +| Harness | Open source (MIT) | Proprietary, closed source | +| Sandbox | LangSmith, Daytona, Modal, Runloop, or custom | Built in | +| MCP Support | Yes | Yes | +| Skill Support | Yes | Yes | +| AGENTS.md Support | Yes | No | +| Agent Endpoints | MCP, A2A, Agent Protocol | Proprietary | +| Self Hosting | Yes | No | + +## Gotchas + +- **Read-only at runtime:** `/memories/` and `/skills/` are synced into the sandbox but cannot be edited at runtime. Edit source files and redeploy. +- **Full rebuild on deploy:** `deepagents deploy` creates a new revision on every invocation. Use `deepagents dev` for local iteration. +- **Sandbox lifecycle:** Thread-scoped sandboxes are provisioned per thread and will be re-created if the server restarts. Use `scope = "assistant"` if you need sandbox state that persists across threads. +- **MCP: HTTP/SSE only.** Stdio transports are rejected at bundle time. +- **No custom Python tools.** Use MCP servers to expose custom tool logic. diff --git a/examples/deploy-coding-agent/.env.example b/examples/deploy-coding-agent/.env.example new file mode 100644 index 000000000..b11666eb3 --- /dev/null +++ b/examples/deploy-coding-agent/.env.example @@ -0,0 +1,5 @@ +# Model provider API key (required) +ANTHROPIC_API_KEY= + +# LangSmith API key (required for deploy and sandbox) +LANGSMITH_API_KEY= diff --git a/examples/deploy-coding-agent/AGENTS.md b/examples/deploy-coding-agent/AGENTS.md new file mode 100644 index 000000000..f3d61e1a7 --- /dev/null +++ b/examples/deploy-coding-agent/AGENTS.md @@ -0,0 +1,53 @@ +# Coding Agent + +You are an expert software engineer that solves coding tasks autonomously. You work inside a sandboxed environment with full shell access. + +## Workflow + +Follow this phased workflow for every task: + +### Phase 1: Plan +- Read the issue/task description carefully +- Explore the repository structure to understand the codebase +- Identify relevant files using `grep` and `glob` +- Write a step-by-step implementation plan using `write_todos` +- If the task is ambiguous, ask for clarification before proceeding + +### Phase 2: Implement +- Follow your plan step by step +- Write clean, idiomatic code that matches existing patterns +- Run tests after each significant change +- If tests fail, debug and fix before moving on +- Update your todo list as you complete steps + +### Phase 3: Review +- Run the full test suite: `execute("python -m pytest")` +- Run linters if configured: `execute("ruff check .")` +- Review your own changes: read each modified file end-to-end +- Verify the changes actually solve the original issue +- If anything is wrong, go back to Phase 2 + +### Phase 4: Deliver +- Commit changes with a clear, descriptive commit message +- Summarize what was done and any decisions made + +## Coding Standards + +- Match the existing code style — don't introduce new patterns +- Write tests for new functionality +- Keep changes minimal and focused — don't refactor unrelated code +- Add comments only where the logic isn't self-evident +- Handle errors at system boundaries, trust internal code + +## Common Patterns + +- **Finding files**: Use `glob("**/*.py")` or `grep("pattern")` before reading +- **Understanding code**: Read imports, class definitions, and tests first +- **Testing changes**: Always run tests after edits, don't assume correctness +- **Shell commands**: Use `execute()` for git, pytest, linters, builds + +## Subagents + +For complex tasks, delegate to subagents: +- Use `task(subagent_type="researcher")` for researching APIs, docs, or patterns +- Use `task(subagent_type="general-purpose")` for independent subtasks diff --git a/examples/deploy-coding-agent/deepagents.assistant-scope.toml b/examples/deploy-coding-agent/deepagents.assistant-scope.toml new file mode 100644 index 000000000..25490fc2a --- /dev/null +++ b/examples/deploy-coding-agent/deepagents.assistant-scope.toml @@ -0,0 +1,13 @@ +# Second deployment of the coding-agent with assistant-scoped sandbox. +# Same src/ (AGENTS.md, skills/, mcp.json) — only the sandbox scope and +# the agent name differ from deepagents.toml. + +[agent] +name = "deepagents-deploy-coding-agent-assistant-scope" +model = "anthropic:claude-sonnet-4-5" + +[sandbox] +provider = "langsmith" +template = "coding-agent" +image = "python:3.12" +scope = "assistant" diff --git a/examples/deploy-coding-agent/deepagents.toml b/examples/deploy-coding-agent/deepagents.toml new file mode 100644 index 000000000..07c5c3f00 --- /dev/null +++ b/examples/deploy-coding-agent/deepagents.toml @@ -0,0 +1,8 @@ +[agent] +name = "deepagents-deploy-coding-agent" +model = "anthropic:claude-sonnet-4-5" + +[sandbox] +provider = "langsmith" +template = "coding-agent" +image = "python:3.12" diff --git a/examples/deploy-coding-agent/mcp.json b/examples/deploy-coding-agent/mcp.json new file mode 100644 index 000000000..4b289e33b --- /dev/null +++ b/examples/deploy-coding-agent/mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "docs-langchain": { + "type": "http", + "url": "https://docs.langchain.com/mcp" + } + } +} diff --git a/examples/deploy-coding-agent/skills/code-review/SKILL.md b/examples/deploy-coding-agent/skills/code-review/SKILL.md new file mode 100644 index 000000000..f070cf583 --- /dev/null +++ b/examples/deploy-coding-agent/skills/code-review/SKILL.md @@ -0,0 +1,50 @@ +--- +name: code-review +description: Perform a structured code review of changes, checking for correctness, style, tests, and potential issues. +--- + +# Code Review Skill + +Use this skill after implementing changes to validate your work before delivering. + +## Review Checklist + +### 1. Correctness +- [ ] Changes solve the original issue/task +- [ ] No unintended side effects on existing functionality +- [ ] Edge cases are handled +- [ ] Error handling is appropriate (not excessive) + +### 2. Code Quality +- [ ] Code matches existing style and patterns +- [ ] No unnecessary complexity or abstraction +- [ ] Variable and function names are clear +- [ ] No dead code, commented-out code, or TODOs left behind + +### 3. Tests +- [ ] New functionality has test coverage +- [ ] Existing tests still pass +- [ ] Tests cover both happy path and error cases +- [ ] Tests are not brittle (don't test implementation details) + +### 4. Safety +- [ ] No hardcoded secrets or credentials +- [ ] User input is validated at boundaries +- [ ] No SQL injection, XSS, or command injection vectors +- [ ] File operations use safe paths + +## Process + +1. Read each modified file end-to-end (not just the diff) +2. Run the test suite: `execute("python -m pytest -v")` +3. Run linters if available: `execute("ruff check .")` +4. Run the bundled lint check: `execute("python /skills/code-review/lint_check.py .")` +5. Check against each item in the review checklist +6. If any issues found, fix them and re-review +7. When everything passes, the review is complete + +## Helper Scripts + +- **`/skills/code-review/lint_check.py`** — Scans Python files for missing + docstrings, long functions (>50 lines), and bare `except:` clauses. Run it + via `execute("python /skills/code-review/lint_check.py [path ...]")`. diff --git a/examples/deploy-coding-agent/skills/code-review/lint_check.py b/examples/deploy-coding-agent/skills/code-review/lint_check.py new file mode 100644 index 000000000..645011016 --- /dev/null +++ b/examples/deploy-coding-agent/skills/code-review/lint_check.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Quick lint check helper for the code-review skill. + +Scans Python files for common issues that a full linter might miss or that +are worth flagging during code review: + +- Files missing a module docstring +- Functions longer than 50 lines +- Bare ``except:`` clauses + +Usage:: + + python /skills/code-review/lint_check.py [path ...] + +If no paths are given, scans the current directory recursively. +""" + +import ast +import sys +from pathlib import Path + + +def check_file(path: Path) -> list[str]: + """Return a list of warnings for a single Python file.""" + warnings: list[str] = [] + try: + source = path.read_text(encoding="utf-8") + except Exception as exc: + return [f"{path}: could not read ({exc})"] + + try: + tree = ast.parse(source, filename=str(path)) + except SyntaxError as exc: + return [f"{path}:{exc.lineno}: syntax error: {exc.msg}"] + + # Check for missing module docstring + if not ast.get_docstring(tree): + warnings.append(f"{path}:1: missing module docstring") + + for node in ast.walk(tree): + # Long functions + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + length = (node.end_lineno or node.lineno) - node.lineno + 1 + if length > 50: + warnings.append( + f"{path}:{node.lineno}: function '{node.name}' is {length} lines long (>50)" + ) + + # Bare except + if isinstance(node, ast.ExceptHandler) and node.type is None: + warnings.append(f"{path}:{node.lineno}: bare 'except:' clause") + + return warnings + + +def main(paths: list[str]) -> int: + targets = [Path(p) for p in paths] if paths else [Path(".")] + all_warnings: list[str] = [] + + for target in targets: + if target.is_file() and target.suffix == ".py": + all_warnings.extend(check_file(target)) + elif target.is_dir(): + for py_file in sorted(target.rglob("*.py")): + all_warnings.extend(check_file(py_file)) + + for w in all_warnings: + print(w) + + if all_warnings: + print(f"\n{len(all_warnings)} warning(s) found.") + return 1 + + print("No warnings found.") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/examples/deploy-coding-agent/skills/coding-prefs/SKILL.md b/examples/deploy-coding-agent/skills/coding-prefs/SKILL.md new file mode 100644 index 000000000..a927cec03 --- /dev/null +++ b/examples/deploy-coding-agent/skills/coding-prefs/SKILL.md @@ -0,0 +1,35 @@ +--- +name: coding-prefs +description: Read the user's coding preferences from /memory/coding-prefs.md before making non-trivial style decisions, and append new preferences when the user gives durable feedback. +--- + +# Coding Preferences Skill + +Use this skill to keep ``/memory/coding-prefs.md`` in sync with how this +specific user wants you to work. This file is **user-scoped**, so each user +has their own copy — anything you write here only affects future +conversations with the same user. + +## When to Read + +- Before picking a code style, test framework, or commit message format +- Before deciding whether to add comments, type hints, or docstrings +- Before refactoring beyond what was asked + +## When to Write + +Append a new entry whenever the user gives feedback that should apply to +future work: + +- "Don't add docstrings unless I ask" → save it +- "I prefer pytest over unittest" → save it +- "Stop summarizing what you did at the end" → save it + +Each entry should be one line: the rule, then a brief reason if the user +gave one. + +## How to Write + +Read the file first (it may not exist yet), then append. Don't overwrite — +preferences accumulate over time. If a new preference contradicts an +existing one, replace the old line and note the change. diff --git a/examples/deploy-coding-agent/skills/planning/SKILL.md b/examples/deploy-coding-agent/skills/planning/SKILL.md new file mode 100644 index 000000000..ae81adfba --- /dev/null +++ b/examples/deploy-coding-agent/skills/planning/SKILL.md @@ -0,0 +1,53 @@ +--- +name: planning +description: Break down a coding task into a structured implementation plan with clear steps, file identification, and risk assessment. +--- + +# Planning Skill + +Use this skill when starting a new coding task to create a thorough implementation plan. + +## Steps + +### 1. Understand the Task +- Read the issue/task description completely +- Identify the expected outcome and acceptance criteria +- Note any constraints or requirements mentioned + +### 2. Explore the Codebase +- Find the repository root and read the project structure +- Identify the tech stack (language, framework, test runner) +- Read README, CONTRIBUTING, or similar docs if they exist +- Find existing tests to understand testing patterns + +### 3. Identify Relevant Files +- Use `grep` to find code related to the task +- Read the most relevant files (entry points, related modules) +- Identify which files need to be modified vs. created +- Check for existing patterns you should follow + +### 4. Write the Plan +Use `write_todos` to create a structured plan: + +``` +write_todos([ + "1. ", + "2. ", + "3. Write tests for ", + "4. Run test suite and fix failures", + "5. Review all changes" +]) +``` + +### 5. Assess Risks +- Are there breaking changes? +- Are there edge cases to handle? +- Does this affect other parts of the codebase? +- Flag anything uncertain for review + +## Guidelines + +- Plans should have 3-10 concrete steps +- Each step should be specific enough to execute without further planning +- Include test writing and test running as explicit steps +- End with a review/verification step diff --git a/examples/deploy-content-writer/.env.example b/examples/deploy-content-writer/.env.example new file mode 100644 index 000000000..7a960bcf3 --- /dev/null +++ b/examples/deploy-content-writer/.env.example @@ -0,0 +1,5 @@ +# Model provider API key (required) +OPENAI_API_KEY= + +# LangSmith API key (required for deploy) +LANGSMITH_API_KEY= diff --git a/examples/deploy-content-writer/AGENTS.md b/examples/deploy-content-writer/AGENTS.md new file mode 100644 index 000000000..76a9954a1 --- /dev/null +++ b/examples/deploy-content-writer/AGENTS.md @@ -0,0 +1,32 @@ +# Content Writer Agent + +You are a content writer for a technology company. Your job is to create engaging, informative content that educates readers about AI, software development, and emerging technologies. + +## Brand Voice + +- **Professional but approachable**: Write like a knowledgeable colleague, not a textbook +- **Clear and direct**: Avoid jargon unless necessary; explain technical concepts simply +- **Confident but not arrogant**: Share expertise without being condescending +- **Engaging**: Use concrete examples, analogies, and stories to illustrate points + +## Writing Standards + +1. Use active voice +2. Lead with value — start with what matters to the reader +3. One idea per paragraph — keep paragraphs focused and scannable +4. Concrete over abstract — use specific examples, numbers, and case studies +5. End with action — every piece should leave the reader knowing what to do next + +## Content Pillars + +- AI agents and automation +- Developer tools and productivity +- Software architecture and best practices +- Emerging technologies and trends + +## Workflow + +1. **Research first** — use the `researcher` subagent for in-depth topic research before writing +2. **Outline** — structure the content with clear headers and logical flow +3. **Write** — draft the content following brand voice and writing standards +4. **Review** — check against the quality checklist before delivering diff --git a/examples/deploy-content-writer/deepagents.toml b/examples/deploy-content-writer/deepagents.toml new file mode 100644 index 000000000..10d201249 --- /dev/null +++ b/examples/deploy-content-writer/deepagents.toml @@ -0,0 +1,3 @@ +[agent] +name = "deepagents-deploy-content-writer" +model = "openai:gpt-4.1" diff --git a/examples/deploy-content-writer/skills/blog-post/SKILL.md b/examples/deploy-content-writer/skills/blog-post/SKILL.md new file mode 100644 index 000000000..484c0cbee --- /dev/null +++ b/examples/deploy-content-writer/skills/blog-post/SKILL.md @@ -0,0 +1,47 @@ +--- +name: blog-post +description: Write structured long-form blog posts with research, SEO optimization, and cover image generation. +--- + +# Blog Post Writing Skill + +## Research First (Required) + +Before writing any blog post, delegate research: +1. Use the `task` tool with `subagent_type: "researcher"` +2. Specify both the topic AND where to save findings + +## Blog Post Structure + +Every blog post should follow this structure: + +### 1. Hook (Opening) +- Start with a compelling question, statistic, or statement +- Keep it to 2-3 sentences + +### 2. Context (The Problem) +- Explain why this topic matters +- Connect to the reader's experience + +### 3. Main Content (3-5 sections) +- Each section covers one key point with an H2 header +- Include code examples where helpful +- Use bullet points for lists of 3+ items + +### 4. Practical Application +- Show how to apply the concepts +- Include step-by-step instructions or code snippets + +### 5. Conclusion & CTA +- Summarize key takeaways (3 bullets max) +- End with a clear call-to-action + +## Output + +Save the blog post to `blogs//post.md`. + +## SEO Considerations + +- Include the main keyword in the title and first paragraph +- Keep the title under 60 characters +- Write a meta description (150-160 characters) diff --git a/examples/deploy-content-writer/skills/social-media/SKILL.md b/examples/deploy-content-writer/skills/social-media/SKILL.md new file mode 100644 index 000000000..42dcb8258 --- /dev/null +++ b/examples/deploy-content-writer/skills/social-media/SKILL.md @@ -0,0 +1,37 @@ +--- +name: social-media +description: Create social media content including Twitter/X threads, LinkedIn posts, and short-form updates. +--- + +# Social Media Content Skill + +## Formats + +### Twitter/X Thread +- Hook tweet: compelling question or bold statement (< 280 chars) +- 3-7 follow-up tweets expanding on the topic +- Final tweet with CTA or key takeaway +- Use line breaks for readability + +### LinkedIn Post +- Opening hook (first 2 lines visible before "see more") +- 3-5 short paragraphs with key insights +- End with a question to drive engagement +- 1,300 character target length + +### Short-form Update +- Single paragraph announcement or insight +- Link to longer content if available +- Under 280 characters for cross-platform use + +## Guidelines + +- Write in first person for LinkedIn, third person for company accounts +- Include 2-3 relevant hashtags (not more) +- Adapt tone: LinkedIn is more professional, Twitter is more conversational +- Every post should provide standalone value — don't just tease +- Use concrete numbers and results over vague claims + +## Output + +Save content to `social//.md` (e.g., `social/twitter/ai-agents-thread.md`). diff --git a/libs/cli/deepagents_cli/config.py b/libs/cli/deepagents_cli/config.py index 545e065d3..b6525fe6d 100644 --- a/libs/cli/deepagents_cli/config.py +++ b/libs/cli/deepagents_cli/config.py @@ -32,8 +32,8 @@ _bootstrap_done = False """Whether `_ensure_bootstrap()` has executed.""" _bootstrap_lock = threading.Lock() -"""Guards `_ensure_bootstrap()` against concurrent access from the main -thread and the prewarm worker thread.""" +"""Guards `_ensure_bootstrap()` against concurrent access from the main thread +and the prewarm worker thread.""" _singleton_lock = threading.Lock() """Guards lazy singleton construction in `_get_console` / `_get_settings`.""" diff --git a/libs/cli/deepagents_cli/deploy/__init__.py b/libs/cli/deepagents_cli/deploy/__init__.py new file mode 100644 index 000000000..9cf9fc7b3 --- /dev/null +++ b/libs/cli/deepagents_cli/deploy/__init__.py @@ -0,0 +1,15 @@ +"""Deploy commands for bundling and shipping deep agents.""" + +from deepagents_cli.deploy.commands import ( + execute_deploy_command, + execute_dev_command, + execute_init_command, + setup_deploy_parsers, +) + +__all__ = [ + "execute_deploy_command", + "execute_dev_command", + "execute_init_command", + "setup_deploy_parsers", +] diff --git a/libs/cli/deepagents_cli/deploy/bundler.py b/libs/cli/deepagents_cli/deploy/bundler.py new file mode 100644 index 000000000..9a14ffe2a --- /dev/null +++ b/libs/cli/deepagents_cli/deploy/bundler.py @@ -0,0 +1,259 @@ +"""Bundle a deepagents project for deployment. + +Reads the canonical project layout: + +```txt +src/ + AGENTS.md # required — system prompt + seeded memory + skills/ # optional — auto-seeded into skills namespace + mcp.json # optional — HTTP/SSE MCP servers + deepagents.toml +``` + +...and writes everything `langgraph deploy` needs to a build directory. +""" + +from __future__ import annotations + +import json +import logging +import shutil +from pathlib import Path + +from deepagents_cli.deploy.config import ( + AGENTS_MD_FILENAME, + MCP_FILENAME, + SKILLS_DIRNAME, + DeployConfig, +) +from deepagents_cli.deploy.templates import ( + DEPLOY_GRAPH_TEMPLATE, + MCP_TOOLS_TEMPLATE, + PYPROJECT_TEMPLATE, + SANDBOX_BLOCKS, +) + +logger = logging.getLogger(__name__) + +_MODEL_PROVIDER_DEPS = { + "anthropic": "langchain-anthropic", + "openai": "langchain-openai", + "google_genai": "langchain-google-genai", + "google_vertexai": "langchain-google-vertexai", + "groq": "langchain-groq", + "mistralai": "langchain-mistralai", +} +"""Dependencies inferred from a provider: prefix on the model string.""" + + +def bundle( + config: DeployConfig, + project_root: Path, + build_dir: Path, +) -> Path: + """Create the full deployment bundle in *build_dir*.""" + build_dir.mkdir(parents=True, exist_ok=True) + + # 1. Read AGENTS.md — the system prompt AND (optionally) seeded memory. + agents_md_path = project_root / AGENTS_MD_FILENAME + system_prompt = agents_md_path.read_text(encoding="utf-8") + + # 2. Build and write the seed payload: memory (AGENTS.md) + skills/. + seed = _build_seed(config, project_root, system_prompt) + (build_dir / "_seed.json").write_text( + json.dumps(seed, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + logger.info( + "Wrote _seed.json (memories: %d, skills: %d)", + len(seed["memories"]), + len(seed["skills"]), + ) + + # 3. Copy mcp.json if present. + mcp_present = (project_root / MCP_FILENAME).is_file() + if mcp_present: + shutil.copy2(project_root / MCP_FILENAME, build_dir / "_mcp.json") + logger.info("Copied %s → _mcp.json", MCP_FILENAME) + + # 3b. Copy .env from the project root if present (i.e. alongside + # deepagents.toml inside ``src/``). The bundler skips .env when + # building the seed payload so secrets never land in _seed.json. + env_src = project_root / ".env" + env_present = env_src.is_file() + if env_present: + shutil.copy2(env_src, build_dir / ".env") + logger.info("Copied %s → .env", env_src) + + # 4. Render deploy_graph.py. + (build_dir / "deploy_graph.py").write_text( + _render_deploy_graph(config, system_prompt, mcp_present=mcp_present), + encoding="utf-8", + ) + logger.info("Generated deploy_graph.py") + + # 5. Render langgraph.json. + (build_dir / "langgraph.json").write_text( + _render_langgraph_json(env_present=env_present), + encoding="utf-8", + ) + + # 6. Render pyproject.toml. + (build_dir / "pyproject.toml").write_text( + _render_pyproject(config, mcp_present=mcp_present), + encoding="utf-8", + ) + + return build_dir + + +def _build_seed( + config: DeployConfig, # noqa: ARG001 + project_root: Path, + system_prompt: str, +) -> dict[str, dict[str, str]]: + """Build the `_seed.json` payload. + + Layout: + + ```txt + { + "memories": { "AGENTS.md": "..." }, + "skills": { "/SKILL.md": "...", ... } + } + ``` + + `memories` always contains `AGENTS.md` — the agent reads it at runtime + via `/memories/AGENTS.md`. Writes and edits to that path are blocked + by `ReadOnlyStoreBackend` in the generated graph. + + `skills` walks `src/skills/` if present. Keys are paths relative to the + skills dir; the runtime namespace handles the scoping. + """ + # Keys must match what CompositeBackend passes to the mounted + # StoreBackend after stripping the route prefix: for a read of + # /memories/AGENTS.md it calls store.read("/AGENTS.md"). + # Seed with the same leading-slash convention. + memories: dict[str, str] = {f"/{AGENTS_MD_FILENAME}": system_prompt} + skills: dict[str, str] = {} + + skills_dir = project_root / SKILLS_DIRNAME + if skills_dir.is_dir(): + for f in sorted(skills_dir.rglob("*")): + if f.is_file() and not f.name.startswith("."): + rel = f.relative_to(skills_dir).as_posix() + skills[f"/{rel}"] = f.read_text(encoding="utf-8") + + return {"memories": memories, "skills": skills} + + +def _render_deploy_graph( + config: DeployConfig, + system_prompt: str, + *, + mcp_present: bool, +) -> str: + """Render the generated `deploy_graph.py`.""" + provider = config.sandbox.provider + if provider not in SANDBOX_BLOCKS: + msg = f"Unknown sandbox provider {provider!r}. Valid: {sorted(SANDBOX_BLOCKS)}" + raise ValueError(msg) + sandbox_block, _ = SANDBOX_BLOCKS[provider] + + if mcp_present: + mcp_tools_block = MCP_TOOLS_TEMPLATE + mcp_tools_load_call = "tools.extend(await _load_mcp_tools())" + else: + mcp_tools_block = "" + mcp_tools_load_call = "pass # no MCP servers configured" + + return DEPLOY_GRAPH_TEMPLATE.format( + model=config.agent.model, + system_prompt=system_prompt, + sandbox_template=config.sandbox.template, + sandbox_image=config.sandbox.image, + sandbox_scope=config.sandbox.scope, + sandbox_block=sandbox_block, + mcp_tools_block=mcp_tools_block, + mcp_tools_load_call=mcp_tools_load_call, + default_assistant_id=config.agent.name, + ) + + +def _render_langgraph_json(*, env_present: bool) -> str: + """Render `langgraph.json` — adds `"env": ".env"` when a `.env` was copied.""" + data: dict = { + "dependencies": ["."], + "graphs": {"agent": "./deploy_graph.py:make_graph"}, + "python_version": "3.12", + } + if env_present: + data["env"] = ".env" + return json.dumps(data, indent=2) + "\n" + + +def _render_pyproject(config: DeployConfig, *, mcp_present: bool) -> str: + """Render the deployment package's `pyproject.toml`. + + Deps are inferred — the user never writes them. We add: + + - the LangChain partner package matching the model provider prefix + - `langchain-mcp-adapters` if `mcp.json` is present + - the sandbox partner package (daytona/modal/runloop) + """ + deps: list[str] = [] + + provider_prefix = ( + config.agent.model.split(":", 1)[0] if ":" in config.agent.model else "" + ) + if provider_prefix and provider_prefix in _MODEL_PROVIDER_DEPS: + deps.append(_MODEL_PROVIDER_DEPS[provider_prefix]) + + if mcp_present: + deps.append("langchain-mcp-adapters") + + _, partner_pkg = SANDBOX_BLOCKS.get(config.sandbox.provider, (None, None)) + if partner_pkg: + deps.append(partner_pkg) + + extra_deps_lines = "".join(f' "{dep}",\n' for dep in deps) + + return PYPROJECT_TEMPLATE.format( + agent_name=config.agent.name, + extra_deps=extra_deps_lines, + ) + + +def print_bundle_summary(config: DeployConfig, build_dir: Path) -> None: + """Print a human-readable summary of what was bundled.""" + seed_path = build_dir / "_seed.json" + seed: dict[str, dict[str, str]] = {"memories": {}, "skills": {}} + if seed_path.exists(): + try: + seed = json.loads(seed_path.read_text(encoding="utf-8")) + except Exception: + pass + + print(f"\n Agent: {config.agent.name}") + print(f" Model: {config.agent.model}") + + memory_files = sorted(seed.get("memories", {}).keys()) + if memory_files: + print(f"\n Memory seed ({len(memory_files)} file(s)):") + for f in memory_files: + print(f" {f}") + + skills_files = sorted(seed.get("skills", {}).keys()) + if skills_files: + print(f"\n Skills seed ({len(skills_files)} file(s)):") + for f in skills_files: + print(f" {f}") + + if (build_dir / "_mcp.json").exists(): + print("\n MCP config: _mcp.json") + + print(f"\n Sandbox: {config.sandbox.provider}") + print(f"\n Build directory: {build_dir}") + generated = sorted(f.name for f in build_dir.iterdir() if f.is_file()) + print(f" Generated files: {', '.join(generated)}") + print() diff --git a/libs/cli/deepagents_cli/deploy/commands.py b/libs/cli/deepagents_cli/deploy/commands.py new file mode 100644 index 000000000..e62b3121b --- /dev/null +++ b/libs/cli/deepagents_cli/deploy/commands.py @@ -0,0 +1,403 @@ +"""CLI commands for `deepagents deploy`. + +Registered with the CLI via `setup_deploy_parser` in `main.py`. + +Commands: +- `deepagents deploy` — Bundle and deploy to LangGraph Platform +- `deepagents deploy --dry-run` — Show what would be generated +- `deepagents init [NAME]` — Scaffold a new deploy project folder +""" + +from __future__ import annotations + +import argparse +import subprocess +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable + + +def setup_deploy_parsers( + subparsers: Any, # noqa: ANN401 + *, + make_help_action: Callable[[Callable[[], None]], type[argparse.Action]], +) -> None: + """Register the top-level `init`, `dev`, and `deploy` subparsers. + + The three commands used to live under `deepagents deploy {init,dev}` + but are now flat: `deepagents init`, `deepagents dev`, and + `deepagents deploy`. This function registers all three on the root + subparsers object. + """ + # deepagents init + init_parser = subparsers.add_parser( + "init", + help="(beta) Scaffold a new deploy project folder", + add_help=False, + ) + init_parser.add_argument( + "name", + nargs="?", + default=None, + help="Project folder name (will be created in cwd). Prompted if omitted.", + ) + init_parser.add_argument( + "-h", + "--help", + action=make_help_action(lambda: init_parser.print_help()), + ) + init_parser.add_argument( + "--force", + action="store_true", + help="Overwrite existing files", + ) + + # deepagents dev + dev_parser = subparsers.add_parser( + "dev", + help="(beta) Bundle and run a local langgraph dev server", + add_help=False, + ) + dev_parser.add_argument( + "-h", + "--help", + action=make_help_action(lambda: dev_parser.print_help()), + ) + dev_parser.add_argument( + "--config", + type=str, + default=None, + help="Path to deepagents.toml (default: auto-discovered from cwd upward)", + ) + dev_parser.add_argument( + "--port", + type=int, + default=2024, + help="Port for the langgraph dev server (default: 2024)", + ) + dev_parser.add_argument( + "--allow-blocking", + action="store_true", + default=True, + help="Pass --allow-blocking to langgraph dev (default: enabled)", + ) + + # deepagents deploy + deploy_parser = subparsers.add_parser( + "deploy", + help="(beta) Bundle and deploy agent to LangGraph Platform", + add_help=False, + ) + deploy_parser.add_argument( + "-h", + "--help", + action=make_help_action(lambda: deploy_parser.print_help()), + ) + deploy_parser.add_argument( + "--config", + type=str, + default=None, + help="Path to deepagents.toml (default: auto-discovered from cwd upward)", + ) + deploy_parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be generated without deploying", + ) + + +_BETA_WARNING = ( + "\033[33mWarning: `deepagents deploy` is in beta. " + "APIs, configuration format, and behavior may change between releases.\033[0m\n" +) + + +def execute_init_command(args: argparse.Namespace) -> None: + """Execute the `deepagents init` command.""" + print(_BETA_WARNING) + name = args.name + if name is None: + try: + name = input("Project name: ").strip() + except (EOFError, KeyboardInterrupt): + print() + raise SystemExit(1) from None + if not name: + print("Error: project name is required.") + raise SystemExit(1) + _init_project(name=name, force=args.force) + + +def execute_dev_command(args: argparse.Namespace) -> None: + """Execute the `deepagents dev` command.""" + print(_BETA_WARNING) + _dev( + config_path=args.config, + port=args.port, + allow_blocking=args.allow_blocking, + ) + + +def execute_deploy_command(args: argparse.Namespace) -> None: + """Execute the `deepagents deploy` command.""" + print(_BETA_WARNING) + _deploy( + config_path=args.config, + dry_run=args.dry_run, + ) + + +def _init_project(*, name: str, force: bool = False) -> None: + """Scaffold a deploy project folder. + + Creates `name/` with the canonical layout: + + ```txt + / + deepagents.toml + AGENTS.md + .env + mcp.json + skills/ + ``` + + Args: + name: Project folder name (created under cwd). + force: Overwrite existing files if `True`. + """ + from deepagents_cli.deploy.config import ( + AGENTS_MD_FILENAME, + DEFAULT_CONFIG_FILENAME, + MCP_FILENAME, + SKILLS_DIRNAME, + STARTER_SKILL_NAME, + generate_starter_agents_md, + generate_starter_config, + generate_starter_env, + generate_starter_mcp_json, + generate_starter_skill_md, + ) + + project_dir = Path.cwd() / name + + if project_dir.exists() and not force: + print(f"Error: {name}/ already exists. Use --force to overwrite.") + raise SystemExit(1) + + project_dir.mkdir(parents=True, exist_ok=True) + + files: list[tuple[str, str]] = [ + (DEFAULT_CONFIG_FILENAME, generate_starter_config()), + (AGENTS_MD_FILENAME, generate_starter_agents_md()), + (".env", generate_starter_env()), + (MCP_FILENAME, generate_starter_mcp_json()), + ] + + for filename, content in files: + (project_dir / filename).write_text(content) + + # Create skills/ directory with a starter skill. + skills_dir = project_dir / SKILLS_DIRNAME + skills_dir.mkdir(exist_ok=True) + starter_skill_dir = skills_dir / STARTER_SKILL_NAME + starter_skill_dir.mkdir(exist_ok=True) + (starter_skill_dir / "SKILL.md").write_text(generate_starter_skill_md()) + + print(f"Created {name}/ with:") + for filename, _ in files: + print(f" {filename}") + print(f" {SKILLS_DIRNAME}/") + print(f" {STARTER_SKILL_NAME}/SKILL.md") + print("\nNext steps:") + print(f" cd {name}") + print(" # edit deepagents.toml and AGENTS.md") + print(" deepagents deploy") + + +def _deploy( + config_path: str | None = None, + dry_run: bool = False, +) -> None: + """Bundle and deploy the agent. + + Args: + config_path: Path to config file, or `None` for default. + dry_run: If `True`, generate artifacts but don't deploy. + """ + from deepagents_cli.deploy.bundler import bundle, print_bundle_summary + from deepagents_cli.deploy.config import ( + DEFAULT_CONFIG_FILENAME, + find_config, + load_config, + ) + + # Resolve config path: explicit flag > auto-discovery > cwd fallback + if config_path: + cfg_path = Path(config_path) + else: + discovered = find_config() + cfg_path = discovered or Path.cwd() / DEFAULT_CONFIG_FILENAME + + project_root = cfg_path.parent + + # Load and validate config + try: + config = load_config(cfg_path) + except FileNotFoundError: + print(f"Error: Config file not found: {cfg_path}") + print(f"Run `deepagents init` to create a starter {DEFAULT_CONFIG_FILENAME}.") + raise SystemExit(1) from None + except ValueError as e: + print(f"Error: Invalid config: {e}") + raise SystemExit(1) from None + + errors = config.validate(project_root) + if errors: + print("Config validation errors:") + for err in errors: + print(f" - {err}") + raise SystemExit(1) + + # Bundle + if dry_run: + build_dir = Path(tempfile.mkdtemp(prefix="deepagents-deploy-")) + else: + build_dir = Path(tempfile.mkdtemp(prefix="deepagents-deploy-")) + + try: + bundle(config, project_root, build_dir) + print_bundle_summary(config, build_dir) + + if dry_run: + print("Dry run — artifacts generated but not deployed.") + print(f"Inspect the build directory: {build_dir}") + return + + # Deploy via langgraph CLI + _run_langgraph_deploy(build_dir, name=config.agent.name) + + except Exception: + if dry_run: + # Keep build dir for inspection on dry run + raise + raise + + +def _dev( + *, + config_path: str | None, + port: int, + allow_blocking: bool, +) -> None: + """Bundle the project and run a local `langgraph dev` server. + + The bundle is identical to what `deepagents deploy` would ship, just + served locally instead of pushed to LangGraph Platform. Hot-reloading + is provided by `langgraph dev` itself watching the build directory; + edits to the source project (`deepagents.toml`, skills, `AGENTS.md`) + require re-running `deepagents deploy dev` to re-bundle. + + Args: + config_path: Path to `deepagents.toml`, or `None` for default. + port: Local port for the dev server. + allow_blocking: Pass `--allow-blocking` to `langgraph dev` so + sync HTTP calls inside the graph (e.g. the LangSmith sandbox + client) don't trigger blockbuster errors. + """ + import shutil + + from deepagents_cli.deploy.bundler import bundle, print_bundle_summary + from deepagents_cli.deploy.config import ( + DEFAULT_CONFIG_FILENAME, + find_config, + load_config, + ) + + if config_path: + cfg_path = Path(config_path) + else: + discovered = find_config() + cfg_path = discovered or Path.cwd() / DEFAULT_CONFIG_FILENAME + project_root = cfg_path.parent + + try: + config = load_config(cfg_path) + except FileNotFoundError: + print(f"Error: Config file not found: {cfg_path}") + raise SystemExit(1) from None + + errors = config.validate(project_root) + if errors: + print("Config validation errors:") + for err in errors: + print(f" - {err}") + raise SystemExit(1) + + build_dir = Path(tempfile.mkdtemp(prefix="deepagents-dev-")) + bundle(config, project_root, build_dir) + print_bundle_summary(config, build_dir) + + if shutil.which("langgraph") is None: + print( + "Error: `langgraph` CLI not found. Install it with:\n" + " pip install 'langgraph-cli[inmem]'" + ) + raise SystemExit(1) + + cmd = [ + "langgraph", + "dev", + "--no-browser", + "--port", + str(port), + ] + if allow_blocking: + cmd.append("--allow-blocking") + + print(f"\nStarting langgraph dev on http://localhost:{port}") + print(f"Build directory: {build_dir}") + print(f"Running: {' '.join(cmd)}\n") + + # Pass through stdout/stderr so the user sees the dev server logs live. + try: + subprocess.run(cmd, cwd=str(build_dir), check=False) + except KeyboardInterrupt: + print("\nShutting down.") + + +def _run_langgraph_deploy(build_dir: Path, *, name: str) -> None: + """Shell out to `langgraph deploy` in the build directory. + + Args: + build_dir: Directory containing generated deployment artifacts. + name: Deployment name (passed as `--name` to avoid interactive prompt). + + Raises: + SystemExit: If `langgraph` CLI is not installed or deployment fails. + """ + import shutil + + if shutil.which("langgraph") is None: + print( + "Error: `langgraph` CLI not found. Install it with:\n" + " pip install 'langgraph-cli[inmem]'" + ) + raise SystemExit(1) + + config_path = str(build_dir / "langgraph.json") + cmd = ["langgraph", "deploy", "-c", config_path, "--name", name, "--verbose"] + + print("Deploying to LangSmith Deployments...") + print(f"Running: {' '.join(cmd)}") + print() + + result = subprocess.run(cmd, cwd=str(build_dir), check=False) + + if result.returncode != 0: + print(f"\nDeployment failed (exit code {result.returncode}).") + raise SystemExit(result.returncode) + + print("\nDeployment complete!") diff --git a/libs/cli/deepagents_cli/deploy/config.py b/libs/cli/deepagents_cli/deploy/config.py new file mode 100644 index 000000000..dcb410291 --- /dev/null +++ b/libs/cli/deepagents_cli/deploy/config.py @@ -0,0 +1,381 @@ +"""Deploy configuration parsing and validation. + +Reads `deepagents.toml` and produces a validated `DeployConfig`. + +The new minimal surface has exactly two sections: + +- `[agent]`: name + model +- `[sandbox]`: sandbox provider settings + +`AGENTS.md` is always seeded into a shared memory namespace so the agent can +read it at runtime, but writes/edits to that path are blocked by a read-only +middleware in the generated graph. + +Skills (`src/skills/`) and MCP servers (`src/mcp.json`) are auto-detected +from the project layout. The agent's system prompt is read from +`src/AGENTS.md` at bundle time — there is no `system_prompt` key. +""" + +from __future__ import annotations + +import json +import os +import tomllib +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +VALID_SANDBOX_PROVIDERS = frozenset( + {"none", "daytona", "langsmith", "modal", "runloop"} +) +"""Valid sandbox providers (mirrors sandbox_factory._PROVIDER_TO_WORKING_DIR)""" + +DEFAULT_CONFIG_FILENAME = "deepagents.toml" + +# Canonical filenames inside the project root. +AGENTS_MD_FILENAME = "AGENTS.md" +SKILLS_DIRNAME = "skills" +MCP_FILENAME = "mcp.json" + + +@dataclass(frozen=True) +class AgentConfig: + """``[agent]`` section — core agent identity.""" + + name: str + model: str = "anthropic:claude-sonnet-4-6" + + +VALID_SANDBOX_SCOPES = frozenset({"thread", "assistant"}) + + +@dataclass(frozen=True) +class SandboxConfig: + """`[sandbox]` section — sandbox provider settings. + + The whole section is optional. When omitted (or `provider = "none"`) + the runtime falls back to an in-process `StateBackend` and tools + like `execute` become no-ops. + + `scope` controls how the sandbox cache keys are built: + + - `"thread"` (default): one sandbox per thread. Different threads + get different sandboxes, same thread reuses across turns. + - `"assistant"`: one sandbox per assistant. All threads of the + same assistant share a single sandbox and its filesystem. + """ + + provider: str = "none" + template: str = "deepagents-deploy" + image: str = "python:3" + scope: str = "thread" + + +@dataclass(frozen=True) +class DeployConfig: + """Top-level deploy configuration parsed from `deepagents.toml`.""" + + agent: AgentConfig + sandbox: SandboxConfig = field(default_factory=SandboxConfig) + + def validate(self, project_root: Path) -> list[str]: + """Validate config against the filesystem. + + Args: + project_root: Directory containing `deepagents.toml` (i.e. + the `src/` dir in the canonical layout). + + Returns: + List of validation error strings. Empty if valid. + """ + errors: list[str] = [] + + # AGENTS.md is required — it's the system prompt. + agents_md = project_root / AGENTS_MD_FILENAME + if not agents_md.is_file(): + errors.append( + f"{AGENTS_MD_FILENAME} not found in {project_root}. " + f"This file is required — it provides the agent's system prompt." + ) + + # skills/ is optional; if present it must be a directory. + skills_dir = project_root / SKILLS_DIRNAME + if skills_dir.exists() and not skills_dir.is_dir(): + errors.append(f"{SKILLS_DIRNAME} must be a directory if present") + + # mcp.json is optional; if present it must be a file with only + # http/sse transports (stdio is unsupported in deployed contexts). + mcp_path = project_root / MCP_FILENAME + if mcp_path.exists(): + if not mcp_path.is_file(): + errors.append(f"{MCP_FILENAME} must be a file if present") + else: + errors.extend(_validate_mcp_for_deploy(mcp_path)) + + if self.sandbox.provider not in VALID_SANDBOX_PROVIDERS: + errors.append( + f"Unknown sandbox provider: {self.sandbox.provider}. " + f"Valid: {', '.join(sorted(VALID_SANDBOX_PROVIDERS))}" + ) + + if self.sandbox.scope not in VALID_SANDBOX_SCOPES: + errors.append( + f"Unknown sandbox scope: {self.sandbox.scope}. " + f"Valid: {', '.join(sorted(VALID_SANDBOX_SCOPES))}" + ) + + # Validate credentials for model provider. + errors.extend(_validate_model_credentials(self.agent.model)) + + # Validate credentials for sandbox provider. + errors.extend(_validate_sandbox_credentials(self.sandbox.provider)) + + return errors + + +def _validate_mcp_for_deploy(mcp_path: Path) -> list[str]: + """Validate that MCP config only uses http/sse transports (no stdio).""" + errors: list[str] = [] + try: + data = json.loads(mcp_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as e: + return [f"Could not read MCP config: {e}"] + + servers = data.get("mcpServers", {}) + if not isinstance(servers, dict): + return ["MCP config 'mcpServers' must be a dictionary"] + + for name, server_config in servers.items(): + transport = server_config.get("type", server_config.get("transport", "stdio")) + if transport == "stdio": + errors.append( + f"MCP server '{name}' uses stdio transport, which is not " + "supported in deployed context. Use http or sse instead." + ) + + return errors + + +def load_config(config_path: Path) -> DeployConfig: + """Load and parse a `deepagents.toml` file. + + Raises: + FileNotFoundError: If the config file does not exist. + ValueError: If the config is missing required fields or has an + unknown top-level section. + """ + if not config_path.exists(): + msg = f"Config file not found: {config_path}" + raise FileNotFoundError(msg) + + with config_path.open("rb") as f: + data = tomllib.load(f) + + return _parse_config(data) + + +_ALLOWED_SECTIONS = frozenset({"agent", "sandbox"}) + + +def _parse_config(data: dict[str, Any]) -> DeployConfig: + """Parse raw TOML dict into a `DeployConfig`.""" + # Reject unknown top-level sections up front — the old surface had + # many more, and silently ignoring them would hide migration bugs. + unknown = set(data.keys()) - _ALLOWED_SECTIONS + if unknown: + msg = ( + f"Unknown section(s) in deepagents.toml: {sorted(unknown)}. " + f"The new surface only accepts: {sorted(_ALLOWED_SECTIONS)}. " + f"Skills, MCP, and tools are auto-detected from the project layout." + ) + raise ValueError(msg) + + agent_data = data.get("agent", {}) + if "name" not in agent_data: + msg = "[agent].name is required in deepagents.toml" + raise ValueError(msg) + + agent = AgentConfig( + name=agent_data["name"], + model=agent_data.get("model", "anthropic:claude-sonnet-4-6"), + ) + + sandbox_data = data.get("sandbox", {}) + sandbox = SandboxConfig( + provider=sandbox_data.get("provider", "none"), + template=sandbox_data.get("template", "deepagents-deploy"), + image=sandbox_data.get("image", "python:3"), + scope=sandbox_data.get("scope", "thread"), + ) + + return DeployConfig(agent=agent, sandbox=sandbox) + + +_MODEL_PROVIDER_ENV: dict[str, str] = { + "anthropic": "ANTHROPIC_API_KEY", + "openai": "OPENAI_API_KEY", + "google_genai": "GOOGLE_API_KEY", + "google_vertexai": "GOOGLE_CLOUD_PROJECT", + "azure_openai": "AZURE_OPENAI_API_KEY", + "groq": "GROQ_API_KEY", + "mistralai": "MISTRAL_API_KEY", + "fireworks": "FIREWORKS_API_KEY", + "baseten": "BASETEN_API_KEY", + "together": "TOGETHER_API_KEY", + "xai": "XAI_API_KEY", + "nvidia": "NVIDIA_API_KEY", + "cohere": "COHERE_API_KEY", + "deepseek": "DEEPSEEK_API_KEY", + "openrouter": "OPENROUTER_API_KEY", + "perplexity": "PPLX_API_KEY", +} + +_SANDBOX_PROVIDER_ENV: dict[str, list[str]] = { + "langsmith": [ + "LANGSMITH_API_KEY", + "LANGCHAIN_API_KEY", + "LANGSMITH_SANDBOX_API_KEY", + ], + "daytona": ["DAYTONA_API_KEY"], + "runloop": ["RUNLOOP_API_KEY"], + # Modal falls back to default auth if env vars are not set. +} + + +def _validate_model_credentials(model: str) -> list[str]: + """Check that the API key env var is set for the model provider.""" + if ":" not in model: + return [] + provider = model.split(":", 1)[0] + env_var = _MODEL_PROVIDER_ENV.get(provider) + if env_var is None: + return [] + if os.environ.get(env_var): + return [] + return [ + ( + f"Missing API key for model provider '{provider}': " + f"set {env_var} in your .env file or environment." + ), + ] + + +def _validate_sandbox_credentials(provider: str) -> list[str]: + """Check that the API key env var is set for the sandbox provider.""" + required_vars = _SANDBOX_PROVIDER_ENV.get(provider) + if required_vars is None: + return [] + if any(os.environ.get(v) for v in required_vars): + return [] + return [ + ( + f"Missing API key for sandbox provider '{provider}': " + f"set one of {', '.join(required_vars)} in your .env file or environment." + ), + ] + + +def find_config(start_path: Path | None = None) -> Path | None: + """Find `deepagents.toml` in the current directory. + + Returns the path if found, or ``None`` otherwise. + """ + current = (start_path or Path.cwd()).resolve() + candidate = current / DEFAULT_CONFIG_FILENAME + if candidate.is_file(): + return candidate + return None + + +def generate_starter_config() -> str: + """Generate a starter `deepagents.toml` template.""" + return """\ +[agent] +name = "my-agent" +model = "anthropic:claude-sonnet-4-6" + +# [sandbox] is optional. Omit if not needed for skills or code execution. +# [sandbox] +# provider = "langsmith" # langsmith | daytona | modal | runloop +# scope = "thread" # thread | assistant +""" + + +def generate_starter_agents_md() -> str: + """Generate a starter `AGENTS.md` template.""" + return """\ +# Agent Instructions + +You are a helpful AI agent. + +## Guidelines + +- Follow the user's instructions carefully. +- Ask for clarification when the request is ambiguous. +""" + + +def generate_starter_env() -> str: + """Generate a starter `.env` template.""" + return """\ +# Model provider API key (required) +ANTHROPIC_API_KEY= + +# LangSmith API key (required for deploy and sandbox) +LANGSMITH_API_KEY= +""" + + +def generate_starter_mcp_json() -> str: + """Generate a starter `mcp.json` template.""" + return """\ +{ + "mcpServers": {} +} +""" + + +# Starter skill name and content. +STARTER_SKILL_NAME = "review" + + +def generate_starter_skill_md() -> str: + """Generate a starter `skills/review/SKILL.md` for code review.""" + return """\ +--- +name: review +description: >- + Review code for bugs, security issues, and improvements. + Use when the user asks to: (1) review code or a diff, + (2) check code quality, (3) find bugs or issues, + (4) audit for security problems. + Trigger on phrases like 'review this', 'check my code', + 'any issues with this', 'code review'. +--- + +# Code Review + +Review the provided code or diff with focus on: + +1. **Correctness** — Logic errors, off-by-one bugs, unhandled edge cases +2. **Security** — Injection, auth issues, secrets in code, unsafe deserialization +3. **Performance** — Unnecessary allocations, N+1 queries, missing indexes +4. **Readability** — Unclear naming, overly complex logic, missing context + +## Process + +1. Read the code or diff carefully +2. Identify concrete issues (not style nitpicks) +3. For each issue: state what's wrong, why it matters, and suggest a fix +4. If the code looks good, say so — don't invent problems + +## Output format + +For each issue found: + +- **File:line** — Brief description of the problem + - Why it matters + - Suggested fix + +Keep feedback actionable. Skip praise for things that are simply correct. +""" diff --git a/libs/cli/deepagents_cli/deploy/templates.py b/libs/cli/deepagents_cli/deploy/templates.py new file mode 100644 index 000000000..690356fdc --- /dev/null +++ b/libs/cli/deepagents_cli/deploy/templates.py @@ -0,0 +1,511 @@ +"""String templates for generated deployment artifacts. + +These templates are rendered by the bundler with values from +`~deepagents_cli.deploy.config.DeployConfig`. + +The generated `deploy_graph.py` is store-only: one `StoreBackend` with +two namespaces (memory and skills) composed with the configured sandbox as +the default backend. There is no hub path, no custom Python tools, and +no env-file handling. +""" + +from __future__ import annotations + +# --------------------------------------------------------------------------- +# Per-provider sandbox creation blocks +# +# Each block defines `_get_or_create_sandbox(cache_key) -> BackendProtocol`. +# The caller builds the cache_key from either the thread_id or the +# assistant_id depending on `[sandbox].scope`. +# using the canonical SDK init for that provider. +# --------------------------------------------------------------------------- + +SANDBOX_BLOCK_LANGSMITH = '''\ +from deepagents.backends.langsmith import LangSmithSandbox + +_SANDBOXES: dict = {} + + +def _get_or_create_sandbox(cache_key): + """Get or create a LangSmith sandbox cached by ``cache_key``.""" + if cache_key in _SANDBOXES: + return _SANDBOXES[cache_key] + + from langsmith.sandbox import ResourceNotFoundError, SandboxClient + + api_key = ( + os.environ.get("LANGSMITH_SANDBOX_API_KEY") + or os.environ.get("LANGSMITH_API_KEY") + or os.environ["LANGCHAIN_API_KEY"] + ) + client = SandboxClient(api_key=api_key) + + try: + client.get_template(SANDBOX_TEMPLATE) + except ResourceNotFoundError: + client.create_template(name=SANDBOX_TEMPLATE, image=SANDBOX_IMAGE) + + sandbox = client.create_sandbox(template_name=SANDBOX_TEMPLATE) + backend = LangSmithSandbox(sandbox) + _SANDBOXES[cache_key] = backend + logger.info( + "Created LangSmith sandbox %s for key %s", + sandbox.name, + cache_key, + ) + return backend +''' + +SANDBOX_BLOCK_DAYTONA = '''\ +from langchain_daytona import DaytonaSandbox + +_SANDBOXES: dict = {} + + +def _get_or_create_sandbox(cache_key): + """Get or create a Daytona sandbox cached by ``cache_key``.""" + if cache_key in _SANDBOXES: + return _SANDBOXES[cache_key] + + from daytona import Daytona, CreateSandboxFromImageParams + + client = Daytona() + sandbox = client.create(CreateSandboxFromImageParams(image=SANDBOX_IMAGE)) + backend = DaytonaSandbox(sandbox=sandbox) + _SANDBOXES[cache_key] = backend + logger.info("Created Daytona sandbox %s for cache_key %s", sandbox.id, cache_key) + return backend +''' + +SANDBOX_BLOCK_MODAL = '''\ +from langchain_modal import ModalSandbox + +_SANDBOXES: dict = {} + + +def _get_or_create_sandbox(cache_key): + """Get or create a Modal sandbox cached by ``cache_key``.""" + if cache_key in _SANDBOXES: + return _SANDBOXES[cache_key] + + import modal + + image = modal.Image.from_registry(SANDBOX_IMAGE) + sb = modal.Sandbox.create(image=image) + backend = ModalSandbox(sandbox=sb) + _SANDBOXES[cache_key] = backend + logger.info("Created Modal sandbox for cache_key %s", cache_key) + return backend +''' + +SANDBOX_BLOCK_RUNLOOP = '''\ +from langchain_runloop import RunloopSandbox + +_SANDBOXES: dict = {} + + +def _get_or_create_sandbox(cache_key): + """Get or create a Runloop devbox cached by ``cache_key``.""" + if cache_key in _SANDBOXES: + return _SANDBOXES[cache_key] + + from runloop_api_client import Runloop + + client = Runloop() + devbox = client.devboxes.create_and_await_running() + backend = RunloopSandbox(devbox=devbox) + _SANDBOXES[cache_key] = backend + logger.info("Created Runloop devbox %s for cache_key %s", devbox.id, cache_key) + return backend +''' + +SANDBOX_BLOCK_NONE = '''\ +from deepagents.backends.state import StateBackend + +_STATE_BACKEND: StateBackend | None = None + + +def _get_or_create_sandbox(cache_key): # noqa: ARG001 + """No sandbox configured — fall back to a process-wide StateBackend.""" + global _STATE_BACKEND + if _STATE_BACKEND is None: + _STATE_BACKEND = StateBackend() + return _STATE_BACKEND +''' + +SANDBOX_BLOCKS = { + "langsmith": (SANDBOX_BLOCK_LANGSMITH, None), + "daytona": (SANDBOX_BLOCK_DAYTONA, "langchain-daytona"), + "modal": (SANDBOX_BLOCK_MODAL, "langchain-modal"), + "runloop": (SANDBOX_BLOCK_RUNLOOP, "langchain-runloop"), + "none": (SANDBOX_BLOCK_NONE, None), +} +"""Map of provider -> (sandbox_block, requires_partner_package).""" + + +# --------------------------------------------------------------------------- +# MCP tools loader (only emitted when src/mcp.json is present) +# --------------------------------------------------------------------------- + +MCP_TOOLS_TEMPLATE = '''\ +async def _load_mcp_tools(): + """Load MCP tools from bundled config (http/sse only).""" + import json + from pathlib import Path + + mcp_path = Path(__file__).parent / "_mcp.json" + if not mcp_path.exists(): + return [] + + try: + raw = json.loads(mcp_path.read_text()) + except Exception as exc: # noqa: BLE001 + logger.warning("Failed to parse _mcp.json: %s", exc) + return [] + + servers = raw.get("mcpServers", {}) + connections = {} + for name, cfg in servers.items(): + transport = cfg.get("type", cfg.get("transport", "stdio")) + if transport in ("http", "sse"): + conn = {"transport": transport, "url": cfg["url"]} + if "headers" in cfg: + conn["headers"] = cfg["headers"] + connections[name] = conn + + if not connections: + return [] + + try: + from langchain_mcp_adapters.client import MultiServerMCPClient + + client = MultiServerMCPClient(connections) + return await client.get_tools() + except Exception as exc: # noqa: BLE001 + logger.warning( + "Failed to load MCP tools from %d server(s): %s", + len(connections), + exc, + ) + return [] +''' + + +# --------------------------------------------------------------------------- +# deploy_graph.py — the generated server entry point +# +# Store layout (CompositeBackend with sandbox default + two read-only routes): +# +# Mount Namespace Writable +# ------------- -------------------------------- -------- +# /memories/ (assistant_id, "memories") no +# /skills/ (assistant_id, "skills") no +# default sandbox (per-thread) yes +# +# `make_graph` takes the `RunnableConfig` at factory time, pulls +# `assistant_id` from `config["configurable"]`, and uses it as the +# top-level namespace component so different assistants built from the +# same graph have isolated memories and skills. +# +# The bundler ships `_seed.json` containing both payloads; the factory +# seeds each namespace once per (process, assistant_id). +# --------------------------------------------------------------------------- + +DEPLOY_GRAPH_TEMPLATE = '''\ +"""Auto-generated deepagents deploy entry point. + +Created by `deepagents deploy`. Do not edit manually — changes will be +overwritten on the next deploy. +""" + +import json +import logging +import os +from pathlib import Path +from typing import TYPE_CHECKING + +from deepagents import create_deep_agent +from deepagents.backends.composite import CompositeBackend +from deepagents.backends.protocol import EditResult, SandboxBackendProtocol, WriteResult +from deepagents.backends.store import StoreBackend +from langchain.agents.middleware.types import ( + AgentMiddleware, + AgentState, + ModelRequest, + ModelResponse, + PrivateStateAttr, +) +from langchain_core.runnables import RunnableConfig +from langgraph.prebuilt import ToolRuntime + +if TYPE_CHECKING: + from langgraph.runtime import Runtime + from langgraph_sdk.runtime import ServerRuntime + +logger = logging.getLogger(__name__) + +SANDBOX_TEMPLATE = {sandbox_template!r} +SANDBOX_IMAGE = {sandbox_image!r} + +# Mount points inside the composite backend. +MEMORIES_PREFIX = "/memories/" +SKILLS_PREFIX = "/skills/" + +# What to seed into the store on first run. +SEED_PATH = Path(__file__).parent / "_seed.json" + +# AGENTS.md is loaded at build time and baked into this module as the +# system prompt. The same content is seeded into the store under the +# ``memories`` namespace so the agent can read /memories/AGENTS.md at +# runtime — writes to /memories/ and /skills/ are blocked by +# ReadOnlyStoreBackend. +SYSTEM_PROMPT = {system_prompt!r} + + +class SandboxSyncMiddleware(AgentMiddleware): + """Sync skill files from the store into the sandbox filesystem. + + Downloads all files under the configured skill sources from the composite + backend (which routes /skills/ to the store) and uploads them directly + into the sandbox so scripts can be executed. + """ + + def __init__(self, *, backend, sources): + self._backend = backend + self._sources = sources + self._synced_keys: set = set() + + def _get_backend(self, state, runtime, config): + if callable(self._backend): + tool_runtime = ToolRuntime( + state=state, + context=runtime.context, + stream_writer=runtime.stream_writer, + store=runtime.store, + config=config, + tool_call_id=None, + ) + return self._backend(tool_runtime) + return self._backend + + async def _collect_files(self, backend, path): + """Recursively list all files under *path* via ls (not glob).""" + result = await backend.als(path) + files = [] + for entry in result.entries or []: + if entry.get("is_dir"): + files.extend(await self._collect_files(backend, entry["path"])) + else: + files.append(entry["path"]) + return files + + async def abefore_agent(self, state, runtime, config): + backend = self._get_backend(state, runtime, config) + if not isinstance(backend, CompositeBackend): + return None + sandbox = backend.default + if not isinstance(sandbox, SandboxBackendProtocol): + return None + + # Only sync once per sandbox instance + cache_key = id(sandbox) + if cache_key in self._synced_keys: + return None + self._synced_keys.add(cache_key) + + files_to_upload = [] + for source in self._sources: + paths = await self._collect_files(backend, source) + if not paths: + continue + responses = await backend.adownload_files(paths) + for resp in responses: + if resp.content is not None: + files_to_upload.append((resp.path, resp.content)) + + if files_to_upload: + results = await sandbox.aupload_files(files_to_upload) + uploaded = sum(1 for r in results if r.error is None) + logger.info( + "Synced %d/%d skill files into sandbox", + uploaded, + len(files_to_upload), + ) + + return None + + def wrap_model_call(self, request, handler): + return handler(request) + + async def awrap_model_call(self, request, handler): + return await handler(request) + + +class ReadOnlyStoreBackend(StoreBackend): + """StoreBackend that rejects all writes and edits.""" + + _READ_ONLY_MSG = ( + "This path is read-only. /memories/ and /skills/ are managed by " + "the deployment config — they cannot be edited at runtime." + ) + + def write(self, file_path, content): # noqa: ARG002 + return WriteResult(error=self._READ_ONLY_MSG) + + async def awrite(self, file_path, content): # noqa: ARG002 + return WriteResult(error=self._READ_ONLY_MSG) + + def edit( # noqa: ARG002, FBT002 + self, file_path, old_string, new_string, replace_all=False, + ): + return EditResult(error=self._READ_ONLY_MSG) + + async def aedit( # noqa: ARG002, FBT002 + self, file_path, old_string, new_string, replace_all=False, + ): + return EditResult(error=self._READ_ONLY_MSG) + + +_SEED_CACHE: dict | None = None + + +def _load_seed() -> dict: + """Load and cache the bundled seed payload.""" + global _SEED_CACHE + if _SEED_CACHE is not None: + return _SEED_CACHE + if not SEED_PATH.exists(): + _SEED_CACHE = {{"memories": {{}}, "skills": {{}}}} + return _SEED_CACHE + try: + _SEED_CACHE = json.loads(SEED_PATH.read_text(encoding="utf-8")) + except Exception as exc: # noqa: BLE001 + logger.warning("Failed to parse _seed.json: %s", exc) + _SEED_CACHE = {{"memories": {{}}, "skills": {{}}}} + return _SEED_CACHE + + +# Per-(process, assistant_id) gate. +_SEEDED_ASSISTANTS: set[str] = set() + + +async def _seed_store_if_needed(store, assistant_id: str) -> None: + """Seed memories + skills under ``assistant_id`` once per process.""" + if assistant_id in _SEEDED_ASSISTANTS: + return + _SEEDED_ASSISTANTS.add(assistant_id) + + seed = _load_seed() + + memories_ns = (assistant_id, "memories") + for path, content in seed.get("memories", {{}}).items(): + if await store.aget(memories_ns, path) is None: + await store.aput( + memories_ns, + path, + {{"content": content, "encoding": "utf-8"}}, + ) + + skills_ns = (assistant_id, "skills") + for path, content in seed.get("skills", {{}}).items(): + if await store.aget(skills_ns, path) is None: + await store.aput( + skills_ns, + path, + {{"content": content, "encoding": "utf-8"}}, + ) + + +{sandbox_block} + +{mcp_tools_block} + + +def _make_namespace_factory(assistant_id: str, section: str): + """Return a namespace factory closed over an assistant id + section.""" + def _factory(ctx): # noqa: ARG001 + return (assistant_id, section) + return _factory + + +SANDBOX_SCOPE = {sandbox_scope!r} + + +def _build_backend_factory(assistant_id: str): + """Return a backend factory that builds the composite per invocation.""" + def _factory(ctx): # noqa: ARG001 + from langgraph.config import get_config + + if SANDBOX_SCOPE == "assistant": + cache_key = f"assistant:{{assistant_id}}" + else: + thread_id = get_config().get("configurable", {{}}).get("thread_id", "local") + cache_key = f"thread:{{thread_id}}" + sandbox_backend = _get_or_create_sandbox(cache_key) + return CompositeBackend( + default=sandbox_backend, + routes={{ + MEMORIES_PREFIX: ReadOnlyStoreBackend( + namespace=_make_namespace_factory(assistant_id, "memories"), + ), + SKILLS_PREFIX: ReadOnlyStoreBackend( + namespace=_make_namespace_factory(assistant_id, "skills"), + ), + }}, + ) + return _factory + + +async def make_graph(config: RunnableConfig, runtime: "ServerRuntime"): + """Async graph factory. + + Accepts the invocation's ``RunnableConfig`` so we can pull the + ``assistant_id`` out of ``configurable`` and scope all store reads + and writes under it. Seeds the memories + skills namespaces once per + (process, assistant_id), then assembles the deep agent graph. + """ + configurable = (config or {{}}).get("configurable", {{}}) or {{}} + assistant_id = str(configurable.get("assistant_id") or {default_assistant_id!r}) + + store = getattr(runtime, "store", None) + if store is not None: + await _seed_store_if_needed(store, assistant_id) + + tools: list = [] + {mcp_tools_load_call} + + backend_factory = _build_backend_factory(assistant_id) + + return create_deep_agent( + model={model!r}, + system_prompt=SYSTEM_PROMPT, + memory=[f"{{MEMORIES_PREFIX}}AGENTS.md"], + skills=[SKILLS_PREFIX], + tools=tools, + backend=backend_factory, + middleware=[ + SandboxSyncMiddleware(backend=backend_factory, sources=[SKILLS_PREFIX]), + ], + ) + + +graph = make_graph +''' + + +# --------------------------------------------------------------------------- +# pyproject.toml +# --------------------------------------------------------------------------- + +PYPROJECT_TEMPLATE = """\ +[project] +name = {agent_name!r} +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "deepagents==0.5.1", +{extra_deps}] + +[tool.setuptools] +py-modules = [] +""" diff --git a/libs/cli/deepagents_cli/integrations/sandbox_factory.py b/libs/cli/deepagents_cli/integrations/sandbox_factory.py index d6ca6c3c8..7ad71991c 100644 --- a/libs/cli/deepagents_cli/integrations/sandbox_factory.py +++ b/libs/cli/deepagents_cli/integrations/sandbox_factory.py @@ -236,11 +236,12 @@ class _LangSmithProvider(SandboxProvider): api_key or resolve_env_var("LANGSMITH_SANDBOX_API_KEY") or resolve_env_var("LANGSMITH_API_KEY") + or resolve_env_var("LANGCHAIN_API_KEY") ) if not self._api_key: msg = ( "No LangSmith sandbox API key found. Set " - "LANGSMITH_SANDBOX_API_KEY or LANGSMITH_API_KEY " + "LANGSMITH_API_KEY, LANGCHAIN_API_KEY, or LANGSMITH_SANDBOX_API_KEY " "(or the DEEPAGENTS_CLI_-prefixed equivalents)." ) raise ValueError(msg) diff --git a/libs/cli/deepagents_cli/main.py b/libs/cli/deepagents_cli/main.py index 9ed6aaf9f..dc4fa7b30 100644 --- a/libs/cli/deepagents_cli/main.py +++ b/libs/cli/deepagents_cli/main.py @@ -262,6 +262,7 @@ def parse_args() -> argparse.Namespace: Returns: Parsed arguments namespace. """ + from deepagents_cli.deploy import setup_deploy_parsers from deepagents_cli.output import add_json_output_arg from deepagents_cli.skills import setup_skills_parser @@ -386,6 +387,11 @@ def parse_args() -> argparse.Namespace: add_output_args=add_json_output_arg, ) + setup_deploy_parsers( + subparsers, + make_help_action=_make_help_action, + ) + threads_parser = subparsers.add_parser( "threads", help="Manage conversation threads", @@ -1495,6 +1501,18 @@ def cli_main() -> None: from deepagents_cli.skills import execute_skills_command execute_skills_command(args) + elif args.command == "init": + from deepagents_cli.deploy import execute_init_command + + execute_init_command(args) + elif args.command == "dev": + from deepagents_cli.deploy import execute_dev_command + + execute_dev_command(args) + elif args.command == "deploy": + from deepagents_cli.deploy import execute_deploy_command + + execute_deploy_command(args) elif args.command == "threads": from deepagents_cli.sessions import ( delete_thread_command, diff --git a/libs/cli/deepagents_cli/ui.py b/libs/cli/deepagents_cli/ui.py index c5c8d7b27..cffb39f7c 100644 --- a/libs/cli/deepagents_cli/ui.py +++ b/libs/cli/deepagents_cli/ui.py @@ -63,6 +63,15 @@ def show_help() -> None: " deepagents update Check for and install updates" ) console.print() + console.print("[bold]Deploy (beta):[/bold]", style=theme.PRIMARY) + console.print( + " deepagents init [NAME] Scaffold a new deploy project" + ) + console.print( + " deepagents dev --config deepagents.toml Run a local dev server" + ) + console.print(" deepagents deploy --config deepagents.toml Bundle and deploy") + console.print() console.print("[bold]Options:[/bold]", style=theme.PRIMARY) console.print( diff --git a/libs/cli/pyproject.toml b/libs/cli/pyproject.toml index ad0116fc3..f38012232 100644 --- a/libs/cli/pyproject.toml +++ b/libs/cli/pyproject.toml @@ -257,6 +257,15 @@ ignore-var-parameters = true "PLR2004", # Magic value used in comparison — fine in scripts "T201", # `print` found — scripts use print for output ] +"deepagents_cli/deploy/**" = [ + "BLE001", # Blind exception catch — resilience in CLI commands + "DOC", # Docstring conventions — internal CLI module + "PLW0108", # Lambda may be unnecessary — closures over local vars require lambdas + "S", # Security warnings — subprocess calls are controlled CLI invocations + "SIM105", # contextlib.suppress — try/except/pass is clearer in these contexts + "T201", # `print` found — CLI commands use print for user output + "TC003", # Move import into TYPE_CHECKING — imports are used at runtime +] [tool.coverage.run] omit = ["tests/*"] diff --git a/libs/cli/uv.lock b/libs/cli/uv.lock index 957b1ea83..c3334fd04 100644 --- a/libs/cli/uv.lock +++ b/libs/cli/uv.lock @@ -1780,6 +1780,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/e8/2e1462c8fdbe0f210feb5ac7ad2d9029af8be3bf45bd9fa39765f821642f/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c", size = 274974, upload-time = "2026-01-23T15:31:02.891Z" }, { url = "https://files.pythonhosted.org/packages/7e/a8/530a401419a6b302af59f67aaf0b9ba1015855ea7e56c036b5928793c5bd/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd", size = 577175, upload-time = "2026-01-23T16:00:56.213Z" }, { url = "https://files.pythonhosted.org/packages/8e/89/7e812bb9c05e1aaef9b597ac1d0962b9021d2c6269354966451e885c4e6b/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5", size = 590401, upload-time = "2026-01-23T16:05:26.365Z" }, + { url = "https://files.pythonhosted.org/packages/70/ae/e2d5f0e59b94a2269b68a629173263fa40b63da32f5c231307c349315871/greenlet-3.3.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67ea3fc73c8cd92f42467a72b75e8f05ed51a0e9b1d15398c913416f2dafd49f", size = 601161, upload-time = "2026-01-23T16:15:53.456Z" }, { url = "https://files.pythonhosted.org/packages/5c/ae/8d472e1f5ac5efe55c563f3eabb38c98a44b832602e12910750a7c025802/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2", size = 590272, upload-time = "2026-01-23T15:32:49.411Z" }, { url = "https://files.pythonhosted.org/packages/a8/51/0fde34bebfcadc833550717eade64e35ec8738e6b097d5d248274a01258b/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9", size = 1550729, upload-time = "2026-01-23T16:04:20.867Z" }, { url = "https://files.pythonhosted.org/packages/16/c9/2fb47bee83b25b119d5a35d580807bb8b92480a54b68fef009a02945629f/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f", size = 1615552, upload-time = "2026-01-23T15:33:45.743Z" }, @@ -1788,6 +1789,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" }, { url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" }, { url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363, upload-time = "2026-01-23T16:15:54.754Z" }, { url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" }, { url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" }, { url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" }, @@ -1796,6 +1798,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, + { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, @@ -1804,6 +1807,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" }, { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, @@ -1812,6 +1816,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, + { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" }, { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" },