mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"docs-langchain": {
|
||||
"type": "http",
|
||||
"url": "https://docs.langchain.com/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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/")
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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
@@ -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 +0,0 @@
|
||||
24
|
||||
@@ -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>
|
||||
Generated
-6142
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) ?? "";
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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>;
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
Parent prompt
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"name": "parent"}
|
||||
+21
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
You research a topic and summarise.
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"description": "Researches a topic.", "model_id": "anthropic:claude-sonnet-4-6"}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
name: note
|
||||
description: Take a note.
|
||||
---
|
||||
Take a note.
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"tools": [{"name": "search", "mcp_server_url": "https://tools.example"}], "interrupt_config": {}}
|
||||
@@ -0,0 +1 @@
|
||||
hi
|
||||
@@ -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
Reference in New Issue
Block a user