feat(cli)!: migrate deepagents deploy to use managed deep agents api [AB-2470] (#3609)

## ⚠ Breaking changes

`deepagents-cli` `0.1.x` → `0.2.0`. The deploy CLI is a private-preview
beta, but
this rewrites the command surface, config format, and deploy backend, so
existing
projects and scripts will not work unchanged.

| What broke | Before (`0.1.x`) | After (`0.2.0`) |
| --- | --- | --- |
| **`deepagents dev` removed** | `deepagents dev --port 2024` ran a
local `langgraph dev` server off the bundle | No replacement. Local
bundling/serving is gone; the project is now a managed agent. |
| **Deploy backend** | `deepagents deploy` bundled the project and
shipped via `langgraph deploy` to LangSmith Deployment | `deepagents
deploy` patches agent metadata + syncs source files through the Managed
Deep Agents API (`/v1/deepagents/*`) |
| **Config file** | `deepagents.toml` (auto-discovered or `--config`) |
`agent.json` (metadata, top-level `backend`, `runtime.model`,
permissions) + `AGENTS.md`, `tools.json`, `skills/`, `subagents/` |
| **`--config` / `--dry-run` flags** | accepted on `deploy`/`dev` |
removed |
| **MCP tool wiring** | prior tool-config shape (deployed an agent with
**no tools**) | `tools.json` with `tools[].mcp_server_url`; servers must
be registered and `can_invoke == true` or deploy fails fast |
| **Deploy state** | derived from the bundle | tracked user-local in
`~/.deepagents/deployments/`, keyed by project root + endpoint (not
committed) |

### Migration

1. **Re-scaffold or convert the project.** Run `deepagents init` to
generate the
   canonical layout (`agent.json`, `AGENTS.md`, `tools.json`, `skills/`,
   `subagents/`), then port settings out of your old `deepagents.toml`:
   - system prompt → `AGENTS.md`
   - model / permissions → `agent.json` (`runtime.model`, `permissions`)
- runtime/sandbox config → top-level `backend` block (`init` scaffolds
`thread_scoped_sandbox`; the legacy value `"sandbox"` is still accepted
as a
     local alias)
2. **Register MCP servers and attach tools.** Use `deepagents
mcp-servers add`
(then `connect` for OAuth servers), and reference them from `tools.json`
via
`tools[].mcp_server_url`. The old tool-config shape silently deployed an
agent
   with no tools.
3. **Replace `deepagents dev`.** There is no local dev-server equivalent
in this
   release; iterate via `deepagents deploy` against your workspace.
4. **Drop removed flags.** Remove `--config` and `--dry-run` from any
deploy
   scripts.

New commands: `deepagents agents` (`get`, `--include-files`, …) and
`deepagents mcp-servers` (`create`/`list`/`get`/`update`/`delete` +
OAuth
`connect`). `get` redacts stored header values.

## Overview

Migrates `deepagents deploy` from the legacy `langgraph deploy` path to
the new
Managed Deep Agents API (`/v1/deepagents/*`). Instead of bundling and
shipping a
deployment, the CLI now treats a project as a managed agent: agent
**metadata**
lives on the agent record and **source files** live in Hub-backed
directory
state. On redeploy, the CLI patches only metadata and syncs the managed
file tree
via Hub directory commits, rather than re-uploading everything.

The CLI surface is now `init` / `deploy` / `agents` / `mcp-servers`,
operating on
the canonical project layout:

```
agent.json              # name, description, top-level backend, runtime.model, permissions
AGENTS.md               # system prompt
tools.json              # MCP tool references (tools[].mcp_server_url) + interrupt_config
skills/<name>/SKILL.md  # frontmatter-tagged skills
subagents/<name>/       # delegated subagent definitions
```

Bumps `deepagents-cli` to `0.2.0`. The deploy flow is gated behind a
beta warning
while Managed Deep Agents is in private preview.

## What changed

**Deploy model**
- Source files are kept in Hub-backed directory state; redeploy patches
agent
metadata only and reconciles files through directory commits (create →
first
  deploy, patch + directory delta → subsequent deploys).
- Deploy state is tracked **user-local** (`~/.deepagents/deployments/`),
keyed by
  project root + endpoint — not committed to the repo.

**Runtime / backend config**
- Managed runtime config moved to a **top-level `backend`** block;
`init` scaffolds
  `thread_scoped_sandbox` by default.
- Backend/sandbox config is validated at load time; legacy `"sandbox"`
is accepted
  as a local alias for `thread_scoped_sandbox`.

**MCP server flows**
- Consistent dotenv loading across all commands (project `.env`
discovered upward,
  then `~/.deepagents/.env`).
- `mcp-servers` supports create/list/get/update/delete plus OAuth
connect; `get`
  redacts stored header values.
- Deploy fails fast when a referenced server is unregistered or the
caller
`can_invoke == false`, with actionable hints (including OAuth
`connect`).

**Tooling docs/examples** (also in this branch)
- `tools.json` (`tools[].mcp_server_url`) is the supported way to attach
MCP tools;
fixed the `deploy-content-writer` example and README quickstart, which
previously
  implied a tool config shape that deploys an agent with no tools.

## Checked with
```bash
uv run --group test python -m pytest tests/unit_tests/deploy -v
uv run --group test ruff check .
uv run --group test ty check
```
Also ran the flow end to end against a live workspace: `init` →
`mcp-servers add`
→ `tools.json` → `deploy` (create + redeploy) → `agents get
--include-files`,
confirming metadata patch, directory sync, and MCP health check.

## Related
- Linear:
https://linear.app/langchain/issue/AB-2470/mda-wrap-apis-in-deepagents-deploy
- Docs PR: `langchain-ai/docs` `vic/mda-cli-update` (reconciles the
Managed Deep
  Agents guide with this behavior)



BREAKING CHANGE: `deepagents deploy` now targets the Managed Deep Agents
API; `deepagents dev`, `deepagents.toml`, and the `--config`/`--dry-run`
flags are removed. Run `deepagents init` to re-scaffold (`agent.json` +
`tools.json`). See
[PR](https://github.com/langchain-ai/deepagents/pull/3609) for the full
migration guide.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mason Daugherty <mason@langchain.dev>
This commit is contained in:
Victor Moreira
2026-06-01 15:28:53 -07:00
committed by GitHub
parent c7d529ca0f
commit 72aef52542
128 changed files with 5739 additions and 19497 deletions
+4 -4
View File
@@ -17,7 +17,7 @@ Copy `.env.example` to `.env` and fill in both keys.
deepagents deploy
```
The agent is deployed using the config in `deepagents.toml`. The `[sandbox]` section provisions a LangSmith coding sandbox with a Python 3.12 image so the agent can run code safely.
The agent is deployed using the config in `agent.json`.
## What to try
@@ -34,15 +34,15 @@ The agent follows a Plan → Implement → Review → Deliver workflow defined i
```
deploy-coding-agent/
├── AGENTS.md # Agent instructions and workflow
├── deepagents.toml # Deploy config (model, sandbox)
├── deepagents.assistant-scope.toml # Assistant-scoped config variant
├── mcp.json # MCP server config
├── agent.json # Deploy config (name, model)
└── skills/
├── code-review/ # Code review skill with lint helper
├── coding-prefs/ # Coding style preferences
└── planning/ # Task planning skill
```
> **MCP servers:** This example previously used `mcp.json` to wire in the LangChain docs MCP server. MCP servers are now workspace-level resources. Register them once with `deepagents mcp-servers add --url <url>` and reference them in a `tools.json` file.
## Query via SDK
```python
+6
View File
@@ -0,0 +1,6 @@
{
"name": "deepagents-deploy-coding-agent",
"runtime": {
"model": {"model_id": "anthropic:claude-sonnet-4-5"}
}
}
@@ -1,13 +0,0 @@
# 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"
@@ -1,8 +0,0 @@
[agent]
name = "deepagents-deploy-coding-agent"
model = "anthropic:claude-sonnet-4-5"
[sandbox]
provider = "langsmith"
template = "coding-agent"
image = "python:3.12"
-8
View File
@@ -1,8 +0,0 @@
{
"mcpServers": {
"docs-langchain": {
"type": "http",
"url": "https://docs.langchain.com/mcp"
}
}
}
@@ -0,0 +1,6 @@
{
"name": "deepagents-deploy-content-writer",
"runtime": {
"model": {"model_id": "openai:gpt-4.1"}
}
}
@@ -1,6 +0,0 @@
[agent]
name = "deepagents-deploy-content-writer"
model = "openai:gpt-4.1"
[auth]
provider = "supabase"
+7
View File
@@ -0,0 +1,7 @@
{
"name": "deepagents-deploy-gtm-agent",
"description": "Go-to-market strategy agent that coordinates research and content creation",
"runtime": {
"model": {"model_id": "openai:gpt-5.4-nano"}
}
}
@@ -1,8 +0,0 @@
[agent]
name = "deepagents-deploy-gtm-agent"
description = "Go-to-market strategy agent that coordinates research and content creation"
model = "openai:gpt-5.4-nano"
[sandbox]
provider = "none"
-3
View File
@@ -1,3 +0,0 @@
{
"mcpServers": {}
}
@@ -0,0 +1,4 @@
{
"description": "Researches market trends, competitors, and target audiences to inform GTM strategy",
"model_id": "openai:gpt-5.4-mini"
}
@@ -1,4 +0,0 @@
[agent]
name = "market-researcher"
description = "Researches market trends, competitors, and target audiences to inform GTM strategy"
model = "openai:gpt-5.4-mini"
+7 -4
View File
@@ -15,7 +15,11 @@ A documentation research agent deployed with `deepagents deploy`. It answers dev
deepagents deploy
```
The `mcp.json` wires in the LangChain docs MCP server at `https://docs.langchain.com/mcp`. No additional setup is needed — the agent discovers and uses the docs tools automatically.
MCP servers are now workspace-level resources. Register the LangChain docs server once, then reference it in `tools.json`:
```bash
deepagents mcp-servers add --url https://docs.langchain.com/mcp --name docs-langchain
```
## What to try
@@ -50,9 +54,8 @@ Find your deployment URL in LangSmith under **Deployments**. See the [LangGraph
```
deploy-mcp-docs-agent/
├── AGENTS.md # Agent instructions and answer format
── deepagents.toml # Deploy config (model)
└── mcp.json # LangChain docs MCP server
├── AGENTS.md # Agent instructions and answer format
── agent.json # Deploy config (name, model)
```
## Resources
@@ -0,0 +1,6 @@
{
"name": "deploy-mcp-docs-agent",
"runtime": {
"model": {"model_id": "anthropic:claude-sonnet-4-5"}
}
}
@@ -1,3 +0,0 @@
[agent]
name = "deploy-mcp-docs-agent"
model = "anthropic:claude-sonnet-4-5"
-8
View File
@@ -1,8 +0,0 @@
{
"mcpServers": {
"docs-langchain": {
"type": "http",
"url": "https://docs.langchain.com/mcp"
}
}
}
+2 -1
View File
@@ -2,7 +2,8 @@
# Deep Agents CLI Changelog
From 0.1.0 onward, `deepagents-cli` only contains `deploy`, `dev`, and `init`.
From 0.2.0 onward, `deepagents-cli` exposes `init`, `deploy`, `agents`, and
`mcp-servers` against the Managed Deep Agents `/v1/deepagents/*` API.
The coding agent (interactive TUI & headless CLI) moved to [`deepagents-code`](https://github.com/langchain-ai/deepagents/blob/main/libs/code/CHANGELOG.md).
## [0.1.2](https://github.com/langchain-ai/deepagents/compare/deepagents-cli==0.1.1...deepagents-cli==0.1.2) (2026-05-21)
+53 -6
View File
@@ -19,10 +19,13 @@
uv tool install deepagents-cli
```
Or with optional sandbox providers:
You'll need a LangSmith API key with access to the Managed Deep Agents private
preview ([waitlist](https://www.langchain.com/langsmith-managed-deep-agents-waitlist)).
Export it before running any command, or put it in a repo `.env` or
`~/.deepagents/.env`:
```bash
uv tool install 'deepagents-cli[all-sandboxes]'
export LANGSMITH_API_KEY="..."
```
## Usage
@@ -31,11 +34,55 @@ uv tool install 'deepagents-cli[all-sandboxes]'
# Scaffold a new project folder
deepagents init my-agent
# Run a local langgraph dev server against the project
cd my-agent && deepagents dev
# Register any MCP servers you intend to use (one-time, per workspace)
deepagents mcp-servers add --url https://tools.langchain.com \
--header X-Api-Key=$LANGSMITH_API_KEY \
--name Fleet
# Bundle and ship to LangSmith Deployment
deepagents deploy
# Reference the server's tools in tools.json (otherwise the agent has no tools)
cat > my-agent/tools.json <<'JSON'
{
"tools": [
{
"name": "tavily_web_search",
"mcp_server_url": "https://tools.langchain.com",
"mcp_server_name": "Fleet"
}
]
}
JSON
# Upsert the project as a managed agent on /v1/deepagents/*
cd my-agent && deepagents deploy
```
`deepagents init` configures new agents with the managed
`thread_scoped_sandbox` backend by default. The CLI does not create or run
sandboxes locally; sandbox lifecycle is handled by the Managed Deep Agents
platform.
### Project layout
```text
my-agent/
agent.json # name, description, backend, runtime.model, permissions
AGENTS.md # system prompt
tools.json # tools the agent can call (optional)
skills/<name>/SKILL.md # frontmatter-tagged skills (optional)
subagents/<name>/ # delegated subagent definitions (optional)
```
### Other commands
```bash
deepagents agents list # list workspace agents
deepagents agents get <agent_id> # show one agent
deepagents agents delete <agent_id> # delete an agent
deepagents mcp-servers list # list workspace MCP servers
deepagents mcp-servers add --url URL # register a server
deepagents mcp-servers update <id> # update server URL or headers
deepagents mcp-servers delete <id> # remove a server
```
## 📖 Resources
+1 -1
View File
@@ -2,7 +2,7 @@
# Keep the `x-release-please-version` annotation — release-please uses it to
# bump `__version__` in sync with `pyproject.toml` on every release PR.
__version__ = "0.1.2" # x-release-please-version
__version__ = "0.2.0" # x-release-please-version
DOCS_URL = "https://docs.langchain.com/oss/python/deepagents/cli"
"""URL for `deepagents-cli` documentation."""
+43
View File
@@ -3,10 +3,25 @@
from __future__ import annotations
import logging
import os
import sys
from pathlib import Path
logger = logging.getLogger(__name__)
_PROJECT_DOTENV_BLOCKED_ENV_KEYS = (
"LANGSMITH_ENDPOINT",
"LANGCHAIN_ENDPOINT",
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"no_proxy",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
)
def _stderr(msg: str) -> None:
@@ -40,6 +55,30 @@ def _find_dotenv_from_start_path(start_path: Path) -> Path | None:
return None
def _snapshot_blocked_project_env() -> dict[str, str | None]:
return {key: os.environ.get(key) for key in _PROJECT_DOTENV_BLOCKED_ENV_KEYS}
def _restore_env(snapshot: dict[str, str | None]) -> None:
for key, value in snapshot.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
def _warn_if_project_blocked_env_changed(snapshot: dict[str, str | None]) -> None:
changed = [key for key, value in snapshot.items() if os.environ.get(key) != value]
if not changed:
return
keys = ", ".join(sorted(changed))
_stderr(
f"ignoring {keys} from project .env. Set endpoint overrides in your "
"shell environment or ~/.deepagents/.env; proxy/TLS settings are "
"ignored for managed API requests.",
)
try:
_GLOBAL_DOTENV_PATH = Path.home() / ".deepagents" / ".env"
except RuntimeError:
@@ -92,10 +131,12 @@ def _load_dotenv(*, start_path: Path) -> bool:
dotenv_path = _find_dotenv_from_start_path(start_path)
if dotenv_path is not None:
blocked_env = _snapshot_blocked_project_env()
try:
loaded = (
dotenv.load_dotenv(dotenv_path=dotenv_path, override=False) or loaded
)
_warn_if_project_blocked_env_changed(blocked_env)
except (OSError, ValueError) as exc:
_stderr(
f"could not read project .env at {dotenv_path}: {exc}. "
@@ -107,6 +148,8 @@ def _load_dotenv(*, start_path: Path) -> bool:
dotenv_path,
exc_info=True,
)
finally:
_restore_env(blocked_env)
try:
if _GLOBAL_DOTENV_PATH.is_file() and dotenv.load_dotenv(
+5 -15
View File
@@ -1,27 +1,17 @@
"""Deploy commands for bundling and shipping deep agents.
Imports are intentionally limited to lightweight parser/config helpers so
`from deepagents_cli.deploy import setup_deploy_parsers` (used by `--help`)
does not pull in the heavy `deepagents` backend stack. Heavyweight symbols
such as `ContextHubBackend` are deliberately not re-exported here; import
them directly from their submodule when needed:
from deepagents_cli.deploy.context_hub import ContextHubBackend
"""
"""Deploy commands for the Managed Deep Agents (`/v1/deepagents/*`) surface."""
from deepagents_cli.deploy.commands import (
execute_agents_command,
execute_deploy_command,
execute_dev_command,
execute_init_command,
execute_mcp_servers_command,
setup_deploy_parsers,
)
from deepagents_cli.deploy.config import SandboxProvider, SandboxScope
__all__ = [
"SandboxProvider",
"SandboxScope",
"execute_agents_command",
"execute_deploy_command",
"execute_dev_command",
"execute_init_command",
"execute_mcp_servers_command",
"setup_deploy_parsers",
]
@@ -0,0 +1,330 @@
"""HTTP client for the Managed Deep Agents `/v1/deepagents/*` surface.
Thin wrapper around `httpx.Client` that:
- Resolves auth from `LANGSMITH_API_KEY` (preferred) or `LANGCHAIN_API_KEY`
and sends it as `X-Api-Key`.
- Resolves the endpoint from `LANGSMITH_ENDPOINT` / `LANGCHAIN_ENDPOINT`,
defaulting to `https://api.smith.langchain.com`.
- Parses 4xx responses into `ApiError` with the platform's `ErrorResponse`
shape (`type`/`code`/`detail`/`status`).
- Retries 5xx responses once with a short backoff before raising.
Agents and MCP-servers CRUD methods are layered on top in subsequent tasks.
"""
from __future__ import annotations
import os
import sys
import time
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlsplit
import httpx
_DEFAULT_ENDPOINT = "https://api.smith.langchain.com"
_DEPLOY_PATH = "/v1/deepagents"
_HUB_PATH = "/v1/platform/hub"
_RETRY_SLEEP_SECONDS = 1.0
@dataclass
class ApiError(Exception):
"""Surface the platform's `ErrorResponse` envelope as a Python exception."""
status: int
code: str = ""
detail: str = ""
type_: str = ""
def __str__(self) -> str: # noqa: D105
bits = [f"HTTP {self.status}"]
if self.code:
bits.append(self.code)
if self.detail:
bits.append(self.detail)
return "".join(bits)
def _normalize_endpoint(endpoint: str) -> str:
endpoint = endpoint.strip().rstrip("/")
parsed = urlsplit(endpoint)
if parsed.scheme != "https" or not parsed.netloc:
msg = "Error: LANGSMITH_ENDPOINT / LANGCHAIN_ENDPOINT must be an HTTPS URL.\n"
sys.stderr.write(msg)
raise SystemExit(1)
if parsed.username or parsed.password:
msg = (
"Error: LANGSMITH_ENDPOINT / LANGCHAIN_ENDPOINT must not include "
"userinfo.\n"
)
sys.stderr.write(msg)
raise SystemExit(1)
return endpoint
class ApiClient:
"""HTTP client for `/v1/deepagents/*`."""
def __init__(
self,
*,
endpoint: str,
api_key: str,
transport: httpx.BaseTransport | None = None,
) -> None:
"""Initialise the client with an endpoint and API key."""
self.endpoint = endpoint.rstrip("/")
self.api_key = api_key
self._client = httpx.Client(
base_url=self.endpoint,
transport=transport,
trust_env=False,
timeout=httpx.Timeout(30.0, connect=10.0),
headers={"X-Api-Key": api_key, "Content-Type": "application/json"},
)
@classmethod
def from_env(
cls,
*,
transport: httpx.BaseTransport | None = None,
) -> ApiClient:
"""Build a client from `LANGSMITH_*` / `LANGCHAIN_*` env vars.
Endpoint resolution is env var > `_DEFAULT_ENDPOINT`. Project-local
deploy state is intentionally ignored because it can be repository
controlled and must not steer authenticated requests.
Exits non-zero with a friendly message if the API key is missing.
"""
api_key = (
os.environ.get("LANGSMITH_API_KEY")
or os.environ.get("LANGCHAIN_API_KEY")
or ""
).strip()
if not api_key:
sys.stderr.write(
"Error: set LANGSMITH_API_KEY in your .env or environment.\n"
)
raise SystemExit(1)
endpoint = (
os.environ.get("LANGSMITH_ENDPOINT")
or os.environ.get("LANGCHAIN_ENDPOINT")
or _DEFAULT_ENDPOINT
)
endpoint = _normalize_endpoint(endpoint)
return cls(endpoint=endpoint, api_key=api_key, transport=transport)
def close(self) -> None:
"""Close the underlying HTTP connection pool."""
self._client.close()
def _request(
self,
method: str,
path: str,
*,
json: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> Any: # noqa: ANN401
last_status = 0
last_text = ""
for attempt in range(2):
response = self._client.request(method, path, json=json, params=params)
last_status = response.status_code
last_text = response.text
if 200 <= response.status_code < 300: # noqa: PLR2004
if response.status_code == 204 or not response.content: # noqa: PLR2004
return None
return response.json()
if 400 <= response.status_code < 500: # noqa: PLR2004
raise self._build_error(response)
# 5xx: retry once
if attempt == 0:
time.sleep(_RETRY_SLEEP_SECONDS)
continue
raise ApiError(status=last_status, detail=last_text[:500])
@staticmethod
def _build_error(response: httpx.Response) -> ApiError:
try:
payload = response.json()
except ValueError:
payload = {}
return ApiError(
status=response.status_code,
code=str(payload.get("code") or ""),
detail=str(payload.get("detail") or response.text[:500]),
type_=str(payload.get("type") or ""),
)
# --- agents ----------------------------------------------------------
def create_agent(self, payload: dict[str, Any]) -> dict[str, Any]:
"""Create a new agent and return the created resource."""
return self._request("POST", f"{_DEPLOY_PATH}/agents", json=payload)
def get_agent(
self,
agent_id: str,
*,
include_files: bool = False,
) -> dict[str, Any]:
"""Fetch a single agent by ID."""
params = {"include_files": "true"} if include_files else None
return self._request("GET", f"{_DEPLOY_PATH}/agents/{agent_id}", params=params)
def iter_agents(
self,
*,
page_size: int = 50,
name: str | None = None,
) -> Iterator[dict[str, Any]]:
"""Yield AgentSummary objects across all pages."""
cursor: str | None = None
while True:
params: dict[str, Any] = {"page_size": page_size}
if cursor:
params["cursor"] = cursor
if name:
params["name"] = name
body = self._request("GET", f"{_DEPLOY_PATH}/agents", params=params)
yield from body.get("items", [])
cursor = body.get("next_cursor")
if not cursor:
return
def patch_agent(self, agent_id: str, payload: dict[str, Any]) -> dict[str, Any]:
"""Partially update an agent by ID."""
return self._request("PATCH", f"{_DEPLOY_PATH}/agents/{agent_id}", json=payload)
def delete_agent(self, agent_id: str) -> None:
"""Delete an agent by ID."""
self._request("DELETE", f"{_DEPLOY_PATH}/agents/{agent_id}")
# --- mcp-servers -----------------------------------------------------
def list_mcp_servers(self) -> list[dict[str, Any]]:
"""Return all registered MCP servers in this workspace."""
body = self._request("GET", f"{_DEPLOY_PATH}/mcp-servers")
if isinstance(body, list):
return list(body)
if isinstance(body, dict) and isinstance(body.get("servers"), list):
return list(body["servers"])
msg = "Unexpected MCP server list response."
raise ApiError(status=0, detail=msg)
def get_mcp_server(self, mcp_server_id: str) -> dict[str, Any]:
"""Fetch a single MCP server by ID."""
return self._request("GET", f"{_DEPLOY_PATH}/mcp-servers/{mcp_server_id}")
def create_mcp_server(
self,
*,
name: str,
url: str,
headers: list[dict[str, str]] | None = None,
auth_type: str = "headers",
oauth_mode: str | None = None,
) -> dict[str, Any]:
"""Register a new MCP server in this workspace."""
payload: dict[str, Any] = {
"name": name,
"url": url,
"auth_type": auth_type,
}
if headers:
payload["headers"] = headers
if oauth_mode is not None:
payload["oauth_mode"] = oauth_mode
return self._request("POST", f"{_DEPLOY_PATH}/mcp-servers", json=payload)
def update_mcp_server(
self,
mcp_server_id: str,
*,
url: str | None = None,
headers: list[dict[str, str]] | None = None,
auth_type: str | None = None,
) -> dict[str, Any]:
"""Update an existing MCP server in this workspace."""
payload: dict[str, Any] = {}
if url is not None:
payload["url"] = url
if headers is not None:
payload["headers"] = headers
if auth_type is not None:
payload["auth_type"] = auth_type
return self._request(
"PATCH", f"{_DEPLOY_PATH}/mcp-servers/{mcp_server_id}", json=payload
)
def delete_mcp_server(self, mcp_server_id: str) -> None:
"""Delete an MCP server by ID."""
self._request("DELETE", f"{_DEPLOY_PATH}/mcp-servers/{mcp_server_id}")
def register_mcp_oauth_provider(self, mcp_server_id: str) -> dict[str, Any]:
"""Register the caller's per-user OAuth provider for an MCP server."""
return self._request(
"POST",
f"{_DEPLOY_PATH}/mcp-servers/{mcp_server_id}/oauth-provider",
json={},
)
def create_auth_session(
self,
*,
provider_id: str,
scopes: list[str],
strategy: str,
) -> dict[str, Any]:
"""Start an OAuth authorization session for the caller."""
return self._request(
"POST",
f"{_DEPLOY_PATH}/auth-sessions",
json={
"provider_id": provider_id,
"scopes": scopes,
"strategy": strategy,
},
)
def get_auth_session(
self,
session_id: str,
*,
wait_seconds: int,
) -> dict[str, Any]:
"""Fetch or long-poll an OAuth authorization session."""
return self._request(
"GET",
f"{_DEPLOY_PATH}/auth-sessions/{session_id}",
params={"wait_seconds": wait_seconds},
)
# --- hub directories -------------------------------------------------
def get_agent_directory(self, agent_id: str) -> dict[str, Any]:
"""Fetch the Hub directory backing a managed deep agent."""
return self._request("GET", f"{_HUB_PATH}/repos/-/{agent_id}/directories")
def commit_agent_directory(
self,
agent_id: str,
*,
files: dict[str, dict[str, str] | None],
parent_commit: str | None,
) -> dict[str, Any]:
"""Commit file updates to the Hub directory backing an agent."""
payload: dict[str, Any] = {"files": files}
if parent_commit:
payload["parent_commit"] = parent_commit
return self._request(
"POST",
f"{_HUB_PATH}/repos/-/{agent_id}/directories/commits",
json=payload,
)
-583
View File
@@ -1,583 +0,0 @@
"""Bundle a deepagents project for deployment.
Reads the canonical project layout:
```txt
<project>/
deepagents.toml # required — agent + sandbox config
AGENTS.md # required — system prompt + seeded memory
.env # optional — environment variables
mcp.json # optional — HTTP/SSE MCP servers
skills/ # optional — auto-seeded into skills namespace
user/ # optional — per-user writable memory
AGENTS.md # optional — seeded as empty if not provided
```
...and writes everything `langgraph deploy` needs to a build directory.
AGENTS.md and skills are read-only at runtime. When a `user/`
directory is present, a per-user `AGENTS.md` is seeded (from
`user/AGENTS.md` if provided, otherwise empty) and is writable
at runtime.
"""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
from pathlib import Path
from typing import Any
from deepagents_cli.deploy.config import (
AGENTS_MD_FILENAME,
MCP_FILENAME,
SKILLS_DIRNAME,
USER_DIRNAME,
DeployConfig,
SubAgentProject,
load_subagents,
)
from deepagents_cli.deploy.templates import (
APP_PY_TEMPLATE,
AUTH_BLOCKS,
AUTH_ON_HANDLER,
DEPLOY_GRAPH_TEMPLATE,
MCP_TOOLS_TEMPLATE,
PYPROJECT_TEMPLATE,
SANDBOX_BLOCKS,
SYNC_SUBAGENTS_TEMPLATE,
)
logger = logging.getLogger(__name__)
_MODEL_PROVIDER_DEPS: dict[str, str] = {
"anthropic": "langchain-anthropic",
"azure_openai": "langchain-openai",
"baseten": "langchain-baseten",
"cohere": "langchain-cohere",
"deepseek": "langchain-deepseek",
"fireworks": "langchain-fireworks",
"google_genai": "langchain-google-genai",
"google_vertexai": "langchain-google-vertexai",
"groq": "langchain-groq",
"mistralai": "langchain-mistralai",
"nvidia": "langchain-nvidia-ai-endpoints",
"openai": "langchain-openai",
"openrouter": "langchain-openrouter",
"perplexity": "langchain-perplexity",
"together": "langchain-together",
"xai": "langchain-xai",
}
"""Dependencies inferred from a provider: prefix on the model string."""
_FRONTEND_DIST_SRC = Path(__file__).parent / "frontend_dist"
"""Location of the shipped pre-built frontend, inside this Python package."""
_FRONTEND_PLACEHOLDER_RE = re.compile(
r"window\.__DEEPAGENTS_CONFIG__\s*=\s*\{[^<]*?\};",
re.DOTALL,
)
"""Matches the placeholder script we injected into index.html at build time."""
def _build_runtime_config_json(config: DeployConfig) -> str:
"""Build the JSON value injected into `window.__DEEPAGENTS_CONFIG__`.
Only reached when `[frontend].enabled` and `[auth]` is set —
validation guarantees both. The `is None` guards below exist so
the optional fields narrow for type-checkers.
"""
if config.frontend is None:
msg = "runtime config requires [frontend] to be configured"
raise ValueError(msg)
if config.auth is None:
msg = "runtime config requires [auth] to be configured"
raise ValueError(msg)
app_name = config.frontend.app_name or config.agent.name
payload: dict[str, Any] = {
"appName": app_name,
"assistantId": "agent",
}
# Optional UI-customization fields — only injected when the user
# set them, so the default-bundle case stays small.
if config.frontend.subtitle is not None:
payload["subtitle"] = config.frontend.subtitle
if config.frontend.prompts is not None:
payload["prompts"] = list(config.frontend.prompts)
provider = config.auth.provider
payload["auth"] = provider
if provider == "supabase":
payload["supabaseUrl"] = os.environ["SUPABASE_URL"]
payload["supabaseAnonKey"] = os.environ["SUPABASE_PUBLISHABLE_DEFAULT_KEY"]
elif provider == "clerk":
payload["clerkPublishableKey"] = os.environ["CLERK_PUBLISHABLE_KEY"]
elif provider == "anonymous":
# No env vars; payload["auth"] = "anonymous" is enough.
pass
else:
msg = f"Unknown auth provider for frontend: {provider}"
raise ValueError(msg)
# Escape `<` so a hostile or accidental `</script>` inside a string value
# can't break out of the inline <script> tag.
return json.dumps(payload, separators=(",", ":")).replace("<", "\\u003c")
def _copy_frontend_dist(config: DeployConfig, build_dir: Path) -> None:
"""Copy the pre-built bundle into build_dir and rewrite the config placeholder."""
if not _FRONTEND_DIST_SRC.is_dir():
msg = (
f"Shipped frontend bundle not found at {_FRONTEND_DIST_SRC}. "
"Did you run `make build-frontends`?"
)
raise RuntimeError(msg)
dest = build_dir / "frontend_dist"
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(_FRONTEND_DIST_SRC, dest)
index_html = dest / "index.html"
if not index_html.is_file():
msg = f"expected index.html inside {_FRONTEND_DIST_SRC}"
raise RuntimeError(msg)
html = index_html.read_text(encoding="utf-8")
payload = _build_runtime_config_json(config)
replacement = f"window.__DEEPAGENTS_CONFIG__ = {payload};"
new_html, count = _FRONTEND_PLACEHOLDER_RE.subn(
lambda _m: replacement,
html,
count=1,
)
if count == 0:
msg = (
"Could not find window.__DEEPAGENTS_CONFIG__ placeholder in the "
"shipped index.html. The frontend bundle is out of sync with the "
"bundler — rebuild with `make build-frontends`."
)
raise RuntimeError(msg)
index_html.write_text(new_html, encoding="utf-8")
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(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, user_memories: %d)",
len(seed["memories"]),
len(seed["skills"]),
len(seed.get("user_memories", {})),
)
# 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 (alongside
# deepagents.toml). 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. Load subagents (needed for both deploy_graph.py and pyproject.toml).
sync_subagents = load_subagents(project_root)
# 5. Render deploy_graph.py.
has_user_memories = (project_root / USER_DIRNAME).is_dir()
has_sync_subagents = bool(sync_subagents)
(build_dir / "deploy_graph.py").write_text(
_render_deploy_graph(
config,
mcp_present=mcp_present,
has_user_memories=has_user_memories,
has_sync_subagents=has_sync_subagents,
),
encoding="utf-8",
)
logger.info("Generated deploy_graph.py")
# 5b. Vendor ContextHubBackend alongside the graph when hub-backed. The
# deployed bundle cannot import `deepagents_cli` (it is a dev-time CLI,
# not a cloud runtime dependency), so we ship a copy of the module and
# the generated graph imports it locally.
if config.memories.backend == "hub":
src = Path(__file__).parent / "context_hub.py"
shutil.copy2(src, build_dir / "_context_hub.py")
logger.info("Vendored %s → _context_hub.py", src.name)
# 6. Generate auth.py from the [auth] provider, if any. Skipped
# entirely when [auth] is omitted — in that case LangSmith Cloud's
# default x-api-key auth applies. Validation guarantees [auth] is
# set whenever [frontend].enabled, so auth.py is always present
# for frontend deploys (including the "anonymous" provider, whose
# permissive handler lets the bundled UI reach /threads).
frontend_enabled = config.frontend is not None and config.frontend.enabled
auth_provider: str | None = (
config.auth.provider if config.auth is not None else None
)
auth_present = auth_provider is not None
if auth_provider is not None:
(build_dir / "auth.py").write_text(
_render_auth_py(auth_provider),
encoding="utf-8",
)
logger.info("Generated auth.py (%s)", auth_provider)
# 6b. Copy frontend bundle when enabled.
if frontend_enabled:
_copy_frontend_dist(config, build_dir)
(build_dir / "app.py").write_text(APP_PY_TEMPLATE, encoding="utf-8")
logger.info("Copied frontend bundle and wrote app.py (%s)", auth_provider)
# 7. Render langgraph.json.
(build_dir / "langgraph.json").write_text(
_render_langgraph_json(
env_present=env_present,
auth_present=auth_present,
frontend_present=frontend_enabled,
),
encoding="utf-8",
)
# 7. Render pyproject.toml.
subagent_model_providers: list[str] = []
has_subagent_mcp = False
for sa in sync_subagents.values():
model = sa.config.agent.model
if ":" in model:
subagent_model_providers.append(model.split(":", 1)[0])
if (sa.root / MCP_FILENAME).is_file():
has_subagent_mcp = True
(build_dir / "pyproject.toml").write_text(
_render_pyproject(
config,
mcp_present=mcp_present,
subagent_model_providers=subagent_model_providers,
has_subagent_mcp=has_subagent_mcp,
),
encoding="utf-8",
)
return build_dir
def _build_subagent_seed(subagent: SubAgentProject) -> dict:
"""Build the seed entry for a single sync subagent."""
sa_root = subagent.root
agent = subagent.config.agent
memories: dict[str, str] = {
f"/{AGENTS_MD_FILENAME}": (sa_root / AGENTS_MD_FILENAME).read_text(
encoding="utf-8"
),
}
skills: dict[str, str] = {}
skills_dir = sa_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")
mcp_path = sa_root / MCP_FILENAME
mcp = None
if mcp_path.is_file():
mcp = json.loads(mcp_path.read_text(encoding="utf-8"))
return {
"config": {
"name": agent.name,
"description": agent.description,
"model": agent.model,
},
"memories": memories,
"skills": skills,
"mcp": mcp,
}
def _build_seed(
project_root: Path,
system_prompt: str,
) -> dict:
"""Build the `_seed.json` payload.
Layout::
{
"memories": { "/AGENTS.md": "..." },
"skills": { "/<skill>/SKILL.md": "...", ... },
"user_memories": { "/AGENTS.md": "..." }
}
`memories` and `skills` are read-only at runtime.
`user_memories` contains a single writable `AGENTS.md` mounted at
`/memories/user/`, namespaced per user_id. If the project has a
`user/` directory (even if empty), an `AGENTS.md` is always seeded.
"""
memories: dict[str, str] = {f"/{AGENTS_MD_FILENAME}": system_prompt}
skills: dict[str, str] = {}
user_memories: 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")
user_dir = project_root / USER_DIRNAME
if user_dir.is_dir():
user_agents_md = user_dir / AGENTS_MD_FILENAME
content = (
user_agents_md.read_text(encoding="utf-8")
if user_agents_md.is_file()
else ""
)
user_memories[f"/{AGENTS_MD_FILENAME}"] = content
seed: dict = {
"memories": memories,
"skills": skills,
"user_memories": user_memories,
}
# Sync subagents.
sync_subagents = load_subagents(project_root)
if sync_subagents:
seed["subagents"] = {
name: _build_subagent_seed(sa) for name, sa in sync_subagents.items()
}
return seed
def _render_deploy_graph(
config: DeployConfig,
*,
mcp_present: bool,
has_user_memories: bool = False,
has_sync_subagents: bool = False,
) -> 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"
if has_sync_subagents:
sync_subagents_block = SYNC_SUBAGENTS_TEMPLATE
sync_subagents_load_call = (
"all_subagents.extend("
"await _build_sync_subagents(seed, store, assistant_id))"
)
else:
sync_subagents_block = ""
sync_subagents_load_call = "pass # no sync subagents"
memories_hub_identifier = config.memories.identifier or f"-/{config.agent.name}"
return DEPLOY_GRAPH_TEMPLATE.format(
model=config.agent.model,
sandbox_snapshot=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,
has_user_memories=has_user_memories,
sync_subagents_block=sync_subagents_block,
sync_subagents_load_call=sync_subagents_load_call,
memories_backend=config.memories.backend,
memories_hub_identifier=memories_hub_identifier,
agent_writable=config.memories.agent_writable,
)
def _render_auth_py(provider: str) -> str:
"""Render the generated `auth.py` for the given auth provider."""
if provider not in AUTH_BLOCKS:
msg = f"Unknown auth provider {provider!r}. Valid: {sorted(AUTH_BLOCKS)}"
raise ValueError(msg)
auth_block, _ = AUTH_BLOCKS[provider]
return auth_block + AUTH_ON_HANDLER
def _render_langgraph_json(
*,
env_present: bool,
auth_present: bool = False,
frontend_present: bool = False,
) -> str:
"""Render `langgraph.json` — adds `"env"`, `"auth"`, `"http"` when applicable."""
data: dict = {
"dependencies": ["."],
"graphs": {"agent": "./deploy_graph.py:make_graph"},
"python_version": "3.12",
}
if env_present:
data["env"] = ".env"
if auth_present:
data["auth"] = {"path": "./auth.py:auth"}
if frontend_present:
data["http"] = {"app": "./app.py:app"}
return json.dumps(data, indent=2) + "\n"
def _render_pyproject(
config: DeployConfig,
*,
mcp_present: bool,
subagent_model_providers: list[str] | None = None,
has_subagent_mcp: bool = False,
) -> 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])
# Add deps for subagent model providers.
for sp in subagent_model_providers or []:
dep = _MODEL_PROVIDER_DEPS.get(sp)
if dep and dep not in deps:
deps.append(dep)
if mcp_present or has_subagent_mcp:
deps.append("langchain-mcp-adapters")
_, partner_pkg = SANDBOX_BLOCKS.get(config.sandbox.provider, (None, None))
if partner_pkg:
deps.append(partner_pkg)
if config.auth is not None:
_, auth_pkg = AUTH_BLOCKS.get(config.auth.provider, (None, None))
if auth_pkg:
deps.append(auth_pkg)
# ContextHubBackend uses AgentContext/FileEntry from langsmith 0.7.35+.
# deepagents floors langsmith at a lower version, so pin explicitly when
# the deployed graph needs the hub APIs.
if config.memories.backend == "hub":
deps.append("langsmith>=0.7.35")
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, Any] = {"memories": {}, "skills": {}}
if seed_path.exists():
try:
seed = json.loads(seed_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
logger.warning(
"Failed to parse %s; summary may be incomplete: %s",
seed_path,
exc,
)
print(f"\n Agent: {config.agent.name}")
print(f" Model: {config.agent.model}")
if config.auth is not None:
if config.auth.provider == "anonymous":
print(" Auth: anonymous (API open to anyone)")
else:
print(f" Auth: {config.auth.provider}")
else:
print(" Auth: none (LangSmith API key required to call the API)")
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}")
user_memory_files = sorted(seed.get("user_memories", {}).keys())
if user_memory_files:
print(f"\n User memory seed ({len(user_memory_files)} file(s)):")
for f in user_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")
# Subagent summary.
sync_subagents = seed.get("subagents", {})
if sync_subagents:
print(f"\n Subagents ({len(sync_subagents)}):")
for name, sa_data in sync_subagents.items():
desc = sa_data.get("config", {}).get("description", "")
print(f" {name} \u2014 {desc}")
print(f"\n Sandbox: {config.sandbox.provider}")
memories_backend = config.memories.backend
if memories_backend == "hub":
hub_identifier = config.memories.identifier or f"-/{config.agent.name}"
print(f" Memories: hub ({hub_identifier})")
else:
print(f" Memories: {memories_backend}")
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()
File diff suppressed because it is too large Load Diff
-863
View File
@@ -1,863 +0,0 @@
"""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 (`skills/`) and MCP servers (`mcp.json`) are auto-detected from the
project layout. The agent's system prompt is read from `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, Literal, get_args
SandboxProvider = Literal["none", "daytona", "langsmith", "modal", "runloop"]
"""Valid sandbox provider identifiers."""
SandboxScope = Literal["thread", "assistant"]
"""Valid sandbox scope values."""
VALID_SANDBOX_PROVIDERS: frozenset[str] = frozenset(get_args(SandboxProvider))
"""Valid sandbox providers for deploy (subset of sandbox_factory, plus `"none"`)."""
VALID_SANDBOX_SCOPES: frozenset[str] = frozenset(get_args(SandboxScope))
AuthProvider = Literal["supabase", "clerk", "anonymous"]
"""Valid auth provider identifiers.
`"anonymous"` ships a permissive auth handler that overrides LangSmith
Cloud's default `x-api-key` requirement so the bundled frontend can
reach `/threads`. The API is open to anyone with the deploy URL —
per-browser thread scoping is enforced client-side via a UUID cookie.
"""
VALID_AUTH_PROVIDERS: frozenset[str] = frozenset(get_args(AuthProvider))
"""Valid auth providers for deploy."""
MemoriesBackend = Literal["store", "hub"]
"""Valid backing store for the `/memories/` namespace."""
VALID_MEMORIES_BACKENDS: frozenset[str] = frozenset(get_args(MemoriesBackend))
DEFAULT_CONFIG_FILENAME = "deepagents.toml"
# Canonical filenames inside the project root.
AGENTS_MD_FILENAME = "AGENTS.md"
SKILLS_DIRNAME = "skills"
USER_DIRNAME = "user"
MCP_FILENAME = "mcp.json"
SUBAGENTS_DIRNAME = "subagents"
@dataclass(frozen=True)
class AgentConfig:
"""`[agent]` section — core agent identity."""
name: str
description: str = ""
model: str = "anthropic:claude-sonnet-4-6"
def __post_init__(self) -> None: # noqa: D105 — simple guard, not a public API
if not self.name.strip():
msg = "AgentConfig.name must be non-empty"
raise ValueError(msg)
@dataclass(frozen=True)
class SubAgentConfig:
"""Parsed from a subagent's deepagents.toml."""
agent: AgentConfig
@dataclass(frozen=True)
class SubAgentProject:
"""A discovered subagent directory with its parsed config."""
config: SubAgentConfig
root: Path
@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.
"""
provider: SandboxProvider = "none"
"""Sandbox backend identifier (`"none"` disables the sandbox)."""
template: str = "deepagents-deploy"
"""LangSmith snapshot name the deployed graph boots from.
The TOML key is kept as `template` for backward compatibility with
existing `deepagents.toml` files — LangSmith's API renamed "template"
to "snapshot" in 0.7.32, but this field name did not. The default
`"deepagents-deploy"` is distinct from the interactive CLI default
(`deepagents-cli`) so production deployments can be rebuilt
independently of local-CLI snapshots.
"""
image: str = "python:3"
"""Docker image used to build the snapshot when it does not yet exist."""
scope: SandboxScope = "thread"
"""How sandbox cache keys are built.
- `"thread"` (default): one sandbox per thread. Different threads get
different sandboxes; the same thread reuses across turns.
- `"assistant"`: one sandbox per assistant. All threads of the same
assistant share a single sandbox and its filesystem.
"""
@dataclass(frozen=True)
class AuthConfig:
"""`[auth]` section — authentication provider settings.
The whole section is optional. When omitted, no `auth.py` is
generated and LangSmith Cloud's default `x-api-key` auth applies
(callers still need a LangSmith API key to reach the deployment).
To make the API genuinely open — e.g., to expose the bundled
`[frontend]` without sign-in — set `provider = "anonymous"`
explicitly.
"""
provider: AuthProvider
@dataclass(frozen=True)
class MemoriesConfig:
"""`[memories]` section — backing store for `/memories/`.
`backend = "hub"` (default) routes `/memories/` through a
`ContextHubBackend` bound to a LangSmith Hub agent repo, giving
persistent, git-like storage. `backend = "store"` routes it through a
`StoreBackend` against the LangGraph runtime store.
`identifier` overrides the Hub agent repo. When omitted, it defaults
to `-/{agent.name}` at bundle time.
`agent_writable` controls whether the agent can write to `/memories/`.
When `False` (default), agent memory is read-only and only user memories
under `/memories/user/` are writable. When `True`, the agent can write
anywhere under `/memories/`.
"""
backend: MemoriesBackend = "hub"
identifier: str = ""
agent_writable: bool = False
@dataclass(frozen=True)
class FrontendConfig:
"""`[frontend]` section — bundled default frontend settings.
When `enabled = True`, `deepagent deploy` copies a pre-built React
chat UI into the deployment alongside the agent. An `[auth]`
section is required in this case — pick `"supabase"` or `"clerk"`
for real per-user auth, or set `provider = "anonymous"` explicitly
to ship the UI with an open API.
"""
enabled: bool = False
app_name: str | None = None
subtitle: str | None = None
"""Subtitle shown under the app name in the header and on the
empty-state hero. Falls back to a generic default when unset."""
prompts: tuple[str, ...] | None = None
"""Suggestion chips shown on the empty-state. Falls back to the
bundled defaults when unset."""
@dataclass(frozen=True)
class DeployConfig:
"""Top-level deploy configuration parsed from `deepagents.toml`."""
agent: AgentConfig
"""Parsed `[agent]` section — core agent identity (name + model)."""
sandbox: SandboxConfig = field(default_factory=SandboxConfig)
"""Parsed `[sandbox]` section — provider, snapshot name, image, scope."""
auth: AuthConfig | None = None
memories: MemoriesConfig = field(default_factory=MemoriesConfig)
"""Parsed `[memories]` section — backing store for `/memories/`."""
frontend: FrontendConfig | None = None
def validate(self, project_root: Path) -> list[str]:
"""Validate config against the filesystem.
Args:
project_root: Directory containing `deepagents.toml`.
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))}"
)
if self.memories.backend not in VALID_MEMORIES_BACKENDS:
errors.append(
f"Unknown memories backend: {self.memories.backend}. "
f"Valid: {', '.join(sorted(VALID_MEMORIES_BACKENDS))}"
)
if self.memories.backend == "hub":
errors.extend(_validate_hub_credentials())
# 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))
# Validate credentials for auth provider.
if self.auth is not None:
errors.extend(_validate_auth_credentials(self.auth.provider))
if self.frontend is not None and self.frontend.enabled:
if self.auth is None:
errors.append(
"[frontend].enabled requires [auth] to be configured. "
'Add an [auth] section with provider = "supabase", '
'"clerk", or "anonymous".'
)
else:
errors.extend(_validate_frontend_credentials(self.auth.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
_ALLOWED_SUBAGENT_SECTIONS = frozenset({"agent"})
def _parse_subagent_config(data: dict[str, Any], subagent_dir: Path) -> SubAgentConfig:
"""Parse a subagent's deepagents.toml into a `SubAgentConfig`.
Raises:
ValueError: If the config has disallowed sections, missing required
fields, or unknown keys.
"""
# Reject disallowed sections.
unknown = set(data.keys()) - _ALLOWED_SUBAGENT_SECTIONS
if unknown:
disallowed = sorted(unknown)
msg = (
f"Section(s) {disallowed} not allowed in subagent config "
f"({subagent_dir}). Only {sorted(_ALLOWED_SUBAGENT_SECTIONS)} "
f"is permitted."
)
raise ValueError(msg)
agent_data = data.get("agent", {})
# Reject unknown agent keys.
unknown_agent = set(agent_data.keys()) - _ALLOWED_AGENT_KEYS
if unknown_agent:
msg = (
f"Unknown key(s) in [agent]: {sorted(unknown_agent)}. "
f"Allowed: {sorted(_ALLOWED_AGENT_KEYS)}"
)
raise ValueError(msg)
# Require name.
if "name" not in agent_data:
msg = f"[agent].name is required in subagent deepagents.toml ({subagent_dir})"
raise ValueError(msg)
# Require description (non-empty).
desc = agent_data.get("description", "")
if not isinstance(desc, str) or not desc.strip():
msg = (
f"[agent].description is required (non-empty) in subagent "
f"deepagents.toml ({subagent_dir})"
)
raise ValueError(msg)
agent_kwargs: dict[str, Any] = {
"name": agent_data["name"],
"description": desc,
}
if "model" in agent_data:
agent_kwargs["model"] = agent_data["model"]
return SubAgentConfig(agent=AgentConfig(**agent_kwargs))
def load_subagents(project_root: Path) -> dict[str, SubAgentProject]:
"""Discover and load subagent projects from `subagents/`.
Returns a dict keyed by subagent name. If the `subagents/` directory
does not exist or is empty, returns an empty dict.
Raises:
ValueError: On any structural or config validation error.
"""
subagents_dir = project_root / SUBAGENTS_DIRNAME
if not subagents_dir.is_dir():
return {}
result: dict[str, SubAgentProject] = {}
for entry in sorted(subagents_dir.iterdir()):
# Skip dotfiles and non-directories.
if entry.name.startswith(".") or not entry.is_dir():
continue
# Reject nested subagents/.
if (entry / SUBAGENTS_DIRNAME).exists():
msg = (
f"Nested subagents/ not allowed inside subagent "
f"directory '{entry.name}'"
)
raise ValueError(msg)
# Require deepagents.toml.
toml_path = entry / DEFAULT_CONFIG_FILENAME
if not toml_path.is_file():
msg = f"deepagents.toml is required in subagent directory '{entry.name}'"
raise ValueError(msg)
# Require AGENTS.md.
agents_md = entry / AGENTS_MD_FILENAME
if not agents_md.is_file():
msg = f"AGENTS.md is required in subagent directory '{entry.name}'"
raise ValueError(msg)
# Parse the subagent config.
try:
with toml_path.open("rb") as f:
data = tomllib.load(f)
except tomllib.TOMLDecodeError as exc:
msg = f"Syntax error in {toml_path}: {exc}"
raise ValueError(msg) from exc
config = _parse_subagent_config(data, entry)
# Validate MCP if present.
mcp_path = entry / MCP_FILENAME
if mcp_path.is_file():
errors = _validate_mcp_for_deploy(mcp_path)
if errors:
msg = f"MCP validation errors in subagent '{entry.name}': " + "; ".join(
errors
)
raise ValueError(msg)
result[config.agent.name] = SubAgentProject(config=config, root=entry)
return result
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)
try:
with config_path.open("rb") as f:
data = tomllib.load(f)
except tomllib.TOMLDecodeError as exc:
msg = f"Syntax error in {config_path}: {exc}"
raise ValueError(msg) from exc
return _parse_config(data)
_ALLOWED_SECTIONS = frozenset({"agent", "sandbox", "auth", "memories", "frontend"})
_ALLOWED_AGENT_KEYS = frozenset({"name", "description", "model"})
_ALLOWED_SANDBOX_KEYS = frozenset({"provider", "template", "image", "scope"})
_ALLOWED_AUTH_KEYS = frozenset({"provider"})
_ALLOWED_MEMORIES_KEYS = frozenset({"backend", "identifier", "agent_writable"})
_ALLOWED_FRONTEND_KEYS = frozenset({"enabled", "app_name", "subtitle", "prompts"})
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)
unknown_agent = set(agent_data.keys()) - _ALLOWED_AGENT_KEYS
if unknown_agent:
msg = (
f"Unknown key(s) in [agent]: {sorted(unknown_agent)}. "
f"Allowed: {sorted(_ALLOWED_AGENT_KEYS)}"
)
raise ValueError(msg)
# Only pass keys present in TOML; dataclass defaults handle the rest.
agent_kwargs: dict[str, Any] = {"name": agent_data["name"]}
if "description" in agent_data:
agent_kwargs["description"] = agent_data["description"]
if "model" in agent_data:
agent_kwargs["model"] = agent_data["model"]
agent = AgentConfig(**agent_kwargs)
sandbox_data = data.get("sandbox", {})
unknown_sandbox = set(sandbox_data.keys()) - _ALLOWED_SANDBOX_KEYS
if unknown_sandbox:
msg = (
f"Unknown key(s) in [sandbox]: {sorted(unknown_sandbox)}. "
f"Allowed: {sorted(_ALLOWED_SANDBOX_KEYS)}"
)
raise ValueError(msg)
sandbox_kwargs: dict[str, Any] = {
k: sandbox_data[k] for k in _ALLOWED_SANDBOX_KEYS if k in sandbox_data
}
sandbox = SandboxConfig(**sandbox_kwargs)
auth: AuthConfig | None = None
auth_data = data.get("auth")
if auth_data is not None:
unknown_auth = set(auth_data.keys()) - _ALLOWED_AUTH_KEYS
if unknown_auth:
msg = (
f"Unknown key(s) in [auth]: {sorted(unknown_auth)}. "
f"Allowed: {sorted(_ALLOWED_AUTH_KEYS)}"
)
raise ValueError(msg)
if "provider" not in auth_data:
msg = "[auth].provider is required in deepagents.toml"
raise ValueError(msg)
auth_provider = auth_data["provider"]
if auth_provider not in VALID_AUTH_PROVIDERS:
msg = (
f"Unknown auth provider: {auth_provider}. "
f"Valid: {', '.join(sorted(VALID_AUTH_PROVIDERS))}"
)
raise ValueError(msg)
auth = AuthConfig(provider=auth_provider)
memories = MemoriesConfig()
memories_data = data.get("memories")
if memories_data is not None:
unknown_memories = set(memories_data.keys()) - _ALLOWED_MEMORIES_KEYS
if unknown_memories:
msg = (
f"Unknown key(s) in [memories]: {sorted(unknown_memories)}. "
f"Allowed: {sorted(_ALLOWED_MEMORIES_KEYS)}"
)
raise ValueError(msg)
backend = memories_data.get("backend", "hub")
if backend not in VALID_MEMORIES_BACKENDS:
msg = (
f"Unknown memories backend: {backend}. "
f"Valid: {', '.join(sorted(VALID_MEMORIES_BACKENDS))}"
)
raise ValueError(msg)
identifier = memories_data.get("identifier", "")
if identifier and "/" not in identifier:
msg = (
f"[memories].identifier must be in 'owner/name' form "
f"(or '-/name' for the caller's tenant); got {identifier!r}"
)
raise ValueError(msg)
agent_writable = memories_data.get("agent_writable", False)
if not isinstance(agent_writable, bool):
msg = (
"[memories].agent_writable must be a boolean, "
f"got {type(agent_writable).__name__}"
)
raise ValueError(msg)
memories = MemoriesConfig(
backend=backend,
identifier=identifier,
agent_writable=agent_writable,
)
frontend: FrontendConfig | None = None
frontend_data = data.get("frontend")
if frontend_data is not None:
unknown_frontend = set(frontend_data.keys()) - _ALLOWED_FRONTEND_KEYS
if unknown_frontend:
msg = (
f"Unknown key(s) in [frontend]: {sorted(unknown_frontend)}. "
f"Allowed: {sorted(_ALLOWED_FRONTEND_KEYS)}"
)
raise ValueError(msg)
frontend_kwargs: dict[str, Any] = {
k: frontend_data[k] for k in _ALLOWED_FRONTEND_KEYS if k in frontend_data
}
# FrontendConfig is frozen=True; coerce list -> tuple so the
# dataclass stays hashable.
if "prompts" in frontend_kwargs:
prompts_raw = frontend_kwargs["prompts"]
if not isinstance(prompts_raw, list) or not all(
isinstance(p, str) for p in prompts_raw
):
msg = "[frontend].prompts must be a list of strings"
raise ValueError(msg)
frontend_kwargs["prompts"] = tuple(prompts_raw)
frontend = FrontendConfig(**frontend_kwargs)
return DeployConfig(
agent=agent,
sandbox=sandbox,
auth=auth,
memories=memories,
frontend=frontend,
)
_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.
}
_AUTH_PROVIDER_ENV: dict[str, list[str]] = {
"supabase": ["SUPABASE_URL", "SUPABASE_PUBLISHABLE_DEFAULT_KEY"],
"clerk": ["CLERK_SECRET_KEY"],
}
_FRONTEND_EXTRA_ENV: dict[str, list[str]] = {
# Supabase reuses `SUPABASE_URL` + `SUPABASE_PUBLISHABLE_DEFAULT_KEY`
# from [auth] — no extra browser-facing env vars needed.
"supabase": [],
# Clerk's browser-facing publishable key is distinct from
# `CLERK_SECRET_KEY` (which [auth] uses for JWKS validation).
"clerk": ["CLERK_PUBLISHABLE_KEY"],
}
"""Additional env vars the frontend bundle needs beyond what `[auth]` requires."""
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 at least one required API key env var is set for the 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 _validate_auth_credentials(provider: str) -> list[str]:
"""Check that all required env vars are set for the auth provider."""
required_vars = _AUTH_PROVIDER_ENV.get(provider)
if required_vars is None:
return []
missing = [v for v in required_vars if not os.environ.get(v)]
if not missing:
return []
return [
(
f"Auth provider '{provider}' requires {' and '.join(missing)}. "
f"Add them to your .env file or environment."
),
]
def _validate_hub_credentials() -> list[str]:
"""Check that a LangSmith key is set when `[memories].backend = 'hub'`."""
if os.environ.get("LANGSMITH_API_KEY") or os.environ.get("LANGCHAIN_API_KEY"):
return []
return [
(
"Memories backend 'hub' requires a LangSmith key: "
"set LANGSMITH_API_KEY (or LANGCHAIN_API_KEY) in your .env "
"file or environment."
),
]
def _validate_frontend_credentials(provider: str) -> list[str]:
"""Check that all extra env vars are set for the frontend bundle."""
required = _FRONTEND_EXTRA_ENV.get(provider)
if required is None:
return []
missing = [v for v in required if not os.environ.get(v)]
if not missing:
return []
return [
(
f"Frontend for '{provider}' requires {' and '.join(missing)}. "
f"Add it to your .env file so the bundler can write it "
f"into index.html at deploy time."
),
]
def find_config(start_path: Path | None = None) -> Path | None:
"""Find `deepagents.toml` in *start_path* (or cwd if not given).
Only checks the single directory — does not walk parent directories.
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
# [auth] is optional. Add to enable user authentication.
# [auth]
# provider = "supabase" # supabase | clerk | anonymous
# [memories] is optional. Defaults to a LangSmith Hub agent repo
# (`backend = "hub"`). Set backend = "store" to use the LangGraph
# runtime store instead.
# [memories]
# backend = "hub" # hub | store
# identifier = "-/my-agent" # optional override; defaults to `-/{agent.name}`
# [frontend] is optional. Add to ship a bundled chat UI on the same
# deployment as the agent. Requires [auth] — pick "supabase" or
# "clerk" for real per-user auth, or set provider = "anonymous" to
# leave the API open to anyone with the deploy URL (private/dev
# deploys only).
# [frontend]
# enabled = true
# app_name = "My Agent"
"""
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=
# Auth provider (optional, uncomment for [auth])
# SUPABASE_URL=
# SUPABASE_PUBLISHABLE_DEFAULT_KEY=
# CLERK_SECRET_KEY=
# Frontend (optional, uncomment for [frontend] + matching [auth])
# Clerk only — browser-facing publishable key. Supabase reuses the keys above.
# CLERK_PUBLISHABLE_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.
"""
@@ -1,341 +0,0 @@
"""ContextHubBackend: Store files in a LangSmith Hub agent repo (persistent)."""
from __future__ import annotations
import fnmatch
import logging
import re
from typing import TYPE_CHECKING
from deepagents.backends.protocol import (
BackendProtocol,
EditResult,
FileData,
FileDownloadResponse,
FileInfo,
FileUploadResponse,
GlobResult,
GrepMatch,
GrepResult,
LsResult,
ReadResult,
WriteResult,
)
from deepagents.backends.utils import (
create_file_data,
perform_string_replacement,
slice_read_response,
)
if TYPE_CHECKING:
from langsmith import Client
from langsmith.schemas import AgentContext
logger = logging.getLogger(__name__)
# Matches the ":<hash>" suffix appended by langsmith's _build_context_url.
_URL_COMMIT_SUFFIX_RE = re.compile(r":([0-9a-f]{8,64})$")
class ContextHubBackend(BackendProtocol):
"""Backend that stores files in a LangSmith Hub agent repo (persistent)."""
def __init__(
self,
identifier: str,
*,
client: Client | None = None,
) -> None:
"""Initialize ContextHubBackend.
Args:
identifier: Hub agent repo, as ``"owner/name"`` or ``"-/name"``.
client: LangSmith client. Defaults to ``Client()``.
"""
from langsmith import Client as _Client
self._identifier = identifier
self._client = client if client is not None else _Client()
self._cache: dict[str, str] | None = None
self._linked_entries: dict[str, str] = {}
self._commit_hash: str | None = None
def _load_tree(self) -> None:
"""Fetch the file tree; missing repos are treated as empty (first commit creates them).""" # noqa: E501
from langsmith.utils import LangSmithNotFoundError
try:
context: AgentContext = self._client.pull_agent(self._identifier)
except LangSmithNotFoundError:
self._cache = {}
self._linked_entries = {}
self._commit_hash = None
return
self._commit_hash = context.commit_hash
self._cache = {}
self._linked_entries = {}
from langsmith.schemas import FileEntry
for path, entry in context.files.items():
if isinstance(entry, FileEntry):
self._cache[path] = entry.content
else:
self._linked_entries[path] = entry.repo_handle
def _ensure_cache(self) -> dict[str, str]:
"""Load the file tree if not yet loaded."""
if self._cache is None:
self._load_tree()
return self._cache # type: ignore[return-value]
def get_linked_entries(self) -> dict[str, str]:
"""Return linked-entry paths mapped to their repo handles."""
self._ensure_cache()
return dict(self._linked_entries)
def has_prior_commits(self) -> bool:
"""Return True if the hub repo already exists with at least one commit."""
self._ensure_cache()
return self._commit_hash is not None
def _commit(self, files: dict[str, str]) -> None:
"""Push ``files`` as one commit; update the cache on success."""
if not files:
return
from langsmith.schemas import FileEntry
payload = {
path: FileEntry(type="file", content=content)
for path, content in files.items()
}
url = self._client.push_agent(
self._identifier,
files=payload,
parent_commit=self._commit_hash,
)
match = _URL_COMMIT_SUFFIX_RE.search(url)
if match:
self._commit_hash = match.group(1)
if self._cache is not None:
for path, content in files.items():
self._cache[path] = content
@staticmethod
def _strip_prefix(path: str) -> str:
return path.lstrip("/")
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> ReadResult:
"""Read file content for the requested line range.
Args:
file_path: Absolute file path.
offset: 0-indexed starting line.
limit: Maximum number of lines.
Returns:
ReadResult with raw (unformatted) content.
"""
hub_path = self._strip_prefix(file_path)
try:
cache = self._ensure_cache()
except Exception as exc:
logger.exception("Hub pull failed for %r", self._identifier)
return ReadResult(error=f"Hub unavailable: {exc}")
content = cache.get(hub_path)
if content is None:
return ReadResult(error=f"File '{file_path}' not found")
file_data = create_file_data(content)
sliced = slice_read_response(file_data, offset, limit)
if isinstance(sliced, ReadResult):
return sliced
return ReadResult(
file_data=FileData(
content=sliced,
encoding=file_data.get("encoding", "utf-8"),
created_at=file_data.get("created_at", ""),
modified_at=file_data.get("modified_at", ""),
)
)
def write(self, file_path: str, content: str) -> WriteResult:
"""Commit ``content`` to ``file_path``."""
hub_path = self._strip_prefix(file_path)
try:
self._ensure_cache() # populates _commit_hash for parent_commit on push
self._commit({hub_path: content})
except Exception as exc:
logger.exception("Hub write failed for %r", self._identifier)
self._cache = None
return WriteResult(error=f"Hub unavailable: {exc}")
return WriteResult(path=file_path)
def edit(
self,
file_path: str,
old_string: str,
new_string: str,
replace_all: bool = False,
) -> EditResult:
"""Replace ``old_string`` with ``new_string``; fails on multiple matches unless ``replace_all=True``.""" # noqa: E501
hub_path = self._strip_prefix(file_path)
try:
cache = self._ensure_cache()
current = cache.get(hub_path)
if current is None:
return EditResult(error=f"Error: File '{file_path}' not found")
result = perform_string_replacement(
current, old_string, new_string, replace_all
)
if isinstance(result, str):
return EditResult(error=result)
new_content, occurrences = result
self._commit({hub_path: new_content})
except Exception as exc:
logger.exception("Hub edit failed for %r", self._identifier)
self._cache = None
return EditResult(error=f"Hub unavailable: {exc}")
return EditResult(path=file_path, occurrences=occurrences)
def ls(self, path: str = "/") -> LsResult:
"""List immediate files and subdirectories under ``path`` (non-recursive)."""
hub_prefix = self._strip_prefix(path).rstrip("/")
try:
cache = self._ensure_cache()
except Exception as exc:
logger.exception("Hub pull failed for %r", self._identifier)
return LsResult(error=f"Hub unavailable: {exc}")
dirs: set[str] = set()
entries: list[FileInfo] = []
for file_path in cache:
if hub_prefix and not file_path.startswith(hub_prefix + "/"):
continue
relative = file_path[len(hub_prefix) + 1 :] if hub_prefix else file_path
if not relative:
continue
parts = relative.split("/", 1)
if len(parts) == 1:
entries.append(FileInfo(path=f"/{file_path}", is_dir=False))
else:
dir_name = parts[0]
dir_path = f"{hub_prefix}/{dir_name}" if hub_prefix else dir_name
if dir_path not in dirs:
dirs.add(dir_path)
entries.append(FileInfo(path=f"/{dir_path}", is_dir=True))
return LsResult(entries=entries)
def grep(
self,
pattern: str,
path: str | None = None,
glob: str | None = None,
) -> GrepResult:
"""Search contents for ``pattern`` (optional ``path`` / ``glob`` filters)."""
try:
cache = self._ensure_cache()
except Exception as exc:
logger.exception("Hub pull failed for %r", self._identifier)
return GrepResult(error=f"Hub unavailable: {exc}")
matches: list[GrepMatch] = []
try:
regex = re.compile(pattern)
except re.error as e:
return GrepResult(error=f"Invalid regex pattern: {e}")
prefix = self._strip_prefix(path).rstrip("/") if path else ""
for file_path, content in cache.items():
if prefix and not file_path.startswith(prefix):
continue
if glob and not fnmatch.fnmatch(file_path, glob):
continue
for i, line in enumerate(content.splitlines(), start=1):
if regex.search(line):
matches.append(GrepMatch(path=f"/{file_path}", line=i, text=line))
return GrepResult(matches=matches)
def glob(self, pattern: str, path: str = "/") -> GlobResult: # noqa: ARG002
"""Return files matching ``pattern`` (``path`` unused — flat namespace)."""
try:
cache = self._ensure_cache()
except Exception as exc:
logger.exception("Hub pull failed for %r", self._identifier)
return GlobResult(error=f"Hub unavailable: {exc}")
results: list[FileInfo] = [
FileInfo(path=f"/{file_path}", is_dir=False)
for file_path in cache
if fnmatch.fnmatch(f"/{file_path}", pattern)
or fnmatch.fnmatch(file_path, pattern)
]
return GlobResult(matches=results)
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
"""Upload text files in one commit; non-UTF-8 inputs rejected per file."""
# Decode each input; ``None`` text means we'll reject this entry as invalid.
decoded: list[tuple[str, str | None]] = []
valid_files: dict[str, str] = {}
for path, content in files:
try:
text = content.decode("utf-8")
except UnicodeDecodeError:
decoded.append((path, None))
continue
decoded.append((path, text))
valid_files[self._strip_prefix(path)] = text # last write wins
commit_error: str | None = None
if valid_files:
try:
self._ensure_cache()
self._commit(valid_files)
except Exception as exc:
logger.exception("Hub batch upload failed for %r", self._identifier)
self._cache = None
commit_error = f"Hub unavailable: {exc}"
results: list[FileUploadResponse] = []
for path, text in decoded:
if text is None:
results.append(FileUploadResponse(path=path, error="invalid_path"))
elif commit_error is not None:
# Backend-specific error string per protocol docs
# (FileOperationError literal union doesn't cover hub failures).
results.append(FileUploadResponse(path=path, error=commit_error)) # type: ignore[arg-type]
else:
results.append(FileUploadResponse(path=path))
return results
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
"""Download files as raw bytes. Missing paths return ``file_not_found``."""
try:
cache = self._ensure_cache()
except Exception as exc:
logger.exception("Hub pull failed for %r", self._identifier)
# Backend-specific error string per protocol docs (FileOperationError
# literal union doesn't cover hub failures).
return [
FileDownloadResponse(path=p, error=f"Hub unavailable: {exc}") # type: ignore[arg-type]
for p in paths
]
results: list[FileDownloadResponse] = []
for path in paths:
hub_path = self._strip_prefix(path)
content = cache.get(hub_path)
if content is not None:
results.append(
FileDownloadResponse(path=path, content=content.encode("utf-8"))
)
else:
results.append(FileDownloadResponse(path=path, error="file_not_found"))
return results
@@ -1 +0,0 @@
import{j as o}from"./index-DM3gptpu.js";const i="dap_anon_id";function s(n){if(typeof document>"u")return null;const e=n.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),t=document.cookie.match(new RegExp(`(?:^|; )${e}=([^;]*)`));return t?decodeURIComponent(t[1]):null}function r(n,e){typeof document>"u"||(document.cookie=`${n}=${encodeURIComponent(e)}; max-age=31536000; path=/; SameSite=Lax`)}function u(){if(typeof crypto>"u")return"anon";const n=s(i);if(n)return n;const e=crypto.randomUUID();return r(i,e),e}const c={status:"signed-in",accessToken:"",userIdentity:u(),userEmail:null,isAnonymous:!0,signOut:async()=>{}},a={Provider:({children:n})=>o.jsx(o.Fragment,{children:n}),useSession:()=>c,AuthUI:()=>null};export{a as default};
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{r,R as x,L as d,j as f,A as p}from"./index-DM3gptpu.js";var N=({code:i,language:e,raw:a,className:u,startLine:n,lineNumbers:g,...m})=>{let{shikiTheme:l}=r.useContext(x),s=d(),[h,t]=r.useState(a);return r.useEffect(()=>{if(!s){t(a);return}let o=s.highlight({code:i,language:e,themes:l},c=>{t(c)});o&&t(o)},[i,e,l,s,a]),f.jsx(p,{className:u,language:e,lineNumbers:g,result:h,startLine:n,...m})};export{N as HighlightedCodeBlockBody};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,16 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/app/logo-light.svg" media="(prefers-color-scheme: light)" />
<link rel="icon" type="image/svg+xml" href="/app/logo-dark.svg" media="(prefers-color-scheme: dark)" />
<title>Deep Agent</title>
<script>window.__DEEPAGENTS_CONFIG__ = {"__PLACEHOLDER__":true};</script>
<script type="module" crossorigin src="/app/assets/index-DM3gptpu.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-Ddy7F6KI.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
@@ -1,12 +0,0 @@
<svg width="98" height="98" viewBox="0 0 98 98" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="97.2502" height="97.2502" rx="19.4855" fill="#7FC8FF"/>
<g clip-path="url(#clip0_195_1432)">
<path d="M72.5361 42.2004V22.0426H51.7354L51.7354 22.5212C62.5113 22.7991 71.2917 31.6243 72.1695 42.2004H72.5361Z" fill="#030710"/>
<path d="M49.223 22.0428H24.8759V63.0891C24.8759 70.6689 30.7215 75.3844 40.133 75.3844H72.5471V45.3962H49.223V22.0428Z" fill="#030710"/>
</g>
<defs>
<clipPath id="clip0_195_1432">
<rect width="47.6713" height="53.3416" fill="white" transform="translate(24.8799 22.0442)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 636 B

@@ -1,12 +0,0 @@
<svg width="98" height="98" viewBox="0 0 98 98" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="97.2502" height="97.2502" rx="19.4855" fill="#030710"/>
<g clip-path="url(#clip0_195_1437)">
<path d="M72.5361 42.2004V22.0426H51.7354L51.7354 22.5212C62.5113 22.7991 71.2917 31.6243 72.1695 42.2004H72.5361Z" fill="#7FC8FF"/>
<path d="M49.223 22.0428H24.8759V63.0891C24.8759 70.6689 30.7215 75.3844 40.133 75.3844H72.5471V45.3962H49.223V22.0428Z" fill="#7FC8FF"/>
</g>
<defs>
<clipPath id="clip0_195_1437">
<rect width="47.6713" height="53.3416" fill="white" transform="translate(24.8799 22.0442)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 636 B

@@ -0,0 +1,122 @@
"""Resolve MCP server URLs in a payload to workspace-registered server IDs.
The deploy command does not auto-create MCP servers. Instead it validates that
every `mcp_server_url` the payload references already exists at the
`/v1/deepagents/mcp-servers` endpoint, and surfaces a friendly hint if not.
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from deepagents_cli.deploy.api_client import ApiClient
class _ServerResolutionError(RuntimeError):
"""Base error for MCP server resolution failures."""
def __init__(self, msg: str, *, urls: Iterable[str]) -> None:
super().__init__(msg)
self.urls = tuple(urls)
class UnresolvedServersError(_ServerResolutionError):
"""Raised when one or more `mcp_server_url`s aren't registered."""
class UninvokableServersError(_ServerResolutionError):
"""Raised when referenced MCP servers exist but cannot be invoked."""
def _normalize_url(url: str) -> str:
return url.strip().rstrip("/").lower()
def _collect_referenced_urls(payload: dict[str, Any]) -> set[str]:
urls: set[str] = set()
for tool in (payload.get("tools") or {}).get("tools", []):
u = tool.get("mcp_server_url")
if isinstance(u, str) and u:
urls.add(_normalize_url(u))
for sa in payload.get("subagents", []):
for tool in (sa.get("tools") or {}).get("tools", []):
u = tool.get("mcp_server_url")
if isinstance(u, str) and u:
urls.add(_normalize_url(u))
return urls
def resolve_referenced_servers(
client: ApiClient,
payload: dict[str, Any],
*,
cache: dict[str, str],
) -> dict[str, str]:
"""Return `{normalized_url → mcp_server_id}` for every URL in *payload*.
Always hits the list endpoint for referenced URLs so stale local state
cannot authorize a deploy against deleted, changed, or unavailable MCP
servers. Raises `UnresolvedServersError` if any URL is unresolved after
the list call and `UninvokableServersError` if a referenced server exists
but this identity cannot invoke it.
"""
_ = cache
referenced = _collect_referenced_urls(payload)
if not referenced:
return {}
out: dict[str, str] = {}
uninvokable: set[str] = set()
connect_hints: list[str] = []
for server in client.list_mcp_servers():
url = server.get("url")
if isinstance(url, str) and url:
key = _normalize_url(url)
server_id = server.get("id")
if key in referenced and server.get("can_invoke") is False:
uninvokable.add(key)
if (
server.get("auth_type") == "oauth"
and isinstance(server_id, str)
and server_id
):
connect_hints.append(
f" deepagents mcp-servers connect {server_id}"
)
elif key in referenced and isinstance(server_id, str):
out[key] = server_id
if uninvokable:
urls = tuple(sorted(uninvokable))
listed = "\n".join(f" - {u}" for u in urls)
msg = (
"The following MCP server URLs referenced in your tools are "
f"registered but this identity cannot invoke them:\n{listed}\n\n"
)
if connect_hints:
hints = "\n".join(sorted(set(connect_hints)))
msg += (
"If these servers use OAuth, connect your user credentials with:\n"
f"{hints}\n\n"
)
msg += (
"Otherwise update MCP server permissions or reference a server this "
"identity can invoke."
)
raise UninvokableServersError(msg, urls=urls)
still_missing = referenced - out.keys()
if still_missing:
urls = tuple(sorted(still_missing))
listed = "\n".join(f" - {u}" for u in urls)
msg = (
f"The following MCP server URLs referenced in your tools "
f"are not registered in this workspace:\n{listed}\n\n"
f"Register each with:\n"
f" deepagents mcp-servers add --url <url> "
f"--header KEY=VALUE [--name <name>]"
)
raise UnresolvedServersError(msg, urls=urls)
return out
+156
View File
@@ -0,0 +1,156 @@
"""Build agent payloads and managed directory entries for deployment.
This is a pure function over `Project`; no I/O happens here. The result is
suitable for `ApiClient.create_agent`, `ApiClient.patch_agent`, and the Hub
directory commit API.
"""
from __future__ import annotations
import json
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, Literal, cast
if TYPE_CHECKING:
from deepagents_cli.deploy.project import Project, Skill, Subagent
Mode = Literal["create", "patch"]
def build_payload(project: Project, *, mode: Mode = "create") -> dict[str, Any]:
"""Compose the request body for create_agent / patch_agent."""
payload = build_metadata_payload(project)
payload["system_prompt"] = project.system_prompt
if project.tools is not None:
payload["tools"] = project.tools
if project.skills:
payload["skills"] = [_skill_dict(s) for s in project.skills]
if project.subagents:
payload["subagents"] = [_subagent_dict(s) for s in project.subagents]
extra_files = _collect_extra_files(project.subagents)
if extra_files:
payload["files"] = extra_files
# `mode` remains for compatibility with earlier callers. PATCH callers
# should use `build_metadata_payload` so file state is handled by Hub
# directory commits instead of the agent PATCH endpoint.
_ = mode
return payload
def build_metadata_payload(project: Project) -> dict[str, Any]:
"""Compose the request body for metadata-only PATCH updates."""
payload: dict[str, Any] = {"name": project.name}
if project.description:
payload["description"] = project.description
if project.runtime:
payload["runtime"] = project.runtime
elif project.model:
payload["runtime"] = {"model": {"model_id": project.model}}
if project.backend:
payload["backend"] = project.backend
if project.permissions:
payload["permissions"] = project.permissions
if project.extras:
payload["extras"] = project.extras
return payload
def build_directory_files(project: Project) -> dict[str, str]:
"""Return the desired managed directory tree for the project."""
files: dict[str, str] = {"AGENTS.md": project.system_prompt}
if project.tools_text is not None:
files["tools.json"] = project.tools_text
for skill in project.skills:
base = f"skills/{skill.name}"
files[f"{base}/SKILL.md"] = skill.skill_file
for rel, content in skill.files.items():
files[f"{base}/{rel}"] = content
for subagent in project.subagents:
base = f"subagents/{subagent.name}"
files[f"{base}/AGENTS.md"] = _render_subagent_agents_md(subagent)
if subagent.tools_text is not None:
files[f"{base}/tools.json"] = subagent.tools_text
for rel, content in subagent.extra_files.items():
files[f"{base}/{rel}"] = content
return files
def build_directory_delta(
remote_files: Mapping[str, Any],
local_files: Mapping[str, str],
) -> dict[str, dict[str, str] | None]:
"""Return Hub directory commit entries to make remote match local."""
delta: dict[str, dict[str, str] | None] = {}
for path, content in local_files.items():
if _remote_content(remote_files.get(path)) != content:
delta[path] = {"type": "file", "content": content}
for path in sorted(remote_files):
if path not in local_files and _is_managed_path(path):
delta[path] = None
return delta
def _skill_dict(skill: Skill) -> dict[str, Any]:
out: dict[str, Any] = {
"type": "inline",
"name": skill.name,
"description": skill.description,
"instructions": skill.instructions,
}
if skill.files:
out["files"] = dict(skill.files)
return out
def _subagent_dict(sa: Subagent) -> dict[str, Any]:
out: dict[str, Any] = {"name": sa.name}
if sa.description:
out["description"] = sa.description
if sa.model_id:
out["model_id"] = sa.model_id
out["instructions"] = sa.instructions
if sa.tools is not None:
out["tools"] = sa.tools
return out
def _collect_extra_files(subagents: list[Subagent]) -> dict[str, dict[str, str]]:
"""Map raw-files entries from subagents into the top-level `files` field."""
out: dict[str, dict[str, str]] = {}
for sa in subagents:
for rel, content in sa.extra_files.items():
out[f"subagents/{sa.name}/{rel}"] = {"content": content}
return out
def _render_subagent_agents_md(subagent: Subagent) -> str:
frontmatter: list[str] = []
if subagent.description:
frontmatter.append(f"description: {json.dumps(subagent.description)}")
if subagent.model_id:
frontmatter.append(f"model_id: {json.dumps(subagent.model_id)}")
return "---\n" + "\n".join(frontmatter) + f"\n---\n\n{subagent.instructions}"
def _remote_content(entry: object) -> str | None:
if not isinstance(entry, dict):
return None
data = cast("dict[str, object]", entry)
if data.get("type") != "file":
return None
content = data.get("content")
return content if isinstance(content, str) else None
def _is_managed_path(path: str) -> bool:
return path in {"AGENTS.md", "tools.json"} or path.startswith(
("skills/", "subagents/")
)
+534
View File
@@ -0,0 +1,534 @@
"""Parse a Managed Deep Agents project directory into a structured value.
Layout (canonical, all paths relative to the project root):
agent.json required — top-level config
AGENTS.md required — system prompt
tools.json optional — verbatim ToolsConfig
skills/<name>/SKILL.md optional — frontmatter + body
skills/<name>/<file> optional — siblings of SKILL.md → files map
subagents/<name>/agent.json required if subagent dir exists
subagents/<name>/AGENTS.md required if subagent dir exists
subagents/<name>/tools.json optional
The result is plain Python data — no I/O happens after `load()` returns. The
payload builder (`payload.py`) consumes this dataclass.
"""
from __future__ import annotations
import json
import re as _re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, cast
_AGENT_JSON = "agent.json"
_AGENTS_MD = "AGENTS.md"
_TOOLS_JSON = "tools.json"
_SKILLS_DIR = "skills"
_SUBAGENTS_DIR = "subagents"
_SKILL_FILE = "SKILL.md"
_BACKEND_TYPE_DEFAULT = "default"
_BACKEND_TYPE_THREAD_SCOPED_SANDBOX = "thread_scoped_sandbox"
_BACKEND_TYPE_AGENT_SCOPED_SANDBOX = "agent_scoped_sandbox"
_LEGACY_BACKEND_TYPE_SANDBOX = "sandbox"
_VALID_BACKEND_TYPES = frozenset(
{
_BACKEND_TYPE_DEFAULT,
_BACKEND_TYPE_THREAD_SCOPED_SANDBOX,
_BACKEND_TYPE_AGENT_SCOPED_SANDBOX,
}
)
_SANDBOX_BACKEND_TYPES = frozenset(
{_BACKEND_TYPE_THREAD_SCOPED_SANDBOX, _BACKEND_TYPE_AGENT_SCOPED_SANDBOX}
)
_SANDBOX_INTEGER_FIELDS = frozenset({"idle_ttl_seconds", "delete_after_stop_seconds"})
_VALID_IDENTITY = frozenset({"personal", "shared"})
_VALID_VISIBILITY = frozenset({"tenant", "user"})
_VALID_TENANT_ACCESS = frozenset({"read", "run", "write"})
class ProjectError(ValueError):
"""Raised when the on-disk project is malformed."""
def _symlink_error(path: Path) -> ProjectError:
msg = f"{path}: symlinks are not allowed in deploy project inputs."
return ProjectError(msg)
def _ensure_project_contained(
root: Path,
path: Path,
*,
container: Path | None = None,
) -> None:
try:
resolved = path.resolve(strict=True)
resolved.relative_to(root.resolve(strict=True))
if container is not None:
resolved.relative_to(container.resolve(strict=True))
except FileNotFoundError as exc:
msg = f"{path}: file does not exist."
raise ProjectError(msg) from exc
except ValueError as exc:
msg = f"{path}: path escapes the deploy project directory."
raise ProjectError(msg) from exc
def _read_project_text(
root: Path,
path: Path,
*,
missing_msg: str | None = None,
container: Path | None = None,
) -> str:
if path.is_symlink():
raise _symlink_error(path)
if not path.is_file():
msg = missing_msg or f"{path}: expected a regular file."
raise ProjectError(msg)
_ensure_project_contained(root, path, container=container)
return path.read_text(encoding="utf-8")
def _is_project_file(root: Path, path: Path) -> bool:
if path.is_symlink():
raise _symlink_error(path)
if not path.is_file():
return False
_ensure_project_contained(root, path)
return True
def _is_project_dir(root: Path, path: Path) -> bool:
if path.is_symlink():
raise _symlink_error(path)
if not path.is_dir():
return False
_ensure_project_contained(root, path)
return True
@dataclass
class Skill:
"""A skill discovered under `skills/<name>/`."""
name: str
description: str
instructions: str
skill_file: str
files: dict[str, str] = field(default_factory=dict)
@dataclass
class Subagent:
"""A subagent discovered under `subagents/<name>/`."""
name: str
description: str | None
model_id: str | None
instructions: str
tools: dict[str, Any] | None = None
tools_text: str | None = None
extra_files: dict[str, str] = field(default_factory=dict)
"""Subagent-local skills, keyed by path under `subagents/<name>/`."""
@dataclass
class Project:
"""In-memory view of the on-disk project."""
root: Path
agent_id: str | None
name: str
description: str | None
model: str | None
system_prompt: str
runtime: dict[str, Any] | None
backend: dict[str, Any] | None
permissions: dict[str, Any] | None
extras: dict[str, Any] | None
tools: dict[str, Any] | None
tools_text: str | None
skills: list[Skill]
subagents: list[Subagent]
@classmethod
def load(cls, root: Path) -> Project:
"""Read the project at *root*; raise `ProjectError` on any problem."""
root = root.resolve()
if not root.is_dir():
msg = f"Project root is not a directory: {root}"
raise ProjectError(msg)
_check_no_legacy_files(root)
agent_data = _read_agent_json(root)
system_prompt = _read_agents_md(root)
tools, tools_text = _read_tools_json(root)
return cls(
root=root,
agent_id=agent_data.get("agent_id"),
name=agent_data["name"],
description=agent_data.get("description"),
model=agent_data.get("model"),
system_prompt=system_prompt,
runtime=agent_data.get("runtime"),
backend=agent_data.get("backend"),
permissions=agent_data.get("permissions"),
extras=agent_data.get("extras"),
tools=tools,
tools_text=tools_text,
skills=_read_skills(root),
subagents=_read_subagents(root),
)
def _read_agent_json(root: Path) -> dict[str, Any]:
path = root / _AGENT_JSON
try:
data = json.loads(
_read_project_text(
root,
path,
missing_msg=f"agent.json is required but not found in {root}.",
)
)
except json.JSONDecodeError as exc:
msg = f"Invalid JSON in {path}: {exc}"
raise ProjectError(msg) from exc
if not isinstance(data, dict):
msg = f"{path} must contain a JSON object."
raise ProjectError(msg)
name = data.get("name")
if not isinstance(name, str) or not name.strip():
msg = f"`name` (non-empty string) is required in {path}."
raise ProjectError(msg)
agent_id = data.get("agent_id")
if agent_id is not None:
if not isinstance(agent_id, str) or not agent_id.strip():
msg = f"{path}: `agent_id` must be a non-empty string when provided."
raise ProjectError(msg)
data["agent_id"] = agent_id.strip()
model = data.get("model")
if model is not None:
if not isinstance(model, str) or not model.strip():
msg = f"{path}: `model` must be a non-empty string when provided."
raise ProjectError(msg)
data["model"] = model.strip()
runtime = data.get("runtime")
if runtime is not None:
if not isinstance(runtime, dict):
msg = f"{path}: `runtime` must be an object."
raise ProjectError(msg)
if model is not None:
msg = f"{path}: use either top-level `model` or `runtime`, not both."
raise ProjectError(msg)
backend_type = runtime.get("backend_type")
if backend_type is not None:
msg = (
f"{path}: `runtime.backend_type` is no longer supported. "
'Use top-level `backend`: {"type": "thread_scoped_sandbox"} instead.'
)
raise ProjectError(msg)
backend = data.get("backend")
if backend is not None:
data["backend"] = _normalize_backend(backend, source=path)
permissions = data.get("permissions")
if permissions is not None:
if not isinstance(permissions, dict):
msg = f"{path}: `permissions` must be an object."
raise ProjectError(msg)
if (ident := permissions.get("identity")) and ident not in _VALID_IDENTITY:
msg = f"permissions.identity {ident!r} not in {sorted(_VALID_IDENTITY)}"
raise ProjectError(msg)
if (vis := permissions.get("visibility")) and vis not in _VALID_VISIBILITY:
msg = f"permissions.visibility {vis!r} not in {sorted(_VALID_VISIBILITY)}"
raise ProjectError(msg)
lvl = permissions.get("tenant_access_level")
if lvl and lvl not in _VALID_TENANT_ACCESS:
msg = (
f"permissions.tenant_access_level {lvl!r} not in "
f"{sorted(_VALID_TENANT_ACCESS)}"
)
raise ProjectError(msg)
return data
def _normalize_backend(backend: object, *, source: Path) -> dict[str, Any]:
if not isinstance(backend, dict):
msg = f"{source}: `backend` must be an object."
raise ProjectError(msg)
data = dict(cast("dict[str, Any]", backend))
backend_type = data.get("type")
if backend_type == _LEGACY_BACKEND_TYPE_SANDBOX:
backend_type = _BACKEND_TYPE_THREAD_SCOPED_SANDBOX
data["type"] = backend_type
if backend_type is not None and backend_type not in _VALID_BACKEND_TYPES:
msg = f"backend.type {backend_type!r} not in {sorted(_VALID_BACKEND_TYPES)}"
raise ProjectError(msg)
sandbox = data.get("sandbox")
if sandbox is None:
return data
if backend_type not in _SANDBOX_BACKEND_TYPES:
msg = (
f"{source}: `backend.sandbox` requires `backend.type` to be "
"`thread_scoped_sandbox` or `agent_scoped_sandbox`."
)
raise ProjectError(msg)
data["sandbox"] = _normalize_sandbox_config(sandbox, source=source)
return data
def _normalize_sandbox_config(sandbox: object, *, source: Path) -> dict[str, Any]:
if not isinstance(sandbox, dict):
msg = f"{source}: `backend.sandbox` must be an object."
raise ProjectError(msg)
data = dict(cast("dict[str, Any]", sandbox))
policies = data.get("policy_ids")
if policies is not None and (
not isinstance(policies, list)
or not all(isinstance(policy, str) for policy in policies)
):
msg = f"{source}: `backend.sandbox.policy_ids` must be an array of strings."
raise ProjectError(msg)
for key in _SANDBOX_INTEGER_FIELDS:
value = data.get(key)
if value is not None and (
not isinstance(value, int) or isinstance(value, bool)
):
msg = f"{source}: `backend.sandbox.{key}` must be an integer."
raise ProjectError(msg)
return data
def _read_agents_md(root: Path) -> str:
path = root / _AGENTS_MD
return _read_project_text(
root,
path,
missing_msg=f"AGENTS.md is required but not found in {root}.",
)
def _read_tools_json(root: Path) -> tuple[dict[str, Any] | None, str | None]:
path = root / _TOOLS_JSON
if not _is_project_file(root, path):
return None, None
text = _read_project_text(root, path)
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
msg = f"Invalid JSON in {path}: {exc}"
raise ProjectError(msg) from exc
if not isinstance(data, dict):
msg = f"{path} must contain a JSON object."
raise ProjectError(msg)
tools = data.get("tools")
if not isinstance(tools, list):
msg = f"{path}: `tools` must be an array."
raise ProjectError(msg)
for idx, tool in enumerate(tools):
if not isinstance(tool, dict):
msg = f"{path}: tools[{idx}] must be an object."
raise ProjectError(msg)
if not isinstance(tool.get("name"), str) or not tool["name"]:
msg = f"{path}: tools[{idx}].name is required."
raise ProjectError(msg)
url_val = tool.get("mcp_server_url")
if not isinstance(url_val, str) or not url_val:
msg = f"{path}: tools[{idx}].mcp_server_url is required."
raise ProjectError(msg)
interrupt_config = data.get("interrupt_config")
if interrupt_config is not None and not isinstance(interrupt_config, dict):
msg = f"{path}: `interrupt_config` must be an object."
raise ProjectError(msg)
return data, text
_FRONTMATTER_RE = _re.compile(r"^---\n(?P<fm>.*?)\n---\n(?P<body>.*)$", _re.DOTALL)
def _parse_skill_frontmatter(text: str, *, source: Path) -> tuple[dict[str, str], str]:
match = _FRONTMATTER_RE.match(text)
if not match:
msg = f"{source}: YAML frontmatter (--- ... ---) is required."
raise ProjectError(msg)
frontmatter: dict[str, str] = {}
for line in match.group("fm").splitlines():
if not line.strip() or ":" not in line:
continue
key, _, value = line.partition(":")
frontmatter[key.strip()] = value.strip().strip('"').strip("'")
if "name" not in frontmatter or not frontmatter["name"]:
msg = f"{source}: frontmatter is missing required key `name`."
raise ProjectError(msg)
if "description" not in frontmatter or not frontmatter["description"]:
msg = f"{source}: frontmatter is missing required key `description`."
raise ProjectError(msg)
return frontmatter, match.group("body").strip()
def _read_skills(root: Path) -> list[Skill]:
skills_dir = root / _SKILLS_DIR
if not _is_project_dir(root, skills_dir):
return []
result: list[Skill] = []
seen: set[str] = set()
for entry in sorted(skills_dir.iterdir()):
if entry.name.startswith("."):
continue
if entry.is_symlink():
raise _symlink_error(entry)
if not entry.is_dir():
continue
_ensure_project_contained(root, entry)
skill_file = entry / _SKILL_FILE
skill_text = _read_project_text(
root,
skill_file,
missing_msg=f"{entry}: missing SKILL.md",
container=entry,
)
frontmatter, body = _parse_skill_frontmatter(skill_text, source=skill_file)
name = frontmatter["name"]
if name in seen:
msg = f"duplicate skill name {name!r} in {skills_dir}"
raise ProjectError(msg)
seen.add(name)
files: dict[str, str] = {}
for child in sorted(entry.rglob("*")):
if child.is_symlink():
raise _symlink_error(child)
if not child.is_file() or child.name == _SKILL_FILE:
continue
rel = child.relative_to(entry)
if any(part.startswith(".") for part in rel.parts):
continue
files[rel.as_posix()] = _read_project_text(root, child, container=entry)
result.append(
Skill(
name=name,
description=frontmatter["description"],
instructions=body,
skill_file=skill_text,
files=files,
)
)
return result
def _read_subagents(root: Path) -> list[Subagent]:
sa_dir = root / _SUBAGENTS_DIR
if not _is_project_dir(root, sa_dir):
return []
result: list[Subagent] = []
seen: set[str] = set()
for entry in sorted(sa_dir.iterdir()):
if entry.name.startswith("."):
continue
if entry.is_symlink():
raise _symlink_error(entry)
if not entry.is_dir():
continue
_ensure_project_contained(root, entry)
agent_json = entry / _AGENT_JSON
agents_md = entry / _AGENTS_MD
try:
data = json.loads(
_read_project_text(
root,
agent_json,
missing_msg=f"{entry}: missing agent.json",
container=entry,
)
)
except json.JSONDecodeError as exc:
msg = f"Invalid JSON in {agent_json}: {exc}"
raise ProjectError(msg) from exc
if not isinstance(data, dict):
msg = f"{agent_json} must contain a JSON object."
raise ProjectError(msg)
name = entry.name
key = name.lower()
if key in seen:
msg = f"duplicate subagent name {name!r} (case-insensitive)"
raise ProjectError(msg)
seen.add(key)
tools, tools_text = _read_tools_json(entry)
extra_files: dict[str, str] = {}
local_skills_dir = entry / _SKILLS_DIR
if _is_project_dir(root, local_skills_dir):
for f in sorted(local_skills_dir.rglob("*")):
if f.is_symlink():
raise _symlink_error(f)
if not f.is_file():
continue
rel = f.relative_to(entry)
if any(part.startswith(".") for part in rel.parts):
continue
extra_files[rel.as_posix()] = _read_project_text(
root,
f,
container=entry,
)
result.append(
Subagent(
name=name,
description=data.get("description"),
model_id=data.get("model_id"),
instructions=_read_project_text(
root,
agents_md,
missing_msg=f"{entry}: missing AGENTS.md",
container=entry,
),
tools=tools,
tools_text=tools_text,
extra_files=extra_files,
)
)
return result
_LEGACY_TOML_HINT = """\
Found legacy deepagents.toml in {root}. The migrated `deepagents deploy`
expects the new layout. Quick mapping:
[agent] → agent.json (top-level keys: name, description)
[agent].model → agent.json model
[sandbox].scope → agent.json backend.type ("thread_scoped_sandbox")
[auth], [memories], [frontend]→ remove; managed by the platform now
Then run `deepagents init --force` to refresh scaffolding or migrate by hand.
"""
_LEGACY_MCP_HINT = """\
Found legacy `mcp.json` in {root}. MCP servers are now workspace-level resources:
deepagents mcp-servers add --url <url> --header KEY=VALUE [--name <name>]
Then reference the server in tools.json by mcp_server_url.
"""
def _check_no_legacy_files(root: Path) -> None:
if (root / "deepagents.toml").is_file():
raise ProjectError(_LEGACY_TOML_HINT.format(root=root))
if (root / "mcp.json").is_file():
raise ProjectError(_LEGACY_MCP_HINT.format(root=root))
+111
View File
@@ -0,0 +1,111 @@
"""User-local deploy state persisted outside the project checkout.
Tracks the managed agent ID returned by the last successful deploy so that
subsequent runs of `deepagents deploy` issue `PATCH` rather than `POST`. Also
caches the `{mcp_server_url → mcp_server_id}` map to skip the list-call on
every deploy.
State is keyed by project root and API endpoint under `~/.deepagents/`.
Repository-local state is intentionally ignored because cloned projects can be
attacker controlled and must not silently steer authenticated deployments.
"""
from __future__ import annotations
import datetime as _dt
import hashlib
import json
from dataclasses import dataclass, field
from pathlib import Path
try:
_STATE_ROOT = Path.home() / ".deepagents" / "deployments"
except RuntimeError:
_STATE_ROOT = Path("/nonexistent/.deepagents/deployments")
_SCHEMA_VERSION = 1
def _state_path(project_root: Path, endpoint: str) -> Path:
root = str(project_root.resolve())
normalized_endpoint = endpoint.rstrip("/")
material = json.dumps(
{"endpoint": normalized_endpoint, "project_root": root},
sort_keys=True,
separators=(",", ":"),
)
key = hashlib.sha256(material.encode("utf-8")).hexdigest()
return _STATE_ROOT / f"{key}.json"
@dataclass
class State:
"""In-memory view of user-local deploy state.
Use `State.load(project_root, endpoint=...)` to read; mutate fields freely;
call `state.save(...)` to persist.
"""
project_root: Path
state_path: Path
agent_id: str | None = None
revision: str | None = None
endpoint: str | None = None
last_deployed_at: str | None = None
mcp_servers: dict[str, str] = field(default_factory=dict)
@classmethod
def load(cls, project_root: Path, *, endpoint: str, reset: bool = False) -> State:
"""Load state from the user-local deploy cache.
Returns an empty state if the file does not exist. With `reset=True`,
deletes the file (if present) before returning the empty state.
"""
project_root = project_root.resolve()
endpoint = endpoint.rstrip("/")
path = _state_path(project_root, endpoint)
if reset and path.exists():
path.unlink()
if not path.is_file():
return cls(project_root=project_root, state_path=path, endpoint=endpoint)
data = json.loads(path.read_text(encoding="utf-8"))
version = data.get("schema_version")
if version != _SCHEMA_VERSION:
msg = (
f"Unknown schema_version {version!r} in {path}. "
f"Expected {_SCHEMA_VERSION}. Delete the file to start fresh."
)
raise ValueError(msg)
return cls(
project_root=project_root,
state_path=path,
agent_id=data.get("agent_id"),
revision=data.get("revision"),
endpoint=endpoint,
last_deployed_at=data.get("last_deployed_at"),
mcp_servers=dict(data.get("mcp_servers") or {}),
)
def save(self, *, agent_id: str | None = None, revision: str | None = None) -> None:
"""Persist state, optionally updating agent_id / revision in the same call."""
if agent_id is not None:
self.agent_id = agent_id
if revision is not None:
self.revision = revision
self.last_deployed_at = _dt.datetime.now(_dt.UTC).isoformat(timespec="seconds")
self.state_path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"schema_version": _SCHEMA_VERSION,
"project_root": str(self.project_root),
"endpoint": self.endpoint,
"agent_id": self.agent_id,
"revision": self.revision,
"last_deployed_at": self.last_deployed_at,
"mcp_servers": self.mcp_servers,
}
self.state_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
def clear_agent(self) -> None:
"""Remove agent_id / revision from state and persist."""
self.agent_id = None
self.revision = None
self.save()
File diff suppressed because it is too large Load Diff
+20 -14
View File
@@ -1,9 +1,11 @@
"""Entry point for the `deepagents` CLI.
This CLI exposes the deployment-oriented commands for Managed Deep Agents:
`init`, `deploy`, `agents`, and `mcp-servers`. Bare invocations print a
deprecation notice and exit non-zero.
As of `deepagents-cli==0.1.0` the interactive Textual REPL has moved to the
[`deepagents-code`](https://pypi.org/project/deepagents-code/) package. This
CLI now exposes only the deployment-oriented commands: `init`, `dev`, and
`deploy`. Bare invocations print a deprecation notice and exit non-zero.
[`deepagents-code`](https://pypi.org/project/deepagents-code/) package.
"""
from __future__ import annotations
@@ -26,8 +28,8 @@ _REPL_REDIRECT_MESSAGE = (
"Install it with:\n\n"
" pip install deepagents-code\n\n"
"Then run `deepagents-code` to start an interactive session.\n\n"
"The `deepagents` CLI now only provides the `init`, `dev`, and `deploy` "
"subcommands."
"The `deepagents` CLI now only provides `init`, `deploy`, "
"`agents`, and `mcp-servers`. "
)
@@ -78,11 +80,11 @@ def _make_help_action(
def _build_parser() -> argparse.ArgumentParser:
"""Build the top-level argparse parser for the deploy/dev/init CLI.
"""Build the top-level argparse parser for the deploy/init CLI.
Returns:
Configured `ArgumentParser` with `init`, `dev`, and `deploy`
subparsers registered.
Configured `ArgumentParser` with `init`, `deploy`, `agents`, and
`mcp-servers` subparsers registered.
"""
from deepagents_cli.deploy import setup_deploy_parsers
@@ -139,8 +141,8 @@ def cli_main() -> None:
if sys.platform == "darwin":
# gRPC (pulled in transitively by LangSmith deps) crashes on macOS
# when the process forks after gRPC has been initialized. Disable
# fork support to avoid the abort; the current deploy/dev paths
# don't fork, so disabling fork support is a safe default —
# fork support to avoid the abort; the current deploy/agents/mcp-servers
# paths don't fork, so disabling fork support is a safe default —
# reconsider this env var if a future subcommand spawns workers.
os.environ.setdefault("GRPC_ENABLE_FORK_SUPPORT", "0")
@@ -151,14 +153,18 @@ def cli_main() -> None:
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 == "agents":
from deepagents_cli.deploy import execute_agents_command
execute_agents_command(args)
elif args.command == "mcp-servers":
from deepagents_cli.deploy import execute_mcp_servers_command
execute_mcp_servers_command(args)
else:
sys.stderr.write(_REPL_REDIRECT_MESSAGE + "\n")
raise SystemExit(1)
@@ -1,2 +1,8 @@
OPENAI_API_KEY=
# Required: authenticates the deepagents CLI against Managed Deep Agents.
LANGSMITH_API_KEY=
# The model runs on the managed platform, so provider keys are configured there,
# not here. The tavily_search tool in tools.json requires a Tavily MCP server
# registered once per workspace:
# deepagents mcp-servers add --url https://mcp.tavily.com/mcp/ --name tavily \
# --header Authorization="Bearer <TAVILY_API_KEY>"
@@ -0,0 +1,21 @@
# Content Writer
You are a content writer. You research topics with web search, then draft
clear, engaging content tailored to the requested format and audience.
## Workflow
1. Clarify the goal: format (blog post, social post), audience, and tone.
2. Use the `tavily_search` tool to gather current, credible sources.
3. Draft the content, then cite the sources you used.
## Skills
- Use the **blog-post** skill when writing long-form articles.
- Use the **social-media** skill when writing posts for social platforms.
## Guidelines
- Lead with a hook and keep a clear through-line.
- Prefer primary and recent sources; cite the URLs you relied on.
- Match the requested platform's tone and length.
@@ -0,0 +1,8 @@
{
"name": "content-writer",
"description": "Researches topics and drafts blog posts and social media content.",
"model": "anthropic:claude-sonnet-4-6",
"backend": {
"type": "thread_scoped_sandbox"
}
}
@@ -0,0 +1,13 @@
{
"tools": [
{
"name": "tavily_search",
"mcp_server_url": "https://mcp.tavily.com/mcp/",
"mcp_server_name": "tavily",
"display_name": "tavily_search"
}
],
"interrupt_config": {
"https://mcp.tavily.com/mcp/::tavily_search::tavily": true
}
}
@@ -1,15 +0,0 @@
---
name: context
description: Company and product context for content creation
permissions: read
---
# Company Context
Update this file with your company's specific context:
- Company name and description
- Product names and key features
- Target audience details
- Competitor landscape
- Key differentiators
@@ -1,11 +0,0 @@
---
name: preferences
description: User content preferences and style overrides
permissions: read-write
---
# Content Preferences
No preferences set yet. The agent will update this file as it learns
about the user's preferred topics, tone adjustments, and formatting
choices.
-1
View File
@@ -1 +0,0 @@
24
-15
View File
@@ -1,15 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/app/logo-light.svg" media="(prefers-color-scheme: light)" />
<link rel="icon" type="image/svg+xml" href="/app/logo-dark.svg" media="(prefers-color-scheme: dark)" />
<title>Deep Agent</title>
<script>window.__DEEPAGENTS_CONFIG__ = {"__PLACEHOLDER__":true};</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-6142
View File
File diff suppressed because it is too large Load Diff
-34
View File
@@ -1,34 +0,0 @@
{
"name": "@deepagents/frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@clerk/clerk-react": "^5.0.0",
"@clerk/themes": "^2.4.57",
"@langchain/langgraph-sdk": "^1.7.2",
"@langchain/react": "^0.3.3",
"@supabase/auth-ui-react": "^0.4.7",
"@supabase/auth-ui-shared": "^0.1.8",
"@supabase/supabase-js": "^2.99.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"streamdown": "^2.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}
-5
View File
@@ -1,5 +0,0 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
-12
View File
@@ -1,12 +0,0 @@
<svg width="98" height="98" viewBox="0 0 98 98" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="97.2502" height="97.2502" rx="19.4855" fill="#7FC8FF"/>
<g clip-path="url(#clip0_195_1432)">
<path d="M72.5361 42.2004V22.0426H51.7354L51.7354 22.5212C62.5113 22.7991 71.2917 31.6243 72.1695 42.2004H72.5361Z" fill="#030710"/>
<path d="M49.223 22.0428H24.8759V63.0891C24.8759 70.6689 30.7215 75.3844 40.133 75.3844H72.5471V45.3962H49.223V22.0428Z" fill="#030710"/>
</g>
<defs>
<clipPath id="clip0_195_1432">
<rect width="47.6713" height="53.3416" fill="white" transform="translate(24.8799 22.0442)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 636 B

-12
View File
@@ -1,12 +0,0 @@
<svg width="98" height="98" viewBox="0 0 98 98" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="97.2502" height="97.2502" rx="19.4855" fill="#030710"/>
<g clip-path="url(#clip0_195_1437)">
<path d="M72.5361 42.2004V22.0426H51.7354L51.7354 22.5212C62.5113 22.7991 71.2917 31.6243 72.1695 42.2004H72.5361Z" fill="#7FC8FF"/>
<path d="M49.223 22.0428H24.8759V63.0891C24.8759 70.6689 30.7215 75.3844 40.133 75.3844H72.5471V45.3962H49.223V22.0428Z" fill="#7FC8FF"/>
</g>
<defs>
<clipPath id="clip0_195_1437">
<rect width="47.6713" height="53.3416" fill="white" transform="translate(24.8799 22.0442)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 636 B

-342
View File
@@ -1,342 +0,0 @@
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Client } from "@langchain/langgraph-sdk";
import type { BaseMessage } from "@langchain/core/messages";
import { useAuthAdapter } from "./auth/loader";
import type { AuthAdapter } from "./auth/types";
import { ASSISTANT_ID } from "./constants";
import AppHeader from "./components/AppHeader";
import ThreadPicker from "./components/ThreadPicker";
import MessageList from "./components/MessageList";
import FilesPanel from "./components/FilePanels";
import TodosPanel from "./components/TodosPanel";
import { useAgentStream } from "./lib/stream";
export default function App() {
return (
<Suspense fallback={<SplashScreen />}>
<AppWithAdapter />
</Suspense>
);
}
function AppWithAdapter() {
const adapter = useAuthAdapter();
return (
<adapter.Provider>
<Gate adapter={adapter} />
</adapter.Provider>
);
}
function Gate({ adapter }: { adapter: AuthAdapter }) {
const session = adapter.useSession();
if (session.status === "loading") return <SplashScreen />;
if (session.status === "signed-out") {
const { AuthUI } = adapter;
return <AuthUI />;
}
return (
<AuthenticatedApp
accessToken={session.accessToken}
userEmail={session.userEmail}
userIdentity={session.userIdentity}
isAnonymous={session.isAnonymous}
onSignOut={session.signOut}
/>
);
}
function SplashScreen() {
return <div className="min-h-dvh bg-[var(--background)]" />;
}
function AuthenticatedApp({
accessToken,
userEmail,
userIdentity,
isAnonymous,
onSignOut,
}: {
accessToken: string;
userEmail: string | null;
userIdentity: string;
isAnonymous: boolean;
onSignOut: () => Promise<void>;
}) {
return (
<NewChatApp
accessToken={accessToken}
userEmail={userEmail}
userIdentity={userIdentity}
isAnonymous={isAnonymous}
onSignOut={onSignOut}
/>
);
}
function NewChatApp({
accessToken,
userEmail,
userIdentity,
isAnonymous,
onSignOut,
}: {
accessToken: string;
userEmail: string | null;
userIdentity: string;
isAnonymous: boolean;
onSignOut: () => Promise<void>;
}) {
const [input, setInput] = useState("");
const [threadId, setThreadId] = useState<string | null>(null);
const [showTodos, setShowTodos] = useState(false);
const [showFiles, setShowFiles] = useState(false);
const bottomRef = useRef<HTMLDivElement | null>(null);
const mainRef = useRef<HTMLDivElement | null>(null);
const isNearBottom = useRef(true);
const rafId = useRef(0);
// When the user submits to a not-yet-created thread, stash the first
// message's text here. The SDK assigns a thread_id asynchronously via
// onThreadId — once we have it, we write the title as thread metadata so
// the picker shows "What can you help..." instead of "b6cde91f...".
const pendingTitleRef = useRef<string | null>(null);
const defaultHeaders = useMemo(
() => ({ Authorization: `Bearer ${accessToken}` }),
[accessToken],
);
const client = useMemo(
() =>
new Client({
apiUrl: window.location.origin,
defaultHeaders,
}),
[defaultHeaders],
);
const handleThreadId = useCallback(
(id: string | null) => {
setThreadId(id);
if (id == null) return;
const metadata: Record<string, string> = {};
if (pendingTitleRef.current) {
metadata.title = pendingTitleRef.current;
pendingTitleRef.current = null;
}
if (isAnonymous) {
metadata.dap_anon_id = userIdentity;
}
if (Object.keys(metadata).length > 0) {
client.threads.update(id, { metadata }).catch((err) => {
console.warn("Failed to write thread metadata", err);
});
}
},
[client, isAnonymous, userIdentity],
);
const stream = useAgentStream({
apiUrl: window.location.origin,
assistantId: ASSISTANT_ID,
messagesKey: "messages",
threadId,
onThreadId: handleThreadId,
filterSubagentMessages: true,
// Coalesce token deltas so Streamdown's word-level fade animation has
// time to play between updates. Dropping this to 0/false re-renders
// per token (jumpy); pushing above ~150 makes the stream feel laggy.
throttle: 100,
defaultHeaders,
});
const { messages, isLoading, error, values } = stream;
const files = values.files ?? {};
const todos = values.todos ?? [];
const todoCount = todos.length;
const fileCount = Object.keys(files).length;
const handleScroll = useCallback(() => {
const element = mainRef.current;
if (!element) return;
isNearBottom.current =
element.scrollHeight - element.scrollTop - element.clientHeight < 80;
}, []);
useEffect(() => {
if (!isNearBottom.current) return;
cancelAnimationFrame(rafId.current);
rafId.current = requestAnimationFrame(() => {
const element = mainRef.current;
if (element) element.scrollTop = element.scrollHeight;
});
}, [messages]);
useEffect(() => () => cancelAnimationFrame(rafId.current), []);
const submitInput = useCallback(async () => {
const text = input.trim();
if (!text || isLoading) return;
// Capture the first-message text before we submit. If the thread is
// brand new (threadId is null), handleThreadId will write this as the
// title once the SDK assigns the id.
if (threadId == null) {
pendingTitleRef.current = text.slice(0, 60);
}
setInput("");
// The wire protocol expects {type, content} dicts, not langchain-serialized
// class instances; cast through BaseMessage so AgentState.messages typing
// (BaseMessage[]) is satisfied without sending the lc/id/kwargs envelope.
await stream.submit(
{
messages: [
{ type: "human", content: text } as unknown as BaseMessage,
],
},
{ streamSubgraphs: true },
);
}, [input, isLoading, stream, threadId]);
const threadPicker = (
<ThreadPicker
currentThreadId={threadId}
onSelect={setThreadId}
accessToken={accessToken}
userIdentity={userIdentity}
isAnonymous={isAnonymous}
/>
);
return (
<div className="flex h-dvh flex-col bg-[var(--background)]">
<AppHeader
userEmail={userEmail}
isAnonymous={isAnonymous}
onSignOut={onSignOut}
threadPicker={threadPicker}
/>
<MessageList
bottomRef={bottomRef}
error={error}
isLoading={isLoading}
mainRef={mainRef}
messages={messages}
onScroll={handleScroll}
onSuggestionSelect={setInput}
stream={stream}
/>
{showTodos && todoCount > 0 && (
<div className="border-t border-[var(--border)]">
<TodosPanel todos={todos} />
</div>
)}
{showFiles && fileCount > 0 && (
<div className="border-t border-[var(--border)]">
<FilesPanel files={files} />
</div>
)}
<footer className="bg-[var(--background)] px-2 py-3 sm:px-4 sm:py-4">
<form
onSubmit={(event) => {
event.preventDefault();
void submitInput();
}}
className="mx-auto max-w-4xl"
>
<div className="composer">
<textarea
value={input}
onChange={(event) => {
setInput(event.target.value);
event.target.style.height = "auto";
event.target.style.height = `${Math.min(event.target.scrollHeight, 200)}px`;
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void submitInput();
}
}}
placeholder="Send a message..."
rows={1}
autoFocus
className="min-h-[44px] max-h-[200px] w-full resize-none bg-transparent px-4 pt-3 pb-1 text-sm outline-none placeholder:text-[var(--muted-foreground)]"
/>
<div className="flex items-center justify-between px-4 pb-3">
<div className="flex items-center gap-1.5">
{todoCount > 0 && (
<button
type="button"
onClick={() => setShowTodos((v) => !v)}
className={`flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium transition-colors ${
showTodos
? "bg-[var(--accent-bg)] text-[var(--foreground)]"
: "text-[var(--muted-foreground)] hover:bg-[var(--muted)]"
}`}
>
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 20h9" />
<path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838.838-2.872a2 2 0 0 1 .506-.855z" />
</svg>
Tasks
<span className="rounded-full bg-[var(--primary)] px-1.5 py-0.5 text-[9px] font-medium text-[var(--primary-foreground)]">
{todoCount}
</span>
</button>
)}
{fileCount > 0 && (
<button
type="button"
onClick={() => setShowFiles((v) => !v)}
className={`flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium transition-colors ${
showFiles
? "bg-[var(--accent-bg)] text-[var(--foreground)]"
: "text-[var(--muted-foreground)] hover:bg-[var(--muted)]"
}`}
>
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
Files
<span className="rounded-full bg-[var(--primary)] px-1.5 py-0.5 text-[9px] font-medium text-[var(--primary-foreground)]">
{fileCount}
</span>
</button>
)}
</div>
<button
type={isLoading ? "button" : "submit"}
onClick={isLoading ? () => void stream.stop() : undefined}
disabled={!isLoading && !input.trim()}
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition-all disabled:opacity-30 ${
isLoading
? "bg-red-500 text-white hover:bg-red-600"
: "bg-[var(--accent)] text-[var(--accent-foreground)] hover:bg-[var(--accent-hover)]"
}`}
>
{isLoading ? (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="6" width="12" height="12" rx="1" />
</svg>
) : (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m5 12 7-7 7 7" />
<path d="M12 19V5" />
</svg>
)}
</button>
</div>
</div>
</form>
</footer>
</div>
);
}
-66
View File
@@ -1,66 +0,0 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
export type Theme = "light" | "dark";
const STORAGE_KEY = "deepagents-theme";
interface ThemeCtx {
theme: Theme;
setTheme: (t: Theme) => void;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeCtx | null>(null);
function detectInitialTheme(): Theme {
if (typeof window === "undefined") return "light";
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
function applyTheme(theme: Theme): void {
document.documentElement.dataset.theme = theme;
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(detectInitialTheme);
useEffect(() => {
applyTheme(theme);
try {
window.localStorage.setItem(STORAGE_KEY, theme);
} catch {
// localStorage may throw in private-browsing modes.
}
}, [theme]);
const setTheme = useCallback((t: Theme) => setThemeState(t), []);
const toggleTheme = useCallback(
() => setThemeState((prev) => (prev === "dark" ? "light" : "dark")),
[],
);
const value = useMemo<ThemeCtx>(
() => ({ theme, setTheme, toggleTheme }),
[theme, setTheme, toggleTheme],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeCtx {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme() called outside ThemeProvider");
return ctx;
}
-45
View File
@@ -1,45 +0,0 @@
import type { ReactNode } from "react";
import type { AuthAdapter, SessionState } from "./types";
const COOKIE_NAME = "dap_anon_id";
function readCookie(name: string): string | null {
if (typeof document === "undefined") return null;
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
function writeCookie(name: string, value: string): void {
if (typeof document === "undefined") return;
// 1 year, path=/, SameSite=Lax. Not Secure (must work over http on localhost).
// Not httpOnly — JS needs to read it.
document.cookie = `${name}=${encodeURIComponent(value)}; max-age=31536000; path=/; SameSite=Lax`;
}
function getOrMintAnonId(): string {
if (typeof crypto === "undefined") return "anon";
const existing = readCookie(COOKIE_NAME);
if (existing) return existing;
const id = crypto.randomUUID();
writeCookie(COOKIE_NAME, id);
return id;
}
const session: SessionState = {
status: "signed-in",
accessToken: "",
userIdentity: getOrMintAnonId(),
userEmail: null,
isAnonymous: true,
signOut: async () => {},
};
const adapter: AuthAdapter = {
Provider: ({ children }: { children: ReactNode }) => <>{children}</>,
useSession: () => session,
AuthUI: () => null,
};
export default adapter;
-138
View File
@@ -1,138 +0,0 @@
import {
ClerkProvider,
SignIn,
SignUp,
useAuth,
useUser,
} from "@clerk/clerk-react";
import { dark } from "@clerk/themes";
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { getRuntimeConfig } from "../runtimeConfig";
import { useTheme } from "../ThemeProvider";
import type { AuthAdapter, SessionState } from "./types";
type Ctx = { state: SessionState };
const ClerkCtx = createContext<Ctx | null>(null);
function ClerkSessionBridge({ children }: { children: ReactNode }) {
const { isLoaded, isSignedIn, getToken, signOut } = useAuth();
const { user } = useUser();
const [accessToken, setAccessToken] = useState<string | null>(null);
const userEmail = user?.primaryEmailAddress?.emailAddress ?? null;
useEffect(() => {
let active = true;
if (!isLoaded || !isSignedIn) {
setAccessToken(null);
return;
}
// Clerk session JWTs default to a ~60s TTL; refresh well under that so the
// LangGraph SDK never gets handed an expired token.
const refresh = async () => {
try {
const t = await getToken({ skipCache: true });
if (active && t) setAccessToken(t);
} catch (err) {
if (active) console.warn("Clerk getToken failed", err);
}
};
void refresh();
const interval = window.setInterval(refresh, 45_000);
return () => {
active = false;
window.clearInterval(interval);
};
}, [getToken, isLoaded, isSignedIn, user?.id]);
const value = useMemo<Ctx>(() => {
let state: SessionState;
if (!isLoaded) state = { status: "loading" };
else if (!isSignedIn) state = { status: "signed-out" };
else if (!accessToken) state = { status: "loading" };
else {
state = {
status: "signed-in",
accessToken,
userIdentity: user?.id ?? "",
userEmail,
isAnonymous: false,
signOut: async () => {
await signOut();
},
};
}
return { state };
}, [isLoaded, isSignedIn, accessToken, user?.id, userEmail, signOut]);
return <ClerkCtx.Provider value={value}>{children}</ClerkCtx.Provider>;
}
function ClerkAdapterProvider({ children }: { children: ReactNode }) {
const cfg = getRuntimeConfig();
const { theme } = useTheme();
if (cfg.auth !== "clerk") {
throw new Error("ClerkProvider mounted with non-clerk runtime config");
}
return (
<ClerkProvider
publishableKey={cfg.clerkPublishableKey}
appearance={theme === "dark" ? { baseTheme: dark } : undefined}
// The SPA is mounted at /app/; Clerk's default of "/" lands on the
// LangGraph root (404/JSON). Pin post-auth redirects back into the app.
afterSignOutUrl="/app/"
signInFallbackRedirectUrl="/app/"
signUpFallbackRedirectUrl="/app/"
>
<ClerkSessionBridge>{children}</ClerkSessionBridge>
</ClerkProvider>
);
}
function useSession(): SessionState {
const ctx = useContext(ClerkCtx);
if (!ctx) {
throw new Error("useSession() called outside ClerkAdapterProvider");
}
return ctx.state;
}
function ClerkAuthUI() {
const [mode, setMode] = useState<"signin" | "signup">("signin");
return (
<div className="min-h-dvh flex items-center justify-center bg-[var(--background)] p-4">
<div className="flex flex-col items-center gap-4">
{mode === "signin" ? (
<SignIn routing="virtual" />
) : (
<SignUp routing="virtual" />
)}
<button
type="button"
className="text-xs text-[var(--muted-foreground)] hover:underline"
onClick={() => setMode(mode === "signin" ? "signup" : "signin")}
>
{mode === "signin"
? "Need an account? Sign up"
: "Already have an account? Sign in"}
</button>
</div>
</div>
);
}
const adapter: AuthAdapter = {
Provider: ClerkAdapterProvider,
useSession,
AuthUI: ClerkAuthUI,
};
export default adapter;
-26
View File
@@ -1,26 +0,0 @@
import { use } from "react";
import { getRuntimeConfig } from "../runtimeConfig";
import type { AuthAdapter } from "./types";
// Module-scope cache so `use()` retries during a render pass return the same promise.
let _cache: Promise<AuthAdapter> | null = null;
export function loadAuth(): Promise<AuthAdapter> {
if (_cache) return _cache;
const cfg = getRuntimeConfig();
_cache = (async () => {
const mod =
cfg.auth === "supabase"
? await import("./supabase")
: cfg.auth === "clerk"
? await import("./clerk")
: await import("./anonymous");
return mod.default;
})();
return _cache;
}
export function useAuthAdapter(): AuthAdapter {
return use(loadAuth());
}
-236
View File
@@ -1,236 +0,0 @@
import { Auth } from "@supabase/auth-ui-react";
import { ThemeSupa } from "@supabase/auth-ui-shared";
import { createClient, type Session, type SupabaseClient } from "@supabase/supabase-js";
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { getRuntimeConfig } from "../runtimeConfig";
import { useTheme } from "../ThemeProvider";
import type { AuthAdapter, SessionState } from "./types";
type Ctx = {
supabase: SupabaseClient;
state: SessionState;
// While recoveryMode is true, `state.status` is forced to "signed-out"
// so the update-password view stays mounted even if Supabase has minted
// a short-lived session from the recovery token.
recoveryMode: boolean;
clearRecoveryMode: () => void;
};
const SupabaseCtx = createContext<Ctx | null>(null);
function detectRecoveryInUrl(): boolean {
if (typeof window === "undefined") return false;
// Supabase versions differ between `type=recovery` and `recovery_type=`
// in the URL fragment — accept either.
const hash = window.location.hash;
return hash.includes("type=recovery") || hash.includes("recovery_type=");
}
function SupabaseProvider({ children }: { children: ReactNode }) {
const cfg = getRuntimeConfig();
if (cfg.auth !== "supabase") {
throw new Error("SupabaseProvider mounted with non-supabase runtime config");
}
const supabase = useMemo(
() => createClient(cfg.supabaseUrl, cfg.supabaseAnonKey),
[cfg.supabaseUrl, cfg.supabaseAnonKey],
);
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
// Must be set before any effect runs: `createClient({ detectSessionInUrl: true })`
// parses the hash synchronously and emits PASSWORD_RECOVERY before any
// listener can attach. Detecting from the URL directly is the only reliable path.
const [recoveryMode, setRecoveryMode] = useState<boolean>(detectRecoveryInUrl);
useEffect(() => {
let active = true;
supabase.auth
.getSession()
.then(({ data }) => {
if (!active) return;
setSession(data.session);
setLoading(false);
})
.catch((err) => {
if (!active) return;
console.error("Failed to load Supabase session", err);
setLoading(false);
});
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, nextSession) => {
if (!active) return;
setSession(nextSession);
setLoading(false);
// Cross-tab: another tab enters recovery, mirror that here.
if (event === "PASSWORD_RECOVERY") {
setRecoveryMode(true);
}
// updateUser({ password }) succeeded — recovery flow is complete.
if (event === "USER_UPDATED") {
setRecoveryMode(false);
}
},
);
return () => {
active = false;
subscription.unsubscribe();
};
}, [supabase]);
const baseState: SessionState = loading
? { status: "loading" }
: session
? {
status: "signed-in",
accessToken: session.access_token,
userIdentity: session.user.id,
userEmail: session.user.email ?? null,
isAnonymous: false,
signOut: async () => {
await supabase.auth.signOut();
},
}
: { status: "signed-out" };
const state: SessionState = recoveryMode
? { status: "signed-out" }
: baseState;
const value = useMemo<Ctx>(
() => ({
supabase,
state,
recoveryMode,
clearRecoveryMode: () => setRecoveryMode(false),
}),
[supabase, state, recoveryMode],
);
return <SupabaseCtx.Provider value={value}>{children}</SupabaseCtx.Provider>;
}
function useSupabaseCtx(): Ctx {
const ctx = useContext(SupabaseCtx);
if (!ctx) {
throw new Error("useSession() called outside SupabaseProvider");
}
return ctx;
}
function useSession(): SessionState {
return useSupabaseCtx().state;
}
function SupabaseAuthUI() {
const { supabase, recoveryMode, clearRecoveryMode } = useSupabaseCtx();
const { theme } = useTheme();
// Strip the recovery hash once recovery completes so a refresh doesn't
// re-enter recovery mode.
useEffect(() => {
if (!recoveryMode) {
if (typeof window !== "undefined" && detectRecoveryInUrl()) {
window.history.replaceState(null, "", window.location.pathname + window.location.search);
}
}
}, [recoveryMode]);
return (
<div className="min-h-dvh flex items-center justify-center bg-[var(--background)] p-4">
<div className="flex w-full max-w-sm flex-col gap-4 rounded-xl border border-[var(--border)] bg-[var(--surface)] p-6 shadow-sm">
<div className="flex items-center gap-2">
<img
src={theme === "dark" ? "/app/logo-dark.svg" : "/app/logo-light.svg"}
alt="Deep Agents"
className="h-8 w-8 rounded"
/>
</div>
<Auth
supabaseClient={supabase}
view={recoveryMode ? "update_password" : "sign_in"}
appearance={{
theme: ThemeSupa,
variables: {
default: {
colors: {
brand: "#7fc8ff",
brandAccent: "#99d4ff",
brandButtonText: "#030710",
defaultButtonBackground: "#ffffff",
defaultButtonBackgroundHover: "#f2faff",
defaultButtonBorder: "#b8dfff",
defaultButtonText: "#030710",
dividerBackground: "#e5f4ff",
inputBackground: "#ffffff",
inputBorder: "#b8dfff",
inputBorderHover: "#99d4ff",
inputBorderFocus: "#7fc8ff",
inputText: "#030710",
inputLabelText: "#030710",
inputPlaceholder: "#6b8299",
messageText: "#030710",
messageTextDanger: "#dc2626",
anchorTextColor: "#6b8299",
anchorTextHoverColor: "#030710",
},
},
dark: {
colors: {
brand: "#7fc8ff",
brandAccent: "#99d4ff",
brandButtonText: "#030710",
defaultButtonBackground: "#0a1220",
defaultButtonBackgroundHover: "#111a2b",
defaultButtonBorder: "#1f3a5c",
defaultButtonText: "#fafcff",
dividerBackground: "#1f3a5c",
inputBackground: "#0a1220",
inputBorder: "#1f3a5c",
inputBorderHover: "#7fc8ff",
inputBorderFocus: "#7fc8ff",
inputText: "#fafcff",
inputLabelText: "#fafcff",
inputPlaceholder: "#8a9aac",
messageText: "#fafcff",
messageTextDanger: "#f87171",
anchorTextColor: "#8a9aac",
anchorTextHoverColor: "#fafcff",
},
},
},
}}
providers={[]}
theme={theme}
redirectTo={window.location.origin + "/app/"}
showLinks={!recoveryMode}
/>
{recoveryMode && (
<button
type="button"
onClick={clearRecoveryMode}
className="text-center text-xs text-[var(--muted-foreground)] hover:underline"
>
Cancel back to sign in
</button>
)}
</div>
</div>
);
}
const adapter: AuthAdapter = {
Provider: SupabaseProvider,
useSession,
AuthUI: SupabaseAuthUI,
};
export default adapter;
-22
View File
@@ -1,22 +0,0 @@
import type { ReactNode, ComponentType } from "react";
export type SessionState =
| { status: "loading" }
| { status: "signed-out" }
| {
status: "signed-in";
accessToken: string;
userIdentity: string;
userEmail: string | null;
isAnonymous: boolean;
signOut: () => Promise<void>;
};
export interface AuthAdapter {
/** Provider component that initializes the auth SDK. Wraps the tree. */
Provider: ComponentType<{ children: ReactNode }>;
/** Returns the current session state. Must be called inside `Provider`. */
useSession: () => SessionState;
/** Sign-in / sign-up UI rendered when session is "signed-out". */
AuthUI: ComponentType;
}
@@ -1,70 +0,0 @@
import type { ReactNode } from "react";
import { APP_NAME, APP_SUBTITLE } from "../constants";
import { useTheme } from "../ThemeProvider";
export default function AppHeader({
userEmail,
isAnonymous,
onSignOut,
threadPicker,
}: {
userEmail: string | null;
isAnonymous: boolean;
onSignOut: () => Promise<void>;
threadPicker: ReactNode;
}) {
const { theme, toggleTheme } = useTheme();
const logoSrc = theme === "dark" ? "/app/logo-dark.svg" : "/app/logo-light.svg";
return (
<header className="header-blur sticky top-0 z-30 flex flex-wrap items-center justify-between gap-2 border-b border-[var(--border)] px-3 py-2 sm:px-6 sm:py-3">
<div className="flex items-center gap-2 sm:gap-3">
<img
src={logoSrc}
alt="Deep Agents"
className="h-7 w-7 rounded sm:h-8 sm:w-8"
width={32}
height={32}
/>
<div>
<h1 className="text-sm font-semibold sm:text-lg">{APP_NAME}</h1>
<p className="hidden text-xs text-[var(--muted-foreground)] sm:block">{APP_SUBTITLE}</p>
</div>
</div>
<div className="flex items-center gap-1.5 sm:gap-2">
{userEmail && (
<span className="hidden max-w-[160px] truncate text-xs text-[var(--muted-foreground)] md:inline">
{userEmail}
</span>
)}
<button
type="button"
onClick={toggleTheme}
aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-[var(--border)] text-[var(--muted-foreground)] transition-colors hover:bg-[var(--accent-bg)]"
>
{theme === "dark" ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className="h-3.5 w-3.5">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className="h-3.5 w-3.5">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
)}
</button>
{!isAnonymous && (
<button
onClick={() => { void onSignOut(); }}
className="rounded-lg border border-[var(--border)] px-3 py-1.5 text-xs font-medium text-[var(--muted-foreground)] transition-colors hover:bg-[var(--accent-bg)]"
>
Sign out
</button>
)}
{threadPicker}
</div>
</header>
);
}
@@ -1,148 +0,0 @@
import { useCallback, useEffect, useMemo, useState, type FC } from "react";
import { createPortal } from "react-dom";
import { Streamdown } from "streamdown";
import { extOf, parseDisplayContent } from "../lib/stream";
const FileViewDialog: FC<{
fileName: string;
content: unknown;
onClose: () => void;
}> = ({ fileName, content: rawContent, onClose }) => {
const content = useMemo(() => parseDisplayContent(rawContent), [rawContent]);
const extension = extOf(fileName);
const isMarkdown = ["md", "markdown"].includes(extension);
const isHtml = ["html", "htm"].includes(extension);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(content).catch((err) => {
console.warn("Clipboard write failed", err);
});
}, [content]);
const handleDownload = useCallback(() => {
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName.split("/").pop() ?? "file";
link.click();
URL.revokeObjectURL(url);
}, [content, fileName]);
return createPortal(
<div
className="dialog-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
className="dialog-content flex max-h-[80vh] w-full max-w-3xl flex-col overflow-hidden rounded-xl bg-[var(--surface)] shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-[var(--border)] px-5 py-3">
<div className="flex min-w-0 items-center gap-2">
<svg className="h-4 w-4 shrink-0 text-[var(--muted-foreground)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
<span className="truncate text-sm font-medium">{fileName}</span>
</div>
<div className="flex shrink-0 items-center gap-1">
<button onClick={handleCopy} className="rounded-md p-1.5 text-[var(--muted-foreground)] hover:bg-[var(--muted)]" title="Copy">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect width="14" height="14" x="8" y="8" rx="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
</button>
<button onClick={handleDownload} className="rounded-md p-1.5 text-[var(--muted-foreground)] hover:bg-[var(--muted)]" title="Download">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" x2="12" y1="15" y2="3" />
</svg>
</button>
<button onClick={onClose} className="rounded-md p-1.5 text-[var(--muted-foreground)] hover:bg-[var(--muted)]" title="Close">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-5">
{isHtml && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
HTML preview is disabled in the open-source build. Inspect the escaped
content below or download the file locally.
</div>
)}
{content ? (
isMarkdown && !isHtml ? (
<div className="markdown-body text-sm">
<Streamdown mode="static">{content}</Streamdown>
</div>
) : (
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed text-[var(--foreground)]">
{content}
</pre>
)
) : (
<p className="text-center text-sm text-[var(--muted-foreground)]">
File is empty
</p>
)}
</div>
</div>
</div>,
document.body,
);
};
const FilesPanel: FC<{ files: Record<string, unknown> }> = ({ files }) => {
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const fileNames = Object.keys(files);
if (fileNames.length === 0) return null;
return (
<>
<div className="max-h-48 overflow-y-auto px-4 py-2">
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2">
{fileNames.map((name) => (
<button
key={name}
onClick={() => setSelectedFile(name)}
className="flex flex-col items-center gap-1.5 rounded-lg border border-[var(--border)] bg-[var(--surface)] px-3 py-3 text-center transition-colors hover:bg-[var(--muted)]"
>
<svg className="h-6 w-6 text-[var(--muted-foreground)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
<span className="w-full truncate text-xs font-medium">
{name.split("/").pop()}
</span>
</button>
))}
</div>
</div>
{selectedFile && (
<FileViewDialog
fileName={selectedFile}
content={files[selectedFile] ?? ""}
onClose={() => setSelectedFile(null)}
/>
)}
</>
);
};
export default FilesPanel;
@@ -1,177 +0,0 @@
import type { FC, RefObject } from "react";
import { Streamdown } from "streamdown";
import { APP_NAME, APP_SUBTITLE, PROMPTS } from "../constants";
import {
getErrorMessage,
getImageBlocks,
} from "../lib/stream";
import type { AgentStream } from "../types";
import { AIMessage } from "@langchain/core/messages";
import type { AIMessage as SdkAIMessage } from "@langchain/langgraph-sdk";
import { SubagentPipeline, SynthesisIndicator } from "./SubagentActivity";
import ToolCallCard from "./ToolCallCard";
type MessageListProps = {
bottomRef: RefObject<HTMLDivElement | null>;
error: unknown;
isLoading: boolean;
mainRef: RefObject<HTMLDivElement | null>;
messages: AgentStream["messages"];
onScroll: () => void;
onSuggestionSelect: (prompt: string) => void;
stream: AgentStream;
};
const MessageList: FC<MessageListProps> = ({
bottomRef,
error,
isLoading,
mainRef,
messages,
onScroll,
onSuggestionSelect,
stream,
}) => {
const allSubagents = Array.from(stream.subagents.values());
return (
<main
ref={mainRef}
onScroll={onScroll}
className="flex-1 overflow-y-auto px-2 py-4 sm:px-4 sm:py-6"
>
<div className="mx-auto max-w-4xl space-y-3">
{messages.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center py-24 text-center">
<svg className="anim-stagger-1 mb-4 h-12 w-12" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5">
<path d="M12 2L2 12l10 10 10-10L12 2z" />
<path d="M12 8L8 12l4 4 4-4-4-4z" />
</svg>
<h2 className="anim-stagger-2 text-2xl font-semibold lc-gradient-text">
{APP_NAME}
</h2>
<p className="anim-stagger-2 mt-2 text-[var(--muted-foreground)]">
{APP_SUBTITLE}
</p>
<div className="anim-stagger-3 mt-6 flex flex-wrap justify-center gap-2">
{PROMPTS.map((suggestion) => (
<button
key={suggestion}
onClick={() => onSuggestionSelect(suggestion)}
className="rounded-full border border-[var(--border)] bg-[var(--surface)] px-4 py-2 text-xs font-medium text-[var(--muted-foreground)] transition-colors hover:bg-[var(--accent-bg)] hover:text-[var(--foreground)]"
>
{suggestion}
</button>
))}
</div>
</div>
)}
{messages.map((message, index) => {
if (message.type === "tool") return null;
const content = message.text;
const isLastMessage = index === messages.length - 1;
if (message.getType() === "human") {
return (
<div key={message.id ?? index} className="anim-msg flex justify-end">
<div className="max-w-[90%] rounded-2xl rounded-br-sm bg-[var(--primary)] px-4 py-2.5 text-sm leading-relaxed text-[var(--primary-foreground)]">
{content}
</div>
</div>
);
}
if (AIMessage.isInstance(message)) {
// SDK's getToolCalls is parameterized over its own AIMessage<ToolCall>;
// structurally identical to core's AIMessage but TS sees them as distinct.
const toolCalls = stream.getToolCalls(message as unknown as SdkAIMessage);
const messageSubagents = message.id
? stream.getSubagentsByMessage(message.id)
: [];
const subagentIds = new Set(messageSubagents.map((subagent) => subagent.id));
const nonSubagentToolCalls = toolCalls.filter(
(toolCall) => !subagentIds.has(toolCall.id),
);
const imageBlocks = getImageBlocks(message.content);
const hasSubagents = messageSubagents.length > 0;
const isStreaming = isLastMessage && isLoading;
return (
<div key={message.id ?? index} className="anim-msg flex flex-col items-start gap-2">
{content ? (
<div className="ai-bubble max-w-[90%] rounded-2xl rounded-bl-sm border border-[var(--border)] bg-[var(--surface)] px-4 py-2.5 text-sm leading-relaxed shadow-sm">
<div className="markdown-body">
<Streamdown
animated={{ animation: "fadeIn", sep: "word", duration: 300 }}
isAnimating={isStreaming}
parseIncompleteMarkdown
>
{content}
</Streamdown>
</div>
</div>
) : !toolCalls.length && !hasSubagents ? (
<div className="rounded-2xl rounded-bl-sm border border-[var(--border)] bg-[var(--surface)] px-4 py-2.5 text-sm text-[var(--muted-foreground)] shadow-sm">
<span className="inline-flex items-center gap-1">
<span className="animate-pulse"></span>
<span className="animate-pulse" style={{ animationDelay: "150ms" }}>
</span>
<span className="animate-pulse" style={{ animationDelay: "300ms" }}>
</span>
</span>
</div>
) : null}
{imageBlocks.map((image, imageIndex) => (
<div
key={`${message.id ?? index}-image-${imageIndex}`}
className="max-w-[90%] overflow-hidden rounded-xl border border-[var(--border)]"
>
<img
src={image.url}
alt="Generated content"
className="max-h-96 w-auto object-contain"
/>
</div>
))}
<SubagentPipeline subagents={messageSubagents} />
{nonSubagentToolCalls.map((toolCall) => (
<ToolCallCard key={toolCall.id} toolCall={toolCall} />
))}
</div>
);
}
if (!content) return null;
return (
<div key={message.id ?? index} className="flex justify-start">
<div className="max-w-[90%] rounded-xl border border-[var(--border)] bg-[var(--surface)] px-3 py-2 text-xs text-[var(--muted-foreground)]">
<span className="font-mono">[{message.type}]</span>{" "}
{content.slice(0, 300)}
</div>
</div>
);
})}
<SynthesisIndicator subagents={allSubagents} isLoading={isLoading} />
{error != null && (
<div className="anim-fade-in rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
Error: {getErrorMessage(error)}
</div>
)}
<div ref={bottomRef} className="scroll-anchor" />
</div>
</main>
);
};
export default MessageList;
@@ -1,262 +0,0 @@
import { useEffect, useRef, useState, type FC } from "react";
import { Streamdown } from "streamdown";
import type { SubagentStatus } from "@langchain/react";
import { AIMessage, type BaseMessage } from "@langchain/core/messages";
import type { AgentSubagent } from "../types";
import { getElapsedTime } from "../lib/stream";
const StatusIcon: FC<{ status: SubagentStatus }> = ({ status }) => {
switch (status) {
case "pending":
return (
<span className="text-gray-400">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
</svg>
</span>
);
case "running":
return (
<span className="animate-spin text-[var(--accent)]">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
</span>
);
case "complete":
return (
<span className="text-emerald-500">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
</span>
);
case "error":
return (
<span className="text-red-500">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<line x1="15" x2="9" y1="9" y2="15" />
<line x1="9" x2="15" y1="9" y2="15" />
</svg>
</span>
);
}
};
const StatusBadge: FC<{ status: SubagentStatus }> = ({ status }) => {
const styles: Record<SubagentStatus, string> = {
pending: "bg-gray-100 text-gray-600",
running: "bg-[var(--accent-bg)] text-[var(--accent-foreground)]",
complete: "bg-emerald-100 text-emerald-700",
error: "bg-red-100 text-red-700",
};
return (
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${styles[status]}`}>
{status}
</span>
);
};
const SubagentCard: FC<{
subagent: AgentSubagent;
autoCollapse?: boolean;
}> = ({ subagent, autoCollapse = false }) => {
const [expanded, setExpanded] = useState(
!autoCollapse || subagent.status === "running",
);
const [taskExpanded, setTaskExpanded] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
// Only pin the stream to the bottom when the user is already there; lets
// them scroll up and read earlier content mid-stream without being yanked.
const isNearBottomRef = useRef(true);
const typeName =
typeof subagent.toolCall.args.subagent_type === "string"
? subagent.toolCall.args.subagent_type
: null;
const description =
typeof subagent.toolCall.args.description === "string"
? subagent.toolCall.args.description
: "";
const title = typeName ?? `Agent ${subagent.id.slice(0, 8)}`;
const elapsed = getElapsedTime(subagent.startedAt, subagent.completedAt);
// SubagentStreamInterface still types `messages` as the SDK's Message[]; at runtime they're
// BaseMessage instances (the react package normalizes), so the cast is safe.
const subagentMessages = subagent.messages as unknown as BaseMessage[];
const lastAIMessage = subagentMessages.filter(AIMessage.isInstance).at(-1);
const isStreaming = subagent.status === "running";
const lastAIText = lastAIMessage?.text ?? "";
const displayContent =
subagent.status === "complete" ? subagent.result ?? "" : lastAIText;
const handleScroll = () => {
const el = scrollRef.current;
if (!el) return;
isNearBottomRef.current =
el.scrollHeight - el.scrollTop - el.clientHeight < 40;
};
useEffect(() => {
if (!isStreaming || !scrollRef.current) return;
if (!isNearBottomRef.current) return;
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [displayContent, isStreaming]);
return (
<div className="anim-fade-in overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-sm">
<button
onClick={() => setExpanded((value) => !value)}
className="flex w-full items-center justify-between px-3 py-2.5 text-left hover:bg-[var(--muted)]/30"
>
<div className="flex min-w-0 items-center gap-2.5">
<StatusIcon status={subagent.status} />
<div className="min-w-0">
<h4 className="truncate text-xs font-semibold capitalize">{title}</h4>
{description && (
<p className="truncate text-[11px] text-[var(--muted-foreground)]">
{description}
</p>
)}
</div>
</div>
<div className="ml-2 flex shrink-0 items-center gap-2">
{elapsed && (
<span className="text-[10px] text-[var(--muted-foreground)]">
{elapsed}
</span>
)}
<StatusBadge status={subagent.status} />
<svg
className={`h-3 w-3 text-[var(--muted-foreground)] transition-transform ${
expanded ? "rotate-180" : ""
}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
</button>
{expanded && (description || displayContent) && (
<div className="space-y-3 border-t border-[var(--border)] px-3 py-2.5">
{description && (
<div>
<button
type="button"
onClick={() => setTaskExpanded((v) => !v)}
className="flex w-full items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<span>Task</span>
<svg
className={`h-2.5 w-2.5 transition-transform ${taskExpanded ? "rotate-180" : ""}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
{taskExpanded && (
<p className="mt-1 whitespace-pre-wrap text-xs leading-relaxed text-[var(--foreground)]">
{description}
</p>
)}
</div>
)}
{displayContent && (
<div>
{description && (
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{subagent.status === "complete" ? "Result" : "Output"}
</p>
)}
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-64 min-h-32 max-h-[80vh] resize-y overflow-y-auto"
>
<div className="markdown-body prose prose-sm max-w-none text-xs leading-relaxed">
<Streamdown animated isAnimating={isStreaming} parseIncompleteMarkdown>
{displayContent}
</Streamdown>
</div>
</div>
</div>
)}
</div>
)}
</div>
);
};
const SubagentProgress: FC<{ subagents: AgentSubagent[] }> = ({
subagents,
}) => {
const completed = subagents.filter((subagent) => subagent.status === "complete").length;
const total = subagents.length;
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-[10px] text-[var(--muted-foreground)]">
<span>Subagent progress</span>
<span>
{completed}/{total} complete
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-[var(--accent-bg)]">
<div
className="h-full rounded-full bg-[var(--accent)] transition-all duration-300"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
};
export const SubagentPipeline: FC<{ subagents: AgentSubagent[] }> = ({
subagents,
}) => {
if (subagents.length === 0) return null;
return (
<div className="w-full max-w-[90%] space-y-2 border-l-2 border-[var(--accent)] pl-3">
<SubagentProgress subagents={subagents} />
{subagents.map((subagent) => (
<SubagentCard
key={subagent.id}
subagent={subagent}
autoCollapse={subagents.length >= 5 && subagent.status === "complete"}
/>
))}
</div>
);
};
export const SynthesisIndicator: FC<{
subagents: AgentSubagent[];
isLoading: boolean;
}> = ({ subagents, isLoading }) => {
const allDone =
subagents.length >= 2 &&
subagents.every(
(subagent) =>
subagent.status === "complete" || subagent.status === "error",
);
if (!allDone || !isLoading) return null;
return (
<div className="anim-fade-in flex items-center gap-2 rounded-lg bg-[var(--accent-bg)] px-3 py-2 text-xs text-[var(--foreground)]">
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Synthesizing results from {subagents.length} subagent
{subagents.length !== 1 ? "s" : ""}...
</div>
);
};
@@ -1,162 +0,0 @@
import { useCallback, useEffect, useMemo, useState, type FC } from "react";
import { Client } from "@langchain/langgraph-sdk";
import { getErrorMessage } from "../lib/stream";
import type { ThreadSummary } from "../types";
type ThreadPickerProps = {
currentThreadId: string | null;
onSelect: (id: string | null) => void;
accessToken?: string;
userIdentity: string;
isAnonymous: boolean;
};
const ThreadPicker: FC<ThreadPickerProps> = ({
currentThreadId,
onSelect,
accessToken,
userIdentity,
isAnonymous,
}) => {
const [open, setOpen] = useState(false);
const [threads, setThreads] = useState<ThreadSummary[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const client = useMemo(
() =>
new Client({
apiUrl: window.location.origin,
defaultHeaders: accessToken
? { Authorization: `Bearer ${accessToken}` }
: undefined,
}),
[accessToken],
);
const loadThreads = useCallback(async () => {
setIsLoading(true);
setLoadError(null);
try {
const query: { limit: number; metadata?: Record<string, unknown> } = { limit: 20 };
if (isAnonymous) {
query.metadata = { dap_anon_id: userIdentity };
}
const result = await client.threads.search<ThreadSummary["values"]>(query);
setThreads(result);
} catch (error) {
setLoadError(getErrorMessage(error, "Failed to load threads."));
} finally {
setIsLoading(false);
}
}, [client, isAnonymous, userIdentity]);
useEffect(() => {
if (!open) return;
void loadThreads();
}, [loadThreads, open]);
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open]);
return (
<div className="relative">
<button
onClick={() => setOpen((value) => !value)}
className="flex items-center gap-1.5 rounded-lg border border-[var(--border)] px-3 py-1.5 text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)]"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
{currentThreadId ? `${currentThreadId.slice(0, 8)}...` : "Threads"}
<svg
className={`h-3 w-3 transition-transform ${open ? "rotate-180" : ""}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="anim-slide-down absolute right-0 top-full z-50 mt-1 w-[calc(100vw-2rem)] rounded-xl border border-[var(--border)] bg-[var(--surface)] shadow-lg sm:w-72">
<div className="border-b border-[var(--border)] px-3 py-2">
<button
onClick={() => {
onSelect(null);
setOpen(false);
}}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-xs font-medium text-[var(--foreground)] hover:bg-[var(--muted)]"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>
New Thread
</button>
</div>
<div className="max-h-64 overflow-y-auto py-1">
{isLoading && (
<p className="px-3 py-4 text-center text-xs text-[var(--muted-foreground)]">
Loading threads...
</p>
)}
{loadError && (
<p className="px-3 py-4 text-center text-xs text-red-600">
{loadError}
</p>
)}
{!isLoading && !loadError && threads.length === 0 && (
<p className="px-3 py-4 text-center text-xs text-[var(--muted-foreground)]">
No threads yet
</p>
)}
{!isLoading &&
!loadError &&
threads.map((thread) => (
<button
key={thread.thread_id}
onClick={() => {
onSelect(thread.thread_id);
setOpen(false);
}}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs hover:bg-[var(--muted)] ${
thread.thread_id === currentThreadId
? "bg-[var(--muted)] font-medium"
: ""
}`}
>
<span className="truncate">
{(typeof thread.metadata?.title === "string"
? thread.metadata.title
: "") || (
<span className="font-mono">
{thread.thread_id.slice(0, 12)}...
</span>
)}
</span>
<span className="ml-auto shrink-0 text-[var(--muted-foreground)]">
{new Date(thread.created_at).toLocaleDateString()}
</span>
</button>
))}
</div>
</div>
</>
)}
</div>
);
};
export default ThreadPicker;
@@ -1,54 +0,0 @@
import type { FC } from "react";
import type { TodoItem } from "../types";
const TodosPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
if (todos.length === 0) return null;
const inProgress = todos.filter((todo) => todo.status === "in_progress");
const completed = todos.filter((todo) => todo.status === "completed");
const pending = todos.filter((todo) => todo.status === "pending");
return (
<div className="max-h-48 overflow-y-auto px-4 py-2">
<div className="space-y-1">
{[...inProgress, ...pending, ...completed].map((todo, index) => (
<div
key={todo.id ?? `${todo.content}-${index}`}
className="flex items-start gap-2 rounded-md px-2 py-1.5 text-xs"
>
{todo.status === "completed" ? (
<span className="mt-0.5 text-emerald-600">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
</span>
) : todo.status === "in_progress" ? (
<span className="mt-0.5 animate-spin text-blue-600">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
</span>
) : (
<span className="mt-0.5 text-[var(--muted-foreground)]">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
</svg>
</span>
)}
<span
className={
todo.status === "completed"
? "line-through text-[var(--muted-foreground)]"
: ""
}
>
{todo.content}
</span>
</div>
))}
</div>
</div>
);
};
export default TodosPanel;
@@ -1,136 +0,0 @@
import { useState, type FC } from "react";
import { getToolSummary } from "../lib/stream";
import type { AgentToolCallResult } from "../types";
import { getToolRenderer, getToolIcon } from "./toolcalls";
function formatDetails(value: unknown): string | null {
if (value == null) return null;
if (typeof value === "string") return value;
return JSON.stringify(value, null, 2);
}
const ToolIcon: FC<{ name: string }> = ({ name }) => {
const info = getToolIcon(name);
if (!info) return null;
switch (info.icon) {
case "file-plus":
case "file-edit":
case "file-text":
return (
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
);
case "list-checks":
return (
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 20h9" />
<path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838.838-2.872a2 2 0 0 1 .506-.855z" />
</svg>
);
case "folder":
return (
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h3.93a2 2 0 0 1 1.66.9l.82 1.2a2 2 0 0 0 1.66.9H18a2 2 0 0 1 2 2v2" />
</svg>
);
case "search":
return (
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
);
case "brain":
return (
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2a4 4 0 0 0-4 4v1a3 3 0 0 0-3 3v1a3 3 0 0 0 3 3h1a4 4 0 0 0 0 8h2a4 4 0 0 0 0-8h1a3 3 0 0 0 3-3v-1a3 3 0 0 0-3-3V6a4 4 0 0 0-4-4z" />
<path d="M9 10h6" />
<path d="M9 14h6" />
</svg>
);
default:
return null;
}
};
const ToolCallCard: FC<{ toolCall: AgentToolCallResult }> = ({ toolCall }) => {
const [expanded, setExpanded] = useState(false);
const isPending = toolCall.state === "pending";
const isError = toolCall.state === "error";
const summary = getToolSummary(toolCall.call.args);
const argsStr = formatDetails(toolCall.call.args);
const resultStr = formatDetails(toolCall.result);
const Renderer = getToolRenderer(toolCall.call.name);
const hasToolIcon = getToolIcon(toolCall.call.name) !== null;
return (
<div className="anim-fade-in max-w-[90%] overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface)] text-xs">
<button
onClick={() => setExpanded((value) => !value)}
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[var(--muted)]/30"
>
{isPending ? (
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded bg-amber-100 text-amber-700">
<svg className="h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
</span>
) : isError ? (
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded bg-red-100 text-red-700">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<line x1="15" x2="9" y1="9" y2="15" />
<line x1="9" x2="15" y1="9" y2="15" />
</svg>
</span>
) : hasToolIcon ? (
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded bg-blue-50 text-blue-600">
<ToolIcon name={toolCall.call.name} />
</span>
) : (
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded bg-emerald-100 text-emerald-700">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
</span>
)}
<span className="font-medium text-[var(--foreground)]">
{toolCall.call.name}
</span>
{summary && !expanded && (
<span className="truncate text-[var(--muted-foreground)]">{summary}</span>
)}
<span className="ml-auto flex items-center gap-1.5">
{isPending && (
<span className="text-[var(--muted-foreground)]">running...</span>
)}
{isError && <span className="text-red-600">error</span>}
<svg
className={`h-3 w-3 text-[var(--muted-foreground)] transition-transform ${
expanded ? "rotate-180" : ""
}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="m6 9 6 6 6-6" />
</svg>
</span>
</button>
{Renderer ? (
<Renderer toolCall={toolCall} expanded={expanded} />
) : expanded && (argsStr || resultStr) ? (
<pre className="max-h-48 overflow-auto whitespace-pre-wrap border-t border-[var(--border)] px-3 py-2 text-[var(--muted-foreground)]">
{resultStr ?? argsStr}
</pre>
) : null}
</div>
);
};
export default ToolCallCard;
@@ -1,126 +0,0 @@
import { useState, type FC } from "react";
import type { ToolRendererProps } from "./index";
function parseArgs(args: unknown): Record<string, unknown> {
if (typeof args === "string") {
try { return JSON.parse(args); } catch { return {}; }
}
return (args && typeof args === "object") ? args as Record<string, unknown> : {};
}
function formatResult(result: unknown): string | null {
if (result == null) return null;
if (typeof result === "string") return result;
return JSON.stringify(result, null, 2);
}
const MAX_PREVIEW_LINES = 20;
const CodePreview: FC<{ content: string; label?: string }> = ({ content, label }) => {
const lines = content.split("\n");
const [showAll, setShowAll] = useState(false);
const truncated = !showAll && lines.length > MAX_PREVIEW_LINES;
const displayContent = truncated ? lines.slice(0, MAX_PREVIEW_LINES).join("\n") : content;
return (
<div>
{label && (
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{label}
</div>
)}
<pre className="tool-code-block">{displayContent}</pre>
{truncated && (
<button
onClick={() => setShowAll(true)}
className="mt-1 text-[10px] text-[var(--accent)] hover:underline"
>
Show all {lines.length} lines
</button>
)}
</div>
);
};
const FileToolCard: FC<ToolRendererProps> = ({ toolCall, expanded }) => {
const args = parseArgs(toolCall.call.args);
const toolName = toolCall.call.name;
const fileName = (args.file_name ?? args.filename ?? args.path ?? "") as string;
const content = (args.content ?? "") as string;
const oldStr = (args.old_str ?? args.old_string ?? "") as string;
const newStr = (args.new_str ?? args.new_string ?? "") as string;
const result = formatResult(toolCall.result);
if (!expanded) return null;
if (toolName === "edit_file" && (oldStr || newStr)) {
return (
<div className="space-y-2 border-t border-[var(--border)] px-3 py-2">
{fileName && (
<div className="flex items-center gap-1.5 text-[11px] font-medium text-[var(--foreground)]">
<svg className="h-3 w-3 text-[var(--muted-foreground)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
{fileName}
</div>
)}
{oldStr && (
<div>
<div className="mb-0.5 text-[10px] font-medium uppercase tracking-wide text-red-500">Removed</div>
<pre className="tool-diff-old">{oldStr}</pre>
</div>
)}
{newStr && (
<div>
<div className="mb-0.5 text-[10px] font-medium uppercase tracking-wide text-emerald-600">Added</div>
<pre className="tool-diff-new">{newStr}</pre>
</div>
)}
</div>
);
}
if (toolName === "read_file") {
return (
<div className="space-y-2 border-t border-[var(--border)] px-3 py-2">
{fileName && (
<div className="flex items-center gap-1.5 text-[11px] font-medium text-[var(--foreground)]">
<svg className="h-3 w-3 text-[var(--muted-foreground)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
{fileName}
</div>
)}
{result ? (
<CodePreview content={result} />
) : (
<div className="text-[11px] text-[var(--muted-foreground)]">Reading...</div>
)}
</div>
);
}
// write_file
return (
<div className="space-y-2 border-t border-[var(--border)] px-3 py-2">
{fileName && (
<div className="flex items-center gap-1.5 text-[11px] font-medium text-[var(--foreground)]">
<svg className="h-3 w-3 text-[var(--muted-foreground)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
{fileName}
</div>
)}
{content ? (
<CodePreview content={content} />
) : result ? (
<CodePreview content={result} />
) : null}
</div>
);
};
export default FileToolCard;
@@ -1,107 +0,0 @@
import type { FC } from "react";
import type { ToolRendererProps } from "./index";
function parseArgs(args: unknown): Record<string, unknown> {
if (typeof args === "string") {
try { return JSON.parse(args); } catch { return {}; }
}
return (args && typeof args === "object") ? args as Record<string, unknown> : {};
}
function formatResult(result: unknown): string | null {
if (result == null) return null;
if (typeof result === "string") return result;
if (Array.isArray(result)) return result.map(String).join("\n");
return JSON.stringify(result, null, 2);
}
const FileEntry: FC<{ name: string }> = ({ name }) => (
<div className="flex items-center gap-1.5 py-0.5 text-xs text-[var(--foreground)]">
<svg className="h-3 w-3 shrink-0 text-[var(--muted-foreground)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
<span className="truncate font-mono text-[11px]">{name}</span>
</div>
);
const SearchCard: FC<ToolRendererProps> = ({ toolCall, expanded }) => {
const args = parseArgs(toolCall.call.args);
const toolName = toolCall.call.name;
const result = formatResult(toolCall.result);
if (!expanded) return null;
if (toolName === "ls") {
const path = (args.path ?? args.directory ?? ".") as string;
const lines = result?.split("\n").filter(Boolean) ?? [];
return (
<div className="border-t border-[var(--border)] px-3 py-2">
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-medium text-[var(--foreground)]">
<svg className="h-3 w-3 text-[var(--muted-foreground)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h3.93a2 2 0 0 1 1.66.9l.82 1.2a2 2 0 0 0 1.66.9H18a2 2 0 0 1 2 2v2" />
</svg>
{path}
</div>
{lines.length > 0 ? (
<div className="max-h-48 space-y-0.5 overflow-y-auto">
{lines.map((line, i) => (
<FileEntry key={i} name={line} />
))}
</div>
) : result ? (
<pre className="tool-code-block">{result}</pre>
) : (
<div className="text-[11px] text-[var(--muted-foreground)]">Loading...</div>
)}
</div>
);
}
if (toolName === "glob") {
const pattern = (args.pattern ?? args.glob ?? "") as string;
const lines = result?.split("\n").filter(Boolean) ?? [];
return (
<div className="border-t border-[var(--border)] px-3 py-2">
{pattern && (
<div className="mb-1.5 font-mono text-[11px] text-[var(--muted-foreground)]">
Pattern: {pattern}
</div>
)}
{lines.length > 0 ? (
<div className="max-h-48 space-y-0.5 overflow-y-auto">
{lines.map((line, i) => (
<FileEntry key={i} name={line} />
))}
</div>
) : result ? (
<pre className="tool-code-block">{result}</pre>
) : (
<div className="text-[11px] text-[var(--muted-foreground)]">Searching...</div>
)}
</div>
);
}
// grep
const query = (args.pattern ?? args.query ?? "") as string;
return (
<div className="border-t border-[var(--border)] px-3 py-2">
{query && (
<div className="mb-1.5 font-mono text-[11px] text-[var(--muted-foreground)]">
Search: <span className="text-[var(--foreground)]">{query}</span>
</div>
)}
{result ? (
<pre className="tool-code-block">{result}</pre>
) : (
<div className="text-[11px] text-[var(--muted-foreground)]">Searching...</div>
)}
</div>
);
};
export default SearchCard;
@@ -1,60 +0,0 @@
import { useState, type FC } from "react";
import type { ToolRendererProps } from "./index";
function parseThought(args: unknown): string {
if (typeof args === "string") {
try {
const parsed = JSON.parse(args);
return parseThought(parsed);
} catch {
return args;
}
}
if (args && typeof args === "object") {
const record = args as Record<string, unknown>;
const thought = record.thought ?? record.content ?? record.reflection ?? record.text;
if (typeof thought === "string") return thought;
const firstString = Object.values(record).find(
(v): v is string => typeof v === "string" && v.length > 0,
);
return firstString ?? JSON.stringify(args, null, 2);
}
return String(args ?? "");
}
const PREVIEW_LINES = 3;
const ThinkCard: FC<ToolRendererProps> = ({ toolCall, expanded }) => {
const thought = parseThought(toolCall.call.args);
const lines = thought.split("\n");
const [showAll, setShowAll] = useState(false);
const needsTruncation = lines.length > PREVIEW_LINES;
const preview = lines.slice(0, PREVIEW_LINES).join("\n");
// Always show a preview even when collapsed
return (
<div className="border-t border-[var(--border)]">
<div className="tool-think-block mx-3 my-2">
<div className="whitespace-pre-wrap text-xs leading-relaxed">
{expanded || showAll ? thought : preview}
{!expanded && needsTruncation && !showAll && (
<>
{"..."}
<button
onClick={(e) => {
e.stopPropagation();
setShowAll(true);
}}
className="ml-1 text-violet-600 hover:underline"
>
more
</button>
</>
)}
</div>
</div>
</div>
);
};
export default ThinkCard;
@@ -1,63 +0,0 @@
import type { FC } from "react";
import type { ToolRendererProps } from "./index";
type TodoArg = { content?: string; status?: string; id?: string };
function parseTodos(args: unknown): TodoArg[] {
if (typeof args === "string") {
try { args = JSON.parse(args); } catch { return []; }
}
if (!args || typeof args !== "object") return [];
const record = args as Record<string, unknown>;
const todos = record.todos;
if (Array.isArray(todos)) return todos as TodoArg[];
return [];
}
const TodosCard: FC<ToolRendererProps> = ({ toolCall, expanded }) => {
const todos = parseTodos(toolCall.call.args);
if (!expanded || todos.length === 0) return null;
return (
<div className="space-y-1 border-t border-[var(--border)] px-3 py-2">
{todos.map((todo, index) => (
<div
key={todo.id ?? `${todo.content}-${index}`}
className="flex items-start gap-2 rounded-md px-1 py-1 text-xs"
>
{todo.status === "completed" ? (
<span className="mt-0.5 text-emerald-600">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
</span>
) : todo.status === "in_progress" ? (
<span className="mt-0.5 text-blue-600">
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
</span>
) : (
<span className="mt-0.5 text-[var(--muted-foreground)]">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
</svg>
</span>
)}
<span
className={
todo.status === "completed"
? "line-through text-[var(--muted-foreground)]"
: "text-[var(--foreground)]"
}
>
{todo.content ?? "Untitled task"}
</span>
</div>
))}
</div>
);
};
export default TodosCard;
@@ -1,51 +0,0 @@
import type { FC } from "react";
import type { AgentToolCallResult } from "../../types";
import FileToolCard from "./FileToolCard";
import TodosCard from "./TodosCard";
import SearchCard from "./SearchCard";
import ThinkCard from "./ThinkCard";
export type ToolRendererProps = {
toolCall: AgentToolCallResult;
expanded: boolean;
};
const TOOL_RENDERERS: Record<string, FC<ToolRendererProps>> = {
write_file: FileToolCard,
edit_file: FileToolCard,
read_file: FileToolCard,
write_todos: TodosCard,
ls: SearchCard,
glob: SearchCard,
grep: SearchCard,
think: ThinkCard,
think_tool: ThinkCard,
};
export function getToolRenderer(toolName: string): FC<ToolRendererProps> | null {
return TOOL_RENDERERS[toolName] ?? null;
}
export function getToolIcon(toolName: string): { icon: string; label: string } | null {
switch (toolName) {
case "write_file":
return { icon: "file-plus", label: "Write" };
case "edit_file":
return { icon: "file-edit", label: "Edit" };
case "read_file":
return { icon: "file-text", label: "Read" };
case "write_todos":
return { icon: "list-checks", label: "Todos" };
case "ls":
return { icon: "folder", label: "List" };
case "glob":
return { icon: "search", label: "Glob" };
case "grep":
return { icon: "search", label: "Search" };
case "think":
case "think_tool":
return { icon: "brain", label: "Think" };
default:
return null;
}
}
-15
View File
@@ -1,15 +0,0 @@
import { getRuntimeConfig } from "./runtimeConfig";
const cfg = getRuntimeConfig();
export const APP_NAME = cfg.appName;
export const APP_SUBTITLE = cfg.subtitle ?? "Your deep agent, deployed.";
export const ASSISTANT_ID = cfg.assistantId;
export const PROMPTS: readonly string[] =
cfg.prompts && cfg.prompts.length > 0
? cfg.prompts
: [
"What can you help me research?",
"Research a topic for me",
"Help me write a report",
];
-220
View File
@@ -1,220 +0,0 @@
@import "tailwindcss";
@source "../node_modules/streamdown/dist/*.js";
:root {
--background: #fafcff;
--surface: #ffffff;
--foreground: #030710;
--muted: #f2faff;
--muted-foreground: #6b8299;
--border: #b8dfff;
--border-subtle: #e5f4ff;
--primary: #030710;
--primary-foreground: #ffffff;
--accent: #7fc8ff;
--accent-hover: #99d4ff;
--accent-bg: #e5f4ff;
--accent-foreground: #030710;
--input-border: #b8dfff;
--input-focus: #7fc8ff;
--header-blur-bg: rgba(250, 252, 255, 0.85);
}
[data-theme="dark"] {
--background: #030710;
--surface: #0a1220;
--foreground: #fafcff;
--muted: #111a2b;
--muted-foreground: #8a9aac;
--border: #1f3a5c;
--border-subtle: #152641;
--primary: #7fc8ff;
--primary-foreground: #030710;
--accent: #7fc8ff;
--accent-hover: #99d4ff;
--accent-bg: #152641;
--accent-foreground: #fafcff;
--input-border: #1f3a5c;
--input-focus: #7fc8ff;
--header-blur-bg: rgba(3, 7, 16, 0.85);
}
body {
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--background);
color: var(--foreground);
-webkit-font-smoothing: antialiased;
}
/* Composer card */
.composer {
border: 1px solid var(--border);
border-radius: 1rem;
background: var(--surface);
box-shadow: 0 1px 3px rgba(3, 7, 16, 0.04);
transition: box-shadow 0.2s ease;
}
.composer:focus-within {
box-shadow: 0 0 0 3px var(--accent-bg), 0 1px 3px rgba(3, 7, 16, 0.04);
border-color: var(--accent);
}
/* Header blur */
.header-blur {
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
background: var(--header-blur-bg);
}
/* Gradient text */
.lc-gradient-text {
background: linear-gradient(135deg, var(--foreground) 0%, var(--accent) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Scroll anchoring — pin viewport to the bottom sentinel during streaming */
main { overflow-anchor: none; }
.scroll-anchor { overflow-anchor: auto; height: 1px; }
/* Soften height changes on AI bubbles during markdown re-parsing */
.ai-bubble { transition: min-height 0.15s ease-out; }
/* ── Entrance animations ──────────────────────────────────────────── */
@media (prefers-reduced-motion: no-preference) {
@keyframes fade-in { from { opacity: 0; } }
@keyframes slide-up-fade { from { opacity: 0; transform: translateY(8px); } }
@keyframes slide-down-fade { from { opacity: 0; transform: translateY(-6px); } }
@keyframes scale-fade-in { from { opacity: 0; transform: scale(0.95); } }
@keyframes stagger-fade { from { opacity: 0; transform: translateY(10px); } }
}
.anim-msg { animation: slide-up-fade 200ms ease-out both; }
.anim-fade-in { animation: fade-in 200ms ease-out both; }
.anim-scale-in { animation: scale-fade-in 200ms ease-out both; }
.anim-slide-down { animation: slide-down-fade 150ms ease-out both; }
.anim-stagger-1 { animation: stagger-fade 300ms ease-out 50ms both; }
.anim-stagger-2 { animation: stagger-fade 300ms ease-out 150ms both; }
.anim-stagger-3 { animation: stagger-fade 300ms ease-out 250ms both; }
/* Smooth expand/collapse via grid-rows trick */
.panel-collapse {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 200ms ease-out;
}
.panel-collapse.is-open { grid-template-rows: 1fr; }
.panel-collapse > * { overflow: hidden; }
/* Dialog enter animations */
.dialog-backdrop { animation: fade-in 150ms ease-out both; }
.dialog-content { animation: scale-fade-in 200ms ease-out both; }
/* Tool call rich rendering */
.tool-code-block {
background: #1e1e1e;
color: #d4d4d4;
padding: 0.75em 1em;
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
font-size: 0.75rem;
line-height: 1.5;
overflow-x: auto;
max-height: 12rem;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.tool-think-block {
background: #f3f0ff;
border-left: 3px solid #8b5cf6;
padding: 0.75em 1em;
border-radius: 0 6px 6px 0;
font-style: italic;
color: #4c1d95;
}
.tool-diff-old {
background: #fef2f2;
color: #991b1b;
padding: 0.5em 0.75em;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
font-size: 0.7rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.tool-diff-new {
background: #f0fdf4;
color: #166534;
padding: 0.5em 0.75em;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
font-size: 0.7rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
/* Markdown styles */
.markdown-body h1, .markdown-body h2, .markdown-body h3 {
margin-top: 1em;
margin-bottom: 0.5em;
font-weight: 600;
}
.markdown-body h1 { font-size: 1.25rem; }
.markdown-body h2 { font-size: 1.1rem; }
.markdown-body h3 { font-size: 1rem; }
.markdown-body p { margin: 0.5em 0; line-height: 1.6; }
.markdown-body p:first-child { margin-top: 0; }
.markdown-body p:last-child { margin-bottom: 0; }
.markdown-body ul, .markdown-body ol { margin: 0.5em 0; padding-left: 1.5em; }
.markdown-body li { margin: 0.25em 0; }
.markdown-body code {
background: var(--muted);
padding: 0.15em 0.4em;
border-radius: 4px;
font-size: 0.875em;
}
.markdown-body pre {
background: #1e1e1e;
color: #d4d4d4;
padding: 1em;
border-radius: 8px;
overflow-x: auto;
margin: 0.75em 0;
}
.markdown-body pre code {
background: none;
padding: 0;
color: inherit;
}
.markdown-body a {
color: var(--accent);
text-decoration: underline;
}
.markdown-body blockquote {
border-left: 3px solid var(--border);
padding-left: 1em;
color: var(--muted-foreground);
margin: 0.75em 0;
}
.markdown-body table {
border-collapse: collapse;
width: 100%;
margin: 0.75em 0;
}
.markdown-body th, .markdown-body td {
border: 1px solid var(--border);
padding: 0.5em 0.75em;
text-align: left;
}
.markdown-body th {
background: var(--muted);
font-weight: 600;
}
-133
View File
@@ -1,133 +0,0 @@
import { useStream, type UseDeepAgentStreamOptions, type UseStream } from "@langchain/react";
import type { DefaultToolCall } from "@langchain/react";
import type { AgentState } from "../types";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isImageContentBlock(
block: unknown,
): block is {
type: "image" | "image_url";
image_url?: string | { url: string };
source?: { data?: string };
url?: string;
} {
return (
isRecord(block) &&
typeof block.type === "string" &&
(block.type === "image_url" || block.type === "image")
);
}
function getImageUrl(block: {
image_url?: string | { url: string };
source?: { data?: string };
url?: string;
}): string {
if (typeof block.image_url === "string") return block.image_url;
if (isRecord(block.image_url) && typeof block.image_url.url === "string") {
return block.image_url.url;
}
if (isRecord(block.source) && typeof block.source.data === "string") {
return block.source.data;
}
return typeof block.url === "string" ? block.url : "";
}
export function useAgentStream(
options: UseDeepAgentStreamOptions<AgentState>,
): UseStream<AgentState> {
// UseDeepAgentStreamOptions extends UseStreamOptions with subagent support,
// but the return type is the same UseStream shape at runtime.
return useStream<AgentState>(options) as unknown as UseStream<AgentState>;
}
export function getImageBlocks(content: unknown): { url: string }[] {
if (!Array.isArray(content)) return [];
return content
.filter(isImageContentBlock)
.map((block) => ({ url: getImageUrl(block) }))
.filter((block) => block.url.length > 0);
}
export function getElapsedTime(
startedAt: Date | null,
completedAt: Date | null,
): string | null {
if (!startedAt) return null;
const end = completedAt ?? new Date();
const seconds = Math.round((end.getTime() - startedAt.getTime()) / 1000);
if (seconds < 60) return `${seconds}s`;
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
}
export function extOf(path: string): string {
return path.split(".").pop()?.toLowerCase() ?? "";
}
export function parseDisplayContent(rawContent: unknown): string {
if (rawContent == null) return "";
if (typeof rawContent === "string") {
const trimmed = rawContent.trimStart();
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return rawContent;
try {
return JSON.stringify(JSON.parse(rawContent), null, 2);
} catch {
return rawContent;
}
}
if (Array.isArray(rawContent)) {
return rawContent
.map((item) => parseDisplayContent(item))
.filter(Boolean)
.join("\n");
}
if (!isRecord(rawContent)) {
return String(rawContent);
}
const content = rawContent.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content.map((item) => parseDisplayContent(item)).join("\n");
}
return JSON.stringify(rawContent, null, 2);
}
export function getErrorMessage(
error: unknown,
fallback = "Something went wrong.",
): string {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error) return error;
return fallback;
}
export function getToolSummary(args: DefaultToolCall["args"] | string | undefined): string {
if (typeof args === "string") return args.slice(0, 60);
if (!isRecord(args)) return "";
const fileArg = [args.file_name, args.filename, args.path].find(
(value) => typeof value === "string" && value.length > 0,
);
if (typeof fileArg === "string") return fileArg;
if (Array.isArray(args.todos)) return `${args.todos.length} items`;
if (typeof args.query === "string") return args.query;
const firstString = Object.values(args).find(
(value): value is string => typeof value === "string" && value.length > 0,
);
return firstString?.slice(0, 60) ?? "";
}
-20
View File
@@ -1,20 +0,0 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import { APP_NAME } from "./constants";
import { ThemeProvider } from "./ThemeProvider";
import "./index.css";
document.title = APP_NAME;
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("Missing #root element in index.html");
createRoot(rootEl).render(
<StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</StrictMode>,
);
-75
View File
@@ -1,75 +0,0 @@
interface RuntimeConfigBase {
appName: string;
assistantId: string;
subtitle?: string;
prompts?: string[];
}
export interface RuntimeConfigSupabase extends RuntimeConfigBase {
auth: "supabase";
supabaseUrl: string;
supabaseAnonKey: string;
}
export interface RuntimeConfigClerk extends RuntimeConfigBase {
auth: "clerk";
clerkPublishableKey: string;
}
export interface RuntimeConfigAnonymous extends RuntimeConfigBase {
auth: "anonymous";
}
export type RuntimeConfig =
| RuntimeConfigSupabase
| RuntimeConfigClerk
| RuntimeConfigAnonymous;
declare global {
interface Window {
__DEEPAGENTS_CONFIG__?: Partial<RuntimeConfig> & { __PLACEHOLDER__?: boolean };
}
}
export function getRuntimeConfig(): RuntimeConfig {
const cfg = window.__DEEPAGENTS_CONFIG__;
if (!cfg || cfg.__PLACEHOLDER__) {
throw new Error(
"window.__DEEPAGENTS_CONFIG__ not injected. Run through `deepagent deploy` or `deepagent dev`.",
);
}
const base = {
appName: cfg.appName ?? "Deep Agent",
assistantId: cfg.assistantId ?? "agent",
subtitle: cfg.subtitle,
prompts: cfg.prompts,
};
if (cfg.auth === "supabase") {
if (!cfg.supabaseUrl || !cfg.supabaseAnonKey) {
throw new Error("Runtime config missing supabaseUrl / supabaseAnonKey.");
}
return {
auth: "supabase",
supabaseUrl: cfg.supabaseUrl,
supabaseAnonKey: cfg.supabaseAnonKey,
...base,
};
}
if (cfg.auth === "clerk") {
if (!cfg.clerkPublishableKey) {
throw new Error("Runtime config missing clerkPublishableKey.");
}
return {
auth: "clerk",
clerkPublishableKey: cfg.clerkPublishableKey,
...base,
};
}
if (cfg.auth === "anonymous") {
return {
auth: "anonymous",
...base,
};
}
throw new Error(`Unknown auth provider: ${String(cfg.auth)}`);
}
-22
View File
@@ -1,22 +0,0 @@
import type { UseStream, DefaultToolCall, SubagentStreamInterface } from "@langchain/react";
import type { BaseMessage } from "@langchain/core/messages";
import type { Thread, ToolCallWithResult as SdkToolCallWithResult } from "@langchain/langgraph-sdk";
export type TodoStatus = "pending" | "in_progress" | "completed" | string;
export interface TodoItem {
id?: string;
content: string;
status: TodoStatus;
}
export interface AgentState extends Record<string, unknown> {
messages: BaseMessage[];
files?: Record<string, unknown>;
todos?: TodoItem[];
}
export type AgentStream = UseStream<AgentState>;
export type AgentSubagent = SubagentStreamInterface;
export type AgentToolCallResult = SdkToolCallWithResult<DefaultToolCall>;
export type ThreadSummary = Thread<AgentState>;
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-19
View File
@@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src"]
}
-25
View File
@@ -1,25 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
base: "/app/",
// Dedupe React so dynamic-imported chunks (auth adapters) share the same
// React instance as the main app. Without this, `@supabase/auth-ui-react`
// loaded in the supabase adapter chunk can pick up its own React copy,
// producing the classic "Cannot read properties of null (reading
// 'useState')" error when its hooks run against a null dispatcher.
resolve: {
dedupe: ["react", "react-dom"],
},
build: {
outDir: "dist",
},
server: {
proxy: {
"/threads": "http://localhost:2024",
"/runs": "http://localhost:2024",
"/assistants": "http://localhost:2024",
},
},
});
+3 -28
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "deepagents-cli"
version = "0.1.2"
version = "0.2.0"
description = "Deployment tooling for Deep Agents - bundle, run, and ship agents to LangGraph Platform."
readme = "README.md"
license = { text = "MIT" }
@@ -31,28 +31,15 @@ dependencies = [
# Bundling & deploy server
"langgraph-sdk>=0.3.13,<1.0.0",
"langgraph-cli[inmem]>=0.4.24,<1.0.0",
"langgraph-runtime-inmem>=0.28.1,<1.0.0",
"httpx>=0.28.1,<1.0.0",
# LangSmith hub integration (context_hub.py)
# LangSmith
"langsmith>=0.8.0",
# Utilities
"python-dotenv>=1.2.2,<2.0.0",
]
[project.optional-dependencies]
# Sandbox providers
agentcore = ["langchain-agentcore-codeinterpreter>=0.0.2"]
daytona = ["langchain-daytona>=0.0.5"]
modal = ["langchain-modal>=0.0.3"]
runloop = ["langchain-runloop>=0.0.4"]
all-sandboxes = [
"deepagents-cli[agentcore,daytona,modal,runloop]",
]
[project.scripts]
deepagents = "deepagents_cli:cli_main"
deepagents-cli = "deepagents_cli:cli_main"
@@ -86,25 +73,13 @@ test = [
[tool.hatch.build.targets.wheel]
packages = ["deepagents_cli"]
include = [
"deepagents_cli/**/*.py",
"deepagents_cli/deploy/frontend_dist/**/*",
]
[tool.uv.sources]
deepagents = { path = "../deepagents", editable = true }
langchain-daytona = { path = "../partners/daytona", editable = true }
langchain-modal = { path = "../partners/modal", editable = true }
langchain-runloop = { path = "../partners/runloop", editable = true }
[tool.ty.environment]
python-version = "3.11"
extra-paths = [
"../deepagents",
"../partners/daytona",
"../partners/modal",
"../partners/runloop",
]
extra-paths = ["../deepagents"]
[tool.ty.rules]
# https://docs.astral.sh/ty/rules/
@@ -1,206 +0,0 @@
"""Integration tests for ContextHubBackend against a real LangSmith Hub.
Skipped unless ``LANGSMITH_API_KEY`` is set. Each test fixture creates a
uniquely-named throwaway agent repo and deletes it on teardown, so these
tests are safe to run against a real tenant.
"""
from __future__ import annotations
import logging
import os
import uuid
from typing import TYPE_CHECKING, Any
import pytest
from deepagents_cli.deploy.context_hub import ContextHubBackend
if TYPE_CHECKING:
from collections.abc import Iterator
pytestmark = pytest.mark.skipif(
not os.environ.get("LANGSMITH_API_KEY"),
reason="LANGSMITH_API_KEY not set; skipping Context Hub integration tests.",
)
logger = logging.getLogger(__name__)
@pytest.fixture
def identifier() -> str:
"""Unique throwaway agent-repo handle under the current tenant."""
return f"-/deepagents-ctx-hub-test-{uuid.uuid4().hex[:12]}"
@pytest.fixture
def backend(identifier: str) -> Iterator:
"""Build a ContextHubBackend and delete the underlying repo on teardown."""
from langsmith import Client
from deepagents_cli.deploy.context_hub import ContextHubBackend
client = Client()
yield ContextHubBackend(identifier, client=client)
try:
client.delete_agent(identifier)
except Exception:
logger.warning("Failed to delete test repo %r", identifier, exc_info=True)
def test_lazy_create_on_first_write(backend) -> None:
"""Pulling a non-existent repo returns empty; first write lazily creates it."""
missing = backend.read("/notes.md")
assert missing.error == "File '/notes.md' not found"
write = backend.write("/notes.md", "# hi")
assert write.error is None
assert write.path == "/notes.md"
read = backend.read("/notes.md")
assert read.error is None
assert read.file_data is not None
assert read.file_data["content"] == "# hi"
def test_round_trip_with_ls_grep_glob_edit(backend) -> None:
assert backend.write("/a.md", "hello\nworld").error is None
assert backend.write("/b.md", "hello again").error is None
assert backend.write("/notes/day1.md", "first note").error is None
ls_root = backend.ls("/")
assert ls_root.entries is not None
root_paths = {e["path"] for e in ls_root.entries}
assert {"/a.md", "/b.md", "/notes"} <= root_paths
ls_nested = backend.ls("/notes")
assert ls_nested.entries is not None
assert {e["path"] for e in ls_nested.entries} == {"/notes/day1.md"}
grep = backend.grep("hello")
assert grep.matches is not None
assert {m["path"] for m in grep.matches} == {"/a.md", "/b.md"}
glob = backend.glob("*.md")
assert glob.matches is not None
assert {m["path"] for m in glob.matches} >= {"/a.md", "/b.md"}
edit = backend.edit("/a.md", "world", "earth")
assert edit.error is None
assert edit.occurrences == 1
updated = backend.read("/a.md")
assert updated.error is None
assert updated.file_data is not None
assert "earth" in updated.file_data["content"]
def test_persists_across_backend_instances(backend, identifier) -> None:
"""A fresh ContextHubBackend on the same identifier sees prior writes."""
from langsmith import Client
from deepagents_cli.deploy.context_hub import ContextHubBackend
assert backend.write("/persist.md", "original").error is None
second = ContextHubBackend(identifier, client=Client())
result = second.read("/persist.md")
assert result.error is None
assert result.file_data is not None
assert result.file_data["content"] == "original"
def test_parent_commit_conflict_surfaces_error(backend, identifier) -> None:
"""Concurrent writes against a stale parent_commit should be rejected."""
from langsmith import Client
from deepagents_cli.deploy.context_hub import ContextHubBackend
assert backend.write("/shared.md", "v0").error is None
stale = ContextHubBackend(identifier, client=Client())
stale.read("/shared.md") # prime stale's commit_hash with current state
# `backend` advances the repo.
assert backend.write("/shared.md", "v1").error is None
# `stale` now has an outdated parent_commit; server rejects.
result = stale.write("/other.md", "should-fail")
assert result.error is not None
assert "Hub unavailable" in result.error
def test_download_files_round_trip(backend) -> None:
assert backend.write("/blob.txt", "payload").error is None
responses = backend.download_files(["/blob.txt", "/missing.txt"])
assert len(responses) == 2
assert responses[0].content == b"payload"
assert responses[0].error is None
assert responses[1].error == "file_not_found"
def test_upload_files_round_trip(backend) -> None:
responses = backend.upload_files(
[("/u1.md", b"one"), ("/u2.md", b"two"), ("/bad.bin", b"\x80\xff")]
)
assert responses[0].error is None
assert responses[1].error is None
assert responses[2].error == "invalid_path"
assert backend.read("/u1.md").file_data["content"] == "one"
assert backend.read("/u2.md").file_data["content"] == "two"
def test_upload_files_produces_single_commit(identifier) -> None:
"""Batch upload of N files should make exactly one ``push_agent`` call.
Unit tests assert this with a fully-mocked client; this test wraps a
real ``langsmith.Client`` so we get the same guarantee against the
actual Hub API surface, and additionally confirms the resulting commit
persists every file in one shot.
"""
from unittest.mock import patch
from langsmith import Client
real_client = Client()
push_calls: list[dict[str, Any]] = []
original_push = type(real_client).push_agent
def spy_push(self, identifier: str, **kwargs: Any) -> str:
push_calls.append({"identifier": identifier, **kwargs})
return original_push(self, identifier, **kwargs)
backend = ContextHubBackend(identifier, client=real_client)
try:
with patch.object(type(real_client), "push_agent", spy_push):
responses = backend.upload_files(
[
("/batch/a.md", b"alpha"),
("/batch/b.md", b"beta"),
("/batch/c.md", b"gamma"),
("/batch/d.md", b"delta"),
]
)
assert all(r.error is None for r in responses), responses
assert len(push_calls) == 1, (
f"expected one push_agent call, got {len(push_calls)}"
)
assert set(push_calls[0]["files"].keys()) == {
"batch/a.md",
"batch/b.md",
"batch/c.md",
"batch/d.md",
}
# Confirm the commit actually landed and contains all four files.
pulled = Client().pull_agent(identifier)
pulled_paths = set(pulled.files.keys())
assert {"batch/a.md", "batch/b.md", "batch/c.md", "batch/d.md"} <= pulled_paths
finally:
try:
Client().delete_agent(identifier)
except Exception:
logger.warning("Failed to delete test repo %r", identifier, exc_info=True)
@@ -1,191 +0,0 @@
"""Integration tests for the hub-backed `deepagents deploy` bundle.
These tests scaffold a tiny project, bundle it with
``[memories].backend = "hub"``, load the vendored ContextHubBackend from
the bundle directory, and exercise the seed flow against a real LangSmith
Hub. Each test provisions a unique throwaway agent repo and deletes it on
teardown so the suite is safe to run against a real tenant.
Skipped unless ``LANGSMITH_API_KEY`` is set.
"""
from __future__ import annotations
import asyncio
import importlib
import json
import logging
import os
import sys
import uuid
from typing import TYPE_CHECKING
import pytest
from deepagents_cli.deploy.bundler import bundle
from deepagents_cli.deploy.config import (
AGENTS_MD_FILENAME,
SKILLS_DIRNAME,
AgentConfig,
DeployConfig,
MemoriesConfig,
)
if TYPE_CHECKING:
import types
from collections.abc import Iterator
from pathlib import Path
pytestmark = pytest.mark.skipif(
not os.environ.get("LANGSMITH_API_KEY"),
reason="LANGSMITH_API_KEY not set; skipping hub deploy integration tests.",
)
logger = logging.getLogger(__name__)
@pytest.fixture
def hub_identifier() -> Iterator[str]:
"""Unique throwaway agent-repo handle; deleted on teardown."""
ident = f"-/deepagents-deploy-test-{uuid.uuid4().hex[:12]}"
yield ident
try:
from langsmith import Client
Client().delete_agent(ident)
except Exception:
logger.warning("Failed to delete test repo %r", ident, exc_info=True)
def _scaffold_project(project: Path) -> None:
"""Drop a minimal AGENTS.md + one skill into *project*."""
project.mkdir(parents=True, exist_ok=True)
(project / AGENTS_MD_FILENAME).write_text(
"# Deploy hub integration test\n\nThis agent exists to validate "
"the hub-backed bundle.\n",
encoding="utf-8",
)
skill_dir = project / SKILLS_DIRNAME / "echo"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: echo\ndescription: Echo the user input.\n---\n"
"# Echo\n\nReturn the user input verbatim.\n",
encoding="utf-8",
)
def _load_vendored_hub_backend(build_dir: Path) -> types.ModuleType:
"""Import the bundle's vendored ContextHubBackend module."""
sys.path.insert(0, str(build_dir))
try:
# Fresh import so we pick up the copy in this specific build dir.
if "_context_hub" in sys.modules:
del sys.modules["_context_hub"]
return importlib.import_module("_context_hub")
finally:
sys.path.remove(str(build_dir))
def test_hub_bundle_seeds_through_composite(
tmp_path: Path, hub_identifier: str
) -> None:
"""End-to-end: bundle a hub-backed project and seed it into a real repo."""
from deepagents.backends.composite import CompositeBackend
from deepagents.backends.state import StateBackend
from langsmith import Client
project = tmp_path / "project"
_scaffold_project(project)
build = tmp_path / "build"
config = DeployConfig(
agent=AgentConfig(name="deploy-hub-test"),
memories=MemoriesConfig(backend="hub", identifier=hub_identifier),
)
bundle(config, project, build)
# Bundle-level assertions: the vendored module and the hub wiring land.
assert (build / "_context_hub.py").exists()
graph_src = (build / "deploy_graph.py").read_text(encoding="utf-8")
assert f"MEMORIES_HUB_IDENTIFIER = '{hub_identifier}'" in graph_src
assert "from _context_hub import ContextHubBackend" in graph_src
# Build a composite matching the one the generated graph builds, and
# exercise the seed path end-to-end against a real hub.
ctx_hub_mod = _load_vendored_hub_backend(build)
hub_backend = ctx_hub_mod.ContextHubBackend(identifier=hub_identifier)
composite = CompositeBackend(
default=StateBackend(),
routes={"/memories/": hub_backend},
)
seed = json.loads((build / "_seed.json").read_text(encoding="utf-8"))
async def _run() -> None:
for path, content in seed.get("memories", {}).items():
result = await composite.awrite(f"/memories/{path.lstrip('/')}", content)
assert result.error is None, result.error
for path, content in seed.get("skills", {}).items():
result = await composite.awrite(
f"/memories/skills/{path.lstrip('/')}", content
)
assert result.error is None, result.error
asyncio.run(_run())
# Fresh client pull verifies writes reached the hub (not just cache).
pulled = Client().pull_agent(hub_identifier)
pulled_paths = set(pulled.files.keys())
assert "AGENTS.md" in pulled_paths
assert "skills/echo/SKILL.md" in pulled_paths
# Reads through a brand-new backend should round-trip the seeded content.
fresh_backend = ctx_hub_mod.ContextHubBackend(identifier=hub_identifier)
read = fresh_backend.read("/AGENTS.md")
assert read.error is None
assert read.file_data is not None
assert "Deploy hub integration test" in read.file_data["content"]
def test_seed_hub_repo_creates_repo_before_invocation(
tmp_path: Path, hub_identifier: str
) -> None:
"""`_seed_hub_repo` must create the hub repo at deploy time, not first run.
Bundles a minimal hub-backed project, runs the CLI seed helper directly,
and asserts the agent repo exists in LangSmith Hub — proving the agent
is created before any graph invocation.
"""
from langsmith import Client
from deepagents_cli.deploy.commands import _seed_hub_repo
project = tmp_path / "project"
_scaffold_project(project)
build = tmp_path / "build"
config = DeployConfig(
agent=AgentConfig(name="deploy-hub-test"),
memories=MemoriesConfig(backend="hub", identifier=hub_identifier),
)
bundle(config, project, build)
_seed_hub_repo(config, build)
pulled = Client().pull_agent(hub_identifier)
assert "AGENTS.md" in pulled.files
def test_store_bundle_omits_vendored_hub(tmp_path: Path) -> None:
"""Regression: default store mode must not ship the hub module."""
project = tmp_path / "project"
_scaffold_project(project)
build = tmp_path / "build"
bundle(
DeployConfig(agent=AgentConfig(name="store-only")),
project,
build,
)
# The vendored module is only shipped in hub mode.
assert not (build / "_context_hub.py").exists()
graph_src = (build / "deploy_graph.py").read_text(encoding="utf-8")
assert "MEMORIES_BACKEND = 'store'" in graph_src
@@ -0,0 +1,3 @@
# Research Assistant
You are a careful research assistant.
@@ -0,0 +1,4 @@
{
"name": "research-assistant",
"description": "Researches a topic and returns a summary."
}
@@ -0,0 +1,5 @@
{
"name": "research-assistant",
"description": "Researches a topic and returns a summary.",
"system_prompt": "# Research Assistant\n\nYou are a careful research assistant.\n"
}
@@ -0,0 +1 @@
{"name": "parent"}
@@ -0,0 +1,21 @@
{
"name": "parent",
"system_prompt": "Parent prompt",
"subagents": [
{
"name": "researcher",
"description": "Researches a topic.",
"model_id": "anthropic:claude-sonnet-4-6",
"instructions": "You research a topic and summarise.",
"tools": {
"tools": [{"name": "search", "mcp_server_url": "https://tools.example"}],
"interrupt_config": {}
}
}
],
"files": {
"subagents/researcher/skills/note/SKILL.md": {
"content": "---\nname: note\ndescription: Take a note.\n---\nTake a note."
}
}
}
@@ -0,0 +1 @@
You research a topic and summarise.
@@ -0,0 +1 @@
{"description": "Researches a topic.", "model_id": "anthropic:claude-sonnet-4-6"}
@@ -0,0 +1,5 @@
---
name: note
description: Take a note.
---
Take a note.
@@ -0,0 +1 @@
{"tools": [{"name": "search", "mcp_server_url": "https://tools.example"}], "interrupt_config": {}}
@@ -0,0 +1 @@
{"name": "x"}
@@ -0,0 +1,15 @@
{
"name": "x",
"system_prompt": "hi\n",
"skills": [
{
"type": "inline",
"name": "summarize",
"description": "Summarise text into a one-paragraph summary.",
"instructions": "# Summarize\n\nGiven a text, produce a one-paragraph summary.",
"files": {
"examples.md": "- Example 1: ...\n- Example 2: ...\n"
}
}
]
}

Some files were not shown because too many files have changed in this diff Show More