project init
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
.python-version
|
||||
|
||||
# Secret Environment
|
||||
.env
|
||||
|
||||
# Cache
|
||||
*_cache/
|
||||
*_api/
|
||||
|
||||
# AI
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
GEMINI.md
|
||||
QWEN.md
|
||||
.serena/
|
||||
2500
DeepAgent_research.ipynb
Normal file
1300
DeepAgents_Technical_Guide.md
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 HyunjunJeon
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
56
README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# DeepAgent Context Engineering
|
||||
|
||||
FileSystem 기반 Context Engineering 을 원활히 수행하는 Multi Agent 구성을 위한 DeepAgent(From LangChain's deepagents library)
|
||||
|
||||
## DeepAgent Technical Guide
|
||||
|
||||
[DeepAgent Technical Guide](./DeepAgents_Technical_Guide.md)
|
||||
|
||||
## DeepAgent - Research
|
||||
|
||||
### 모듈 구조 요약
|
||||
|
||||
```
|
||||
research_agent/
|
||||
├── agent.py # 메인 오케스트레이터 (create_deep_agent)
|
||||
├── prompts.py # 오케스트레이터 및 Simple SubAgent 프롬프트
|
||||
├── tools.py # tavily_search, think_tool
|
||||
├── utils.py # 노트북 시각화 헬퍼
|
||||
│
|
||||
├── researcher/ # 자율적 연구 에이전트 모듈 (NEW)
|
||||
│ ├── __init__.py # 모듈 exports
|
||||
│ ├── agent.py # create_researcher_agent, get_researcher_subagent
|
||||
│ └── prompts.py # AUTONOMOUS_RESEARCHER_INSTRUCTIONS
|
||||
│
|
||||
├── skills/ # Skills 미들웨어
|
||||
│ └── middleware.py # SkillsMiddleware (Progressive Disclosure)
|
||||
│
|
||||
└── subagents/ # SubAgent 유틸리티
|
||||
└── definitions.py # SubAgent 정의 헬퍼
|
||||
```
|
||||
|
||||
### 핵심 파일 설명
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `agent.py` | 메인 에이전트 생성 및 구성 |
|
||||
| `researcher/agent.py` | CompiledSubAgent 패턴의 자율적 연구 에이전트 |
|
||||
| `researcher/prompts.py` | "넓게 탐색 → 깊게 파기" 워크플로우 정의 |
|
||||
| `prompts.py` | 오케스트레이터 워크플로우 및 위임 전략 |
|
||||
|
||||
|
||||
## DeepAgent UI(made by LangChain)
|
||||
```bash
|
||||
git clone https://github.com/langchain-ai/deep-agents-ui.git
|
||||
cd deep-agents-ui
|
||||
npm install -g yarn
|
||||
yarn install
|
||||
yarn dev
|
||||
```
|
||||
|
||||
|
||||
### 참고자료
|
||||
|
||||
- [LangChain DeepAgent Docs](https://docs.langchain.com/oss/python/deepagents/overview)
|
||||
- [LangGraph CLI Docs](https://docs.langchain.com/langsmith/cli#configuration-file)
|
||||
- [DeepAgent UI](https://github.com/langchain-ai/deep-agents-ui)
|
||||
0
deep-agents-ui/.codespellignore
Normal file
41
deep-agents-ui/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
2
deep-agents-ui/.nvmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
20
|
||||
|
||||
31
deep-agents-ui/.prettierignore
Normal file
@@ -0,0 +1,31 @@
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
coverage
|
||||
|
||||
# next.js
|
||||
.next
|
||||
out
|
||||
|
||||
# production
|
||||
build
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# misc
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# lock files
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
package-lock.json
|
||||
21
deep-agents-ui/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 LangChain
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
93
deep-agents-ui/README.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# 🚀🧠 Deepagents UI
|
||||
|
||||
[Deepagents](https://github.com/langchain-ai/deepagents) is a simple, open source agent harness that implements a few generally useful tools, including planning (prior to task execution), computer access (giving the able access to a shell and a filesystem), and sub-agent delegation (isolated task execution). This is a UI for interacting with deepagents.
|
||||
|
||||
## 🚀 Quickstart
|
||||
|
||||
**Install dependencies and run the app**
|
||||
|
||||
```bash
|
||||
$ git clone https://github.com/langchain-ai/deep-agents-ui.git
|
||||
$ cd deep-agents-ui
|
||||
$ yarn install
|
||||
$ yarn dev
|
||||
```
|
||||
|
||||
**Deploy a deepagent**
|
||||
|
||||
As an example, see our [deepagents quickstart](https://github.com/langchain-ai/deepagents-quickstarts/tree/main/deep_research) repo for an example and run the `deep_research` example.
|
||||
|
||||
The `langgraph.json` file has the assistant ID as the key:
|
||||
|
||||
```
|
||||
"graphs": {
|
||||
"research": "./agent.py:agent"
|
||||
},
|
||||
```
|
||||
|
||||
Kick off the local LangGraph deployment:
|
||||
|
||||
```bash
|
||||
$ cd deepagents-quickstarts/deep_research
|
||||
$ langgraph dev
|
||||
```
|
||||
|
||||
You will see the local LangGraph deployment log to terminal:
|
||||
|
||||
```
|
||||
╦ ┌─┐┌┐┌┌─┐╔═╗┬─┐┌─┐┌─┐┬ ┬
|
||||
║ ├─┤││││ ┬║ ╦├┬┘├─┤├─┘├─┤
|
||||
╩═╝┴ ┴┘└┘└─┘╚═╝┴└─┴ ┴┴ ┴ ┴
|
||||
|
||||
- 🚀 API: http://127.0.0.1:2024
|
||||
- 🎨 Studio UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024
|
||||
- 📚 API Docs: http://127.0.0.1:2024/docs
|
||||
...
|
||||
```
|
||||
|
||||
You can get the Deployment URL and Assistant ID from the terminal output and `langgraph.json` file, respectively:
|
||||
|
||||
- Deployment URL: http://127.0.1:2024
|
||||
- Assistant ID: `research`
|
||||
|
||||
**Open Deepagents UI** at [http://localhost:3000](http://localhost:3000) and input the Deployment URL and Assistant ID:
|
||||
|
||||
- **Deployment URL**: The URL for the LangGraph deployment you are connecting to
|
||||
- **Assistant ID**: The ID of the assistant or agent you want to use
|
||||
- [Optional] **LangSmith API Key**: Your LangSmith API key (format: `lsv2_pt_...`). This may be required for accessing deployed LangGraph applications. You can also provide this via the `NEXT_PUBLIC_LANGSMITH_API_KEY` environment variable.
|
||||
|
||||
**Usagee**
|
||||
|
||||
You can interact with the deployment via the chat interface and can edit settings at any time by clicking on the Settings button in the header.
|
||||
|
||||
<img width="2039" height="1495" alt="Screenshot 2025-11-17 at 1 11 27 PM" src="https://github.com/user-attachments/assets/50e1b5f3-a626-4461-9ad9-90347e471e8c" />
|
||||
|
||||
As the deepagent runs, you can see its files in LangGraph state.
|
||||
|
||||
<img width="2039" height="1495" alt="Screenshot 2025-11-17 at 1 11 36 PM" src="https://github.com/user-attachments/assets/86cc6228-5414-4cf0-90f5-d206d30c005e" />
|
||||
|
||||
You can click on any file to view it.
|
||||
|
||||
<img width="2039" height="1495" alt="Screenshot 2025-11-17 at 1 11 40 PM" src="https://github.com/user-attachments/assets/9883677f-e365-428d-b941-992bdbfa79dd" />
|
||||
|
||||
### Optional: Environment Variables
|
||||
|
||||
You can optionally set environment variables instead of using the settings dialog:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_LANGSMITH_API_KEY="lsv2_xxxx"
|
||||
```
|
||||
|
||||
**Note:** Settings configured in the UI take precedence over environment variables.
|
||||
|
||||
### Usage
|
||||
|
||||
You can run your Deep Agents in Debug Mode, which will execute the agent step by step. This will allow you to re-run the specific steps of the agent. This is intended to be used alongside the optimizer.
|
||||
|
||||
You can also turn off Debug Mode to run the full agent end-to-end.
|
||||
|
||||
### 📚 Resources
|
||||
|
||||
If the term "Deep Agents" is new to you, check out these videos!
|
||||
[What are Deep Agents?](https://www.youtube.com/watch?v=433SmtTc0TA)
|
||||
[Implementing Deep Agents](https://www.youtube.com/watch?v=TTMYJAw5tiA&t=701s)
|
||||
20
deep-agents-ui/components.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
33
deep-agents-ui/eslint.config.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", ".next", "node_modules"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"@typescript-eslint/no-explicit-any": 0,
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ args: "none", argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
7
deep-agents-ui/next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
77
deep-agents-ui/package.json
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"name": "deep-agents-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/core": "^1.1.1",
|
||||
"@langchain/langgraph": "^1.0.2",
|
||||
"@langchain/langgraph-sdk": "^1.0.3",
|
||||
"@radix-ui/colors": "^1.0.0",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@types/diff": "^5.0.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^1.2.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"diff": "^8.0.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.539.0",
|
||||
"next": "^16.0.7",
|
||||
"nuqs": "^2.4.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sass": "^1.90.0",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.3.6",
|
||||
"tailwind-merge": "^2.6",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@headlessui/tailwindcss": "^0.2.2",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.37.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
}
|
||||
7
deep-agents-ui/postcss.config.cjs
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"tailwindcss/nesting": {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
11
deep-agents-ui/prettier.config.cjs
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @see https://prettier.io/docs/configuration
|
||||
* @type {import("prettier").Config}
|
||||
*/
|
||||
const config = {
|
||||
endOfLine: "auto",
|
||||
singleAttributePerLine: true,
|
||||
plugins: ["prettier-plugin-tailwindcss"],
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
1
deep-agents-ui/public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
deep-agents-ui/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
deep-agents-ui/public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
deep-agents-ui/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
deep-agents-ui/public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
545
deep-agents-ui/src/app/components/ChatInterface.tsx
Normal file
@@ -0,0 +1,545 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
FormEvent,
|
||||
Fragment,
|
||||
} from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Square,
|
||||
ArrowUp,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Circle,
|
||||
FileIcon,
|
||||
} from "lucide-react";
|
||||
import { ChatMessage } from "@/app/components/ChatMessage";
|
||||
import type {
|
||||
TodoItem,
|
||||
ToolCall,
|
||||
ActionRequest,
|
||||
ReviewConfig,
|
||||
} from "@/app/types/types";
|
||||
import { Assistant, Message } from "@langchain/langgraph-sdk";
|
||||
import { extractStringFromMessageContent } from "@/app/utils/utils";
|
||||
import { useChatContext } from "@/providers/ChatProvider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useStickToBottom } from "use-stick-to-bottom";
|
||||
import { FilesPopover } from "@/app/components/TasksFilesSidebar";
|
||||
|
||||
interface ChatInterfaceProps {
|
||||
assistant: Assistant | null;
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: TodoItem["status"], className?: string) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return (
|
||||
<CheckCircle
|
||||
size={16}
|
||||
className={cn("text-success/80", className)}
|
||||
/>
|
||||
);
|
||||
case "in_progress":
|
||||
return (
|
||||
<Clock
|
||||
size={16}
|
||||
className={cn("text-warning/80", className)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Circle
|
||||
size={16}
|
||||
className={cn("text-tertiary/70", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
|
||||
const [metaOpen, setMetaOpen] = useState<"tasks" | "files" | null>(null);
|
||||
const tasksContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const { scrollRef, contentRef } = useStickToBottom();
|
||||
|
||||
const {
|
||||
stream,
|
||||
messages,
|
||||
todos,
|
||||
files,
|
||||
ui,
|
||||
setFiles,
|
||||
isLoading,
|
||||
isThreadLoading,
|
||||
interrupt,
|
||||
sendMessage,
|
||||
stopStream,
|
||||
resumeInterrupt,
|
||||
} = useChatContext();
|
||||
|
||||
const submitDisabled = isLoading || !assistant;
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e?: FormEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
const messageText = input.trim();
|
||||
if (!messageText || isLoading || submitDisabled) return;
|
||||
sendMessage(messageText);
|
||||
setInput("");
|
||||
},
|
||||
[input, isLoading, sendMessage, setInput, submitDisabled]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (submitDisabled) return;
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit, submitDisabled]
|
||||
);
|
||||
|
||||
// TODO: can we make this part of the hook?
|
||||
const processedMessages = useMemo(() => {
|
||||
/*
|
||||
1. Loop through all messages
|
||||
2. For each AI message, add the AI message, and any tool calls to the messageMap
|
||||
3. For each tool message, find the corresponding tool call in the messageMap and update the status and output
|
||||
*/
|
||||
const messageMap = new Map<
|
||||
string,
|
||||
{ message: Message; toolCalls: ToolCall[] }
|
||||
>();
|
||||
messages.forEach((message: Message) => {
|
||||
if (message.type === "ai") {
|
||||
const toolCallsInMessage: Array<{
|
||||
id?: string;
|
||||
function?: { name?: string; arguments?: unknown };
|
||||
name?: string;
|
||||
type?: string;
|
||||
args?: unknown;
|
||||
input?: unknown;
|
||||
}> = [];
|
||||
if (
|
||||
message.additional_kwargs?.tool_calls &&
|
||||
Array.isArray(message.additional_kwargs.tool_calls)
|
||||
) {
|
||||
toolCallsInMessage.push(...message.additional_kwargs.tool_calls);
|
||||
} else if (message.tool_calls && Array.isArray(message.tool_calls)) {
|
||||
toolCallsInMessage.push(
|
||||
...message.tool_calls.filter(
|
||||
(toolCall: { name?: string }) => toolCall.name !== ""
|
||||
)
|
||||
);
|
||||
} else if (Array.isArray(message.content)) {
|
||||
const toolUseBlocks = message.content.filter(
|
||||
(block: { type?: string }) => block.type === "tool_use"
|
||||
);
|
||||
toolCallsInMessage.push(...toolUseBlocks);
|
||||
}
|
||||
const toolCallsWithStatus = toolCallsInMessage.map(
|
||||
(toolCall: {
|
||||
id?: string;
|
||||
function?: { name?: string; arguments?: unknown };
|
||||
name?: string;
|
||||
type?: string;
|
||||
args?: unknown;
|
||||
input?: unknown;
|
||||
}) => {
|
||||
const name =
|
||||
toolCall.function?.name ||
|
||||
toolCall.name ||
|
||||
toolCall.type ||
|
||||
"unknown";
|
||||
const args =
|
||||
toolCall.function?.arguments ||
|
||||
toolCall.args ||
|
||||
toolCall.input ||
|
||||
{};
|
||||
return {
|
||||
id: toolCall.id || `tool-${Math.random()}`,
|
||||
name,
|
||||
args,
|
||||
status: interrupt ? "interrupted" : ("pending" as const),
|
||||
} as ToolCall;
|
||||
}
|
||||
);
|
||||
messageMap.set(message.id!, {
|
||||
message,
|
||||
toolCalls: toolCallsWithStatus,
|
||||
});
|
||||
} else if (message.type === "tool") {
|
||||
const toolCallId = message.tool_call_id;
|
||||
if (!toolCallId) {
|
||||
return;
|
||||
}
|
||||
for (const [, data] of messageMap.entries()) {
|
||||
const toolCallIndex = data.toolCalls.findIndex(
|
||||
(tc: ToolCall) => tc.id === toolCallId
|
||||
);
|
||||
if (toolCallIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
data.toolCalls[toolCallIndex] = {
|
||||
...data.toolCalls[toolCallIndex],
|
||||
status: "completed" as const,
|
||||
result: extractStringFromMessageContent(message),
|
||||
};
|
||||
break;
|
||||
}
|
||||
} else if (message.type === "human") {
|
||||
messageMap.set(message.id!, {
|
||||
message,
|
||||
toolCalls: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
const processedArray = Array.from(messageMap.values());
|
||||
return processedArray.map((data, index) => {
|
||||
const prevMessage = index > 0 ? processedArray[index - 1].message : null;
|
||||
return {
|
||||
...data,
|
||||
showAvatar: data.message.type !== prevMessage?.type,
|
||||
};
|
||||
});
|
||||
}, [messages, interrupt]);
|
||||
|
||||
const groupedTodos = {
|
||||
in_progress: todos.filter((t) => t.status === "in_progress"),
|
||||
pending: todos.filter((t) => t.status === "pending"),
|
||||
completed: todos.filter((t) => t.status === "completed"),
|
||||
};
|
||||
|
||||
const hasTasks = todos.length > 0;
|
||||
const hasFiles = Object.keys(files).length > 0;
|
||||
|
||||
// Parse out any action requests or review configs from the interrupt
|
||||
const actionRequestsMap: Map<string, ActionRequest> | null = useMemo(() => {
|
||||
const actionRequests =
|
||||
interrupt?.value && (interrupt.value as any)["action_requests"];
|
||||
if (!actionRequests) return new Map<string, ActionRequest>();
|
||||
return new Map(actionRequests.map((ar: ActionRequest) => [ar.name, ar]));
|
||||
}, [interrupt]);
|
||||
|
||||
const reviewConfigsMap: Map<string, ReviewConfig> | null = useMemo(() => {
|
||||
const reviewConfigs =
|
||||
interrupt?.value && (interrupt.value as any)["review_configs"];
|
||||
if (!reviewConfigs) return new Map<string, ReviewConfig>();
|
||||
return new Map(
|
||||
reviewConfigs.map((rc: ReviewConfig) => [rc.actionName, rc])
|
||||
);
|
||||
}, [interrupt]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"
|
||||
ref={scrollRef}
|
||||
>
|
||||
<div
|
||||
className="mx-auto w-full max-w-[1024px] px-6 pb-6 pt-4"
|
||||
ref={contentRef}
|
||||
>
|
||||
{isThreadLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{processedMessages.map((data, index) => {
|
||||
const messageUi = ui?.filter(
|
||||
(u: any) => u.metadata?.message_id === data.message.id
|
||||
);
|
||||
const isLastMessage = index === processedMessages.length - 1;
|
||||
return (
|
||||
<ChatMessage
|
||||
key={data.message.id}
|
||||
message={data.message}
|
||||
toolCalls={data.toolCalls}
|
||||
isLoading={isLoading}
|
||||
actionRequestsMap={
|
||||
isLastMessage ? actionRequestsMap : undefined
|
||||
}
|
||||
reviewConfigsMap={
|
||||
isLastMessage ? reviewConfigsMap : undefined
|
||||
}
|
||||
ui={messageUi}
|
||||
stream={stream}
|
||||
onResumeInterrupt={resumeInterrupt}
|
||||
graphId={assistant?.graph_id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 bg-background">
|
||||
<div
|
||||
className={cn(
|
||||
"mx-4 mb-6 flex flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-background",
|
||||
"mx-auto w-[calc(100%-32px)] max-w-[1024px] transition-colors duration-200 ease-in-out"
|
||||
)}
|
||||
>
|
||||
{(hasTasks || hasFiles) && (
|
||||
<div className="flex max-h-72 flex-col overflow-y-auto border-b border-border bg-sidebar empty:hidden">
|
||||
{!metaOpen && (
|
||||
<>
|
||||
{(() => {
|
||||
const activeTask = todos.find(
|
||||
(t) => t.status === "in_progress"
|
||||
);
|
||||
|
||||
const totalTasks = todos.length;
|
||||
const remainingTasks =
|
||||
totalTasks - groupedTodos.pending.length;
|
||||
const isCompleted = totalTasks === remainingTasks;
|
||||
|
||||
const tasksTrigger = (() => {
|
||||
if (!hasTasks) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setMetaOpen((prev) =>
|
||||
prev === "tasks" ? null : "tasks"
|
||||
)
|
||||
}
|
||||
className="grid w-full cursor-pointer grid-cols-[auto_auto_1fr] items-center gap-3 px-[18px] py-3 text-left"
|
||||
aria-expanded={metaOpen === "tasks"}
|
||||
>
|
||||
{(() => {
|
||||
if (isCompleted) {
|
||||
return [
|
||||
<CheckCircle
|
||||
key="icon"
|
||||
size={16}
|
||||
className="text-success/80"
|
||||
/>,
|
||||
<span
|
||||
key="label"
|
||||
className="ml-[1px] min-w-0 truncate text-sm"
|
||||
>
|
||||
All tasks completed
|
||||
</span>,
|
||||
];
|
||||
}
|
||||
|
||||
if (activeTask != null) {
|
||||
return [
|
||||
<div key="icon">
|
||||
{getStatusIcon(activeTask.status)}
|
||||
</div>,
|
||||
<span
|
||||
key="label"
|
||||
className="ml-[1px] min-w-0 truncate text-sm"
|
||||
>
|
||||
Task{" "}
|
||||
{totalTasks - groupedTodos.pending.length} of{" "}
|
||||
{totalTasks}
|
||||
</span>,
|
||||
<span
|
||||
key="content"
|
||||
className="min-w-0 gap-2 truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{activeTask.content}
|
||||
</span>,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
<Circle
|
||||
key="icon"
|
||||
size={16}
|
||||
className="text-tertiary/70"
|
||||
/>,
|
||||
<span
|
||||
key="label"
|
||||
className="ml-[1px] min-w-0 truncate text-sm"
|
||||
>
|
||||
Task {totalTasks - groupedTodos.pending.length}{" "}
|
||||
of {totalTasks}
|
||||
</span>,
|
||||
];
|
||||
})()}
|
||||
</button>
|
||||
);
|
||||
})();
|
||||
|
||||
const filesTrigger = (() => {
|
||||
if (!hasFiles) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setMetaOpen((prev) =>
|
||||
prev === "files" ? null : "files"
|
||||
)
|
||||
}
|
||||
className="flex flex-shrink-0 cursor-pointer items-center gap-2 px-[18px] py-3 text-left text-sm"
|
||||
aria-expanded={metaOpen === "files"}
|
||||
>
|
||||
<FileIcon size={16} />
|
||||
Files (State)
|
||||
<span className="h-4 min-w-4 rounded-full bg-[#2F6868] px-0.5 text-center text-[10px] leading-[16px] text-white">
|
||||
{Object.keys(files).length}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto_auto] items-center">
|
||||
{tasksTrigger}
|
||||
{filesTrigger}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
|
||||
{metaOpen && (
|
||||
<>
|
||||
<div className="sticky top-0 flex items-stretch bg-sidebar text-sm">
|
||||
{hasTasks && (
|
||||
<button
|
||||
type="button"
|
||||
className="py-3 pr-4 first:pl-[18px] aria-expanded:font-semibold"
|
||||
onClick={() =>
|
||||
setMetaOpen((prev) =>
|
||||
prev === "tasks" ? null : "tasks"
|
||||
)
|
||||
}
|
||||
aria-expanded={metaOpen === "tasks"}
|
||||
>
|
||||
Tasks
|
||||
</button>
|
||||
)}
|
||||
{hasFiles && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 py-3 pr-4 first:pl-[18px] aria-expanded:font-semibold"
|
||||
onClick={() =>
|
||||
setMetaOpen((prev) =>
|
||||
prev === "files" ? null : "files"
|
||||
)
|
||||
}
|
||||
aria-expanded={metaOpen === "files"}
|
||||
>
|
||||
Files (State)
|
||||
<span className="h-4 min-w-4 rounded-full bg-[#2F6868] px-0.5 text-center text-[10px] leading-[16px] text-white">
|
||||
{Object.keys(files).length}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label="Close"
|
||||
className="flex-1"
|
||||
onClick={() => setMetaOpen(null)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={tasksContainerRef}
|
||||
className="px-[18px]"
|
||||
>
|
||||
{metaOpen === "tasks" &&
|
||||
Object.entries(groupedTodos)
|
||||
.filter(([_, todos]) => todos.length > 0)
|
||||
.map(([status, todos]) => (
|
||||
<div
|
||||
key={status}
|
||||
className="mb-4"
|
||||
>
|
||||
<h3 className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-tertiary">
|
||||
{
|
||||
{
|
||||
pending: "Pending",
|
||||
in_progress: "In Progress",
|
||||
completed: "Completed",
|
||||
}[status]
|
||||
}
|
||||
</h3>
|
||||
<div className="grid grid-cols-[auto_1fr] gap-3 rounded-sm p-1 pl-0 text-sm">
|
||||
{todos.map((todo, index) => (
|
||||
<Fragment key={`${status}_${todo.id}_${index}`}>
|
||||
{getStatusIcon(todo.status, "mt-0.5")}
|
||||
<span className="break-words text-inherit">
|
||||
{todo.content}
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{metaOpen === "files" && (
|
||||
<div className="mb-6">
|
||||
<FilesPopover
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
editDisabled={
|
||||
isLoading === true || interrupt !== undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={isLoading ? "Running..." : "Write your message..."}
|
||||
className="font-inherit field-sizing-content flex-1 resize-none border-0 bg-transparent px-[18px] pb-[13px] pt-[14px] text-sm leading-7 text-primary outline-none placeholder:text-tertiary"
|
||||
rows={1}
|
||||
/>
|
||||
<div className="flex justify-between gap-2 p-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type={isLoading ? "button" : "submit"}
|
||||
variant={isLoading ? "destructive" : "default"}
|
||||
onClick={isLoading ? stopStream : handleSubmit}
|
||||
disabled={!isLoading && (submitDisabled || !input.trim())}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Square size={14} />
|
||||
<span>Stop</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowUp size={18} />
|
||||
<span>Send</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ChatInterface.displayName = "ChatInterface";
|
||||
200
deep-agents-ui/src/app/components/ChatMessage.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState, useCallback } from "react";
|
||||
import { SubAgentIndicator } from "@/app/components/SubAgentIndicator";
|
||||
import { ToolCallBox } from "@/app/components/ToolCallBox";
|
||||
import { MarkdownContent } from "@/app/components/MarkdownContent";
|
||||
import type {
|
||||
SubAgent,
|
||||
ToolCall,
|
||||
ActionRequest,
|
||||
ReviewConfig,
|
||||
} from "@/app/types/types";
|
||||
import { Message } from "@langchain/langgraph-sdk";
|
||||
import {
|
||||
extractSubAgentContent,
|
||||
extractStringFromMessageContent,
|
||||
} from "@/app/utils/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: Message;
|
||||
toolCalls: ToolCall[];
|
||||
isLoading?: boolean;
|
||||
actionRequestsMap?: Map<string, ActionRequest>;
|
||||
reviewConfigsMap?: Map<string, ReviewConfig>;
|
||||
ui?: any[];
|
||||
stream?: any;
|
||||
onResumeInterrupt?: (value: any) => void;
|
||||
graphId?: string;
|
||||
}
|
||||
|
||||
export const ChatMessage = React.memo<ChatMessageProps>(
|
||||
({
|
||||
message,
|
||||
toolCalls,
|
||||
isLoading,
|
||||
actionRequestsMap,
|
||||
reviewConfigsMap,
|
||||
ui,
|
||||
stream,
|
||||
onResumeInterrupt,
|
||||
graphId,
|
||||
}) => {
|
||||
const isUser = message.type === "human";
|
||||
const messageContent = extractStringFromMessageContent(message);
|
||||
const hasContent = messageContent && messageContent.trim() !== "";
|
||||
const hasToolCalls = toolCalls.length > 0;
|
||||
const subAgents = useMemo(() => {
|
||||
return toolCalls
|
||||
.filter((toolCall: ToolCall) => {
|
||||
return (
|
||||
toolCall.name === "task" &&
|
||||
toolCall.args["subagent_type"] &&
|
||||
toolCall.args["subagent_type"] !== "" &&
|
||||
toolCall.args["subagent_type"] !== null
|
||||
);
|
||||
})
|
||||
.map((toolCall: ToolCall) => {
|
||||
const subagentType = (toolCall.args as Record<string, unknown>)[
|
||||
"subagent_type"
|
||||
] as string;
|
||||
return {
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
subAgentName: subagentType,
|
||||
input: toolCall.args,
|
||||
output: toolCall.result ? { result: toolCall.result } : undefined,
|
||||
status: toolCall.status,
|
||||
} as SubAgent;
|
||||
});
|
||||
}, [toolCalls]);
|
||||
|
||||
const [expandedSubAgents, setExpandedSubAgents] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const isSubAgentExpanded = useCallback(
|
||||
(id: string) => expandedSubAgents[id] ?? true,
|
||||
[expandedSubAgents]
|
||||
);
|
||||
const toggleSubAgent = useCallback((id: string) => {
|
||||
setExpandedSubAgents((prev) => ({
|
||||
...prev,
|
||||
[id]: prev[id] === undefined ? false : !prev[id],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full max-w-full overflow-x-hidden",
|
||||
isUser && "flex-row-reverse"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 max-w-full",
|
||||
isUser ? "max-w-[70%]" : "w-full"
|
||||
)}
|
||||
>
|
||||
{hasContent && (
|
||||
<div className={cn("relative flex items-end gap-0")}>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 overflow-hidden break-words text-sm font-normal leading-[150%]",
|
||||
isUser
|
||||
? "rounded-xl rounded-br-none border border-border px-3 py-2 text-foreground"
|
||||
: "text-primary"
|
||||
)}
|
||||
style={
|
||||
isUser
|
||||
? { backgroundColor: "var(--color-user-message-bg)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isUser ? (
|
||||
<p className="m-0 whitespace-pre-wrap break-words text-sm leading-relaxed">
|
||||
{messageContent}
|
||||
</p>
|
||||
) : hasContent ? (
|
||||
<MarkdownContent content={messageContent} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasToolCalls && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
{toolCalls.map((toolCall: ToolCall) => {
|
||||
if (toolCall.name === "task") return null;
|
||||
const toolCallGenUiComponent = ui?.find(
|
||||
(u) => u.metadata?.tool_call_id === toolCall.id
|
||||
);
|
||||
const actionRequest = actionRequestsMap?.get(toolCall.name);
|
||||
const reviewConfig = reviewConfigsMap?.get(toolCall.name);
|
||||
return (
|
||||
<ToolCallBox
|
||||
key={toolCall.id}
|
||||
toolCall={toolCall}
|
||||
uiComponent={toolCallGenUiComponent}
|
||||
stream={stream}
|
||||
graphId={graphId}
|
||||
actionRequest={actionRequest}
|
||||
reviewConfig={reviewConfig}
|
||||
onResume={onResumeInterrupt}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!isUser && subAgents.length > 0 && (
|
||||
<div className="flex w-fit max-w-full flex-col gap-4">
|
||||
{subAgents.map((subAgent) => (
|
||||
<div
|
||||
key={subAgent.id}
|
||||
className="flex w-full flex-col gap-2"
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="w-[calc(100%-100px)]">
|
||||
<SubAgentIndicator
|
||||
subAgent={subAgent}
|
||||
onClick={() => toggleSubAgent(subAgent.id)}
|
||||
isExpanded={isSubAgentExpanded(subAgent.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isSubAgentExpanded(subAgent.id) && (
|
||||
<div className="w-full max-w-full">
|
||||
<div className="bg-surface border-border-light rounded-md border p-4">
|
||||
<h4 className="text-primary/70 mb-2 text-xs font-semibold uppercase tracking-wider">
|
||||
Input
|
||||
</h4>
|
||||
<div className="mb-4">
|
||||
<MarkdownContent
|
||||
content={extractSubAgentContent(subAgent.input)}
|
||||
/>
|
||||
</div>
|
||||
{subAgent.output && (
|
||||
<>
|
||||
<h4 className="text-primary/70 mb-2 text-xs font-semibold uppercase tracking-wider">
|
||||
Output
|
||||
</h4>
|
||||
<MarkdownContent
|
||||
content={extractSubAgentContent(subAgent.output)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ChatMessage.displayName = "ChatMessage";
|
||||
120
deep-agents-ui/src/app/components/ConfigDialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { StandaloneConfig } from "@/lib/config";
|
||||
|
||||
interface ConfigDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (config: StandaloneConfig) => void;
|
||||
initialConfig?: StandaloneConfig;
|
||||
}
|
||||
|
||||
export function ConfigDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
initialConfig,
|
||||
}: ConfigDialogProps) {
|
||||
const [deploymentUrl, setDeploymentUrl] = useState(
|
||||
initialConfig?.deploymentUrl || ""
|
||||
);
|
||||
const [assistantId, setAssistantId] = useState(
|
||||
initialConfig?.assistantId || ""
|
||||
);
|
||||
const [langsmithApiKey, setLangsmithApiKey] = useState(
|
||||
initialConfig?.langsmithApiKey || ""
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && initialConfig) {
|
||||
setDeploymentUrl(initialConfig.deploymentUrl);
|
||||
setAssistantId(initialConfig.assistantId);
|
||||
setLangsmithApiKey(initialConfig.langsmithApiKey || "");
|
||||
}
|
||||
}, [open, initialConfig]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!deploymentUrl || !assistantId) {
|
||||
alert("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
|
||||
onSave({
|
||||
deploymentUrl,
|
||||
assistantId,
|
||||
langsmithApiKey: langsmithApiKey || undefined,
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[525px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configuration</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure your LangGraph deployment settings. These settings are
|
||||
saved in your browser's local storage.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="deploymentUrl">Deployment URL</Label>
|
||||
<Input
|
||||
id="deploymentUrl"
|
||||
placeholder="https://<deployment-url>"
|
||||
value={deploymentUrl}
|
||||
onChange={(e) => setDeploymentUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="assistantId">Assistant ID</Label>
|
||||
<Input
|
||||
id="assistantId"
|
||||
placeholder="<assistant-id>"
|
||||
value={assistantId}
|
||||
onChange={(e) => setAssistantId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="langsmithApiKey">
|
||||
LangSmith API Key{" "}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="langsmithApiKey"
|
||||
type="password"
|
||||
placeholder="lsv2_pt_..."
|
||||
value={langsmithApiKey}
|
||||
onChange={(e) => setLangsmithApiKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
296
deep-agents-ui/src/app/components/FileViewDialog.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useCallback, useState, useEffect } from "react";
|
||||
import { FileText, Copy, Download, Edit, Save, X, Loader2 } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { toast } from "sonner";
|
||||
import { MarkdownContent } from "@/app/components/MarkdownContent";
|
||||
import type { FileItem } from "@/app/types/types";
|
||||
import useSWRMutation from "swr/mutation";
|
||||
|
||||
const LANGUAGE_MAP: Record<string, string> = {
|
||||
js: "javascript",
|
||||
jsx: "javascript",
|
||||
ts: "typescript",
|
||||
tsx: "typescript",
|
||||
py: "python",
|
||||
rb: "ruby",
|
||||
go: "go",
|
||||
rs: "rust",
|
||||
java: "java",
|
||||
cpp: "cpp",
|
||||
c: "c",
|
||||
cs: "csharp",
|
||||
php: "php",
|
||||
swift: "swift",
|
||||
kt: "kotlin",
|
||||
scala: "scala",
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
zsh: "bash",
|
||||
json: "json",
|
||||
xml: "xml",
|
||||
html: "html",
|
||||
css: "css",
|
||||
scss: "scss",
|
||||
sass: "sass",
|
||||
less: "less",
|
||||
sql: "sql",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
toml: "toml",
|
||||
ini: "ini",
|
||||
dockerfile: "dockerfile",
|
||||
makefile: "makefile",
|
||||
};
|
||||
|
||||
export const FileViewDialog = React.memo<{
|
||||
file: FileItem | null;
|
||||
onSaveFile: (fileName: string, content: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
editDisabled: boolean;
|
||||
}>(({ file, onSaveFile, onClose, editDisabled }) => {
|
||||
const [isEditingMode, setIsEditingMode] = useState(file === null);
|
||||
const [fileName, setFileName] = useState(String(file?.path || ""));
|
||||
const [fileContent, setFileContent] = useState(String(file?.content || ""));
|
||||
|
||||
const fileUpdate = useSWRMutation(
|
||||
{ kind: "files-update", fileName, fileContent },
|
||||
async ({ fileName, fileContent }) => {
|
||||
if (!fileName || !fileContent) return;
|
||||
return await onSaveFile(fileName, fileContent);
|
||||
},
|
||||
{
|
||||
onSuccess: () => setIsEditingMode(false),
|
||||
onError: (error) => toast.error(`Failed to save file: ${error}`),
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFileName(String(file?.path || ""));
|
||||
setFileContent(String(file?.content || ""));
|
||||
setIsEditingMode(file === null);
|
||||
}, [file]);
|
||||
|
||||
const fileExtension = useMemo(() => {
|
||||
const fileNameStr = String(fileName || "");
|
||||
return fileNameStr.split(".").pop()?.toLowerCase() || "";
|
||||
}, [fileName]);
|
||||
|
||||
const isMarkdown = useMemo(() => {
|
||||
return fileExtension === "md" || fileExtension === "markdown";
|
||||
}, [fileExtension]);
|
||||
|
||||
const language = useMemo(() => {
|
||||
return LANGUAGE_MAP[fileExtension] || "text";
|
||||
}, [fileExtension]);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
if (fileContent) {
|
||||
navigator.clipboard.writeText(fileContent);
|
||||
}
|
||||
}, [fileContent]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (fileContent && fileName) {
|
||||
const blob = new Blob([fileContent], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}, [fileContent, fileName]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
setIsEditingMode(true);
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (file === null) {
|
||||
onClose();
|
||||
} else {
|
||||
setFileName(String(file.path));
|
||||
setFileContent(String(file.content));
|
||||
setIsEditingMode(false);
|
||||
}
|
||||
}, [file, onClose]);
|
||||
|
||||
const fileNameIsValid = useMemo(() => {
|
||||
return (
|
||||
fileName.trim() !== "" &&
|
||||
!fileName.includes("/") &&
|
||||
!fileName.includes(" ")
|
||||
);
|
||||
}, [fileName]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={true}
|
||||
onOpenChange={onClose}
|
||||
>
|
||||
<DialogContent className="flex h-[80vh] max-h-[80vh] min-w-[60vw] flex-col p-6">
|
||||
<DialogTitle className="sr-only">
|
||||
{file?.path || "New File"}
|
||||
</DialogTitle>
|
||||
<div className="mb-4 flex items-center justify-between border-b border-border pb-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FileText className="text-primary/50 h-5 w-5 shrink-0" />
|
||||
{isEditingMode && file === null ? (
|
||||
<Input
|
||||
value={fileName}
|
||||
onChange={(e) => setFileName(e.target.value)}
|
||||
placeholder="Enter filename..."
|
||||
className="text-base font-medium"
|
||||
aria-invalid={!fileNameIsValid}
|
||||
/>
|
||||
) : (
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-base font-medium text-primary">
|
||||
{file?.path}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{!isEditingMode && (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleEdit}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
disabled={editDisabled}
|
||||
>
|
||||
<Edit
|
||||
size={16}
|
||||
className="mr-1"
|
||||
/>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCopy}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<Copy
|
||||
size={16}
|
||||
className="mr-1"
|
||||
/>
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<Download
|
||||
size={16}
|
||||
className="mr-1"
|
||||
/>
|
||||
Download
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{isEditingMode ? (
|
||||
<Textarea
|
||||
value={fileContent}
|
||||
onChange={(e) => setFileContent(e.target.value)}
|
||||
placeholder="Enter file content..."
|
||||
className="h-full min-h-[400px] resize-none font-mono text-sm"
|
||||
/>
|
||||
) : (
|
||||
<ScrollArea className="bg-surface h-full rounded-md">
|
||||
<div className="p-4">
|
||||
{fileContent ? (
|
||||
isMarkdown ? (
|
||||
<div className="rounded-md p-6">
|
||||
<MarkdownContent content={fileContent} />
|
||||
</div>
|
||||
) : (
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={oneDark}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
showLineNumbers
|
||||
wrapLines={true}
|
||||
lineProps={{
|
||||
style: {
|
||||
whiteSpace: "pre-wrap",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{fileContent}
|
||||
</SyntaxHighlighter>
|
||||
)
|
||||
) : (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
File is empty
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
{isEditingMode && (
|
||||
<div className="mt-4 flex justify-end gap-2 border-t border-border pt-4">
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<X
|
||||
size={16}
|
||||
className="mr-1"
|
||||
/>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => fileUpdate.trigger()}
|
||||
size="sm"
|
||||
disabled={
|
||||
fileUpdate.isMutating ||
|
||||
!fileName.trim() ||
|
||||
!fileContent.trim() ||
|
||||
!fileNameIsValid
|
||||
}
|
||||
>
|
||||
{fileUpdate.isMutating ? (
|
||||
<Loader2
|
||||
size={16}
|
||||
className="mr-1 animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<Save
|
||||
size={16}
|
||||
className="mr-1"
|
||||
/>
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
|
||||
FileViewDialog.displayName = "FileViewDialog";
|
||||
135
deep-agents-ui/src/app/components/MarkdownContent.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MarkdownContent = React.memo<MarkdownContentProps>(
|
||||
({ content, className = "" }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"prose min-w-0 max-w-full overflow-hidden break-words text-sm leading-relaxed text-inherit [&_h1:first-child]:mt-0 [&_h1]:mb-4 [&_h1]:mt-6 [&_h1]:font-semibold [&_h2:first-child]:mt-0 [&_h2]:mb-4 [&_h2]:mt-6 [&_h2]:font-semibold [&_h3:first-child]:mt-0 [&_h3]:mb-4 [&_h3]:mt-6 [&_h3]:font-semibold [&_h4:first-child]:mt-0 [&_h4]:mb-4 [&_h4]:mt-6 [&_h4]:font-semibold [&_h5:first-child]:mt-0 [&_h5]:mb-4 [&_h5]:mt-6 [&_h5]:font-semibold [&_h6:first-child]:mt-0 [&_h6]:mb-4 [&_h6]:mt-6 [&_h6]:font-semibold [&_p:last-child]:mb-0 [&_p]:mb-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({
|
||||
inline,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={oneDark}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
className="max-w-full rounded-md text-sm"
|
||||
wrapLines={true}
|
||||
wrapLongLines={true}
|
||||
lineProps={{
|
||||
style: {
|
||||
wordBreak: "break-all",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflowWrap: "break-word",
|
||||
},
|
||||
}}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
maxWidth: "100%",
|
||||
overflowX: "auto",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{String(children).replace(/\n$/, "")}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code
|
||||
className="bg-surface rounded-sm px-1 py-0.5 font-mono text-[0.9em]"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="my-4 max-w-full overflow-hidden last:mb-0">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
a({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary no-underline hover:underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
blockquote({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<blockquote className="text-primary/50 my-4 border-l-4 border-border pl-4 italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
ul({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<ul className="my-4 pl-6 [&>li:last-child]:mb-0 [&>li]:mb-1">
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
ol({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<ol className="my-4 pl-6 [&>li:last-child]:mb-0 [&>li]:mb-1">
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
table({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="my-4 overflow-x-auto">
|
||||
<table className="[&_th]:bg-surface w-full border-collapse [&_td]:border [&_td]:border-border [&_td]:p-2 [&_th]:border [&_th]:border-border [&_th]:p-2 [&_th]:text-left [&_th]:font-semibold">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
MarkdownContent.displayName = "MarkdownContent";
|
||||
48
deep-agents-ui/src/app/components/SubAgentIndicator.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { SubAgent } from "@/app/types/types";
|
||||
|
||||
interface SubAgentIndicatorProps {
|
||||
subAgent: SubAgent;
|
||||
onClick: () => void;
|
||||
isExpanded?: boolean;
|
||||
}
|
||||
|
||||
export const SubAgentIndicator = React.memo<SubAgentIndicatorProps>(
|
||||
({ subAgent, onClick, isExpanded = true }) => {
|
||||
return (
|
||||
<div className="w-fit max-w-[70vw] overflow-hidden rounded-lg border-none bg-card shadow-none outline-none">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center justify-between gap-2 border-none px-4 py-2 text-left shadow-none outline-none transition-colors duration-200"
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-sans text-[15px] font-bold leading-[140%] tracking-[-0.6px] text-[#3F3F46]">
|
||||
{subAgent.subAgentName}
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp
|
||||
size={14}
|
||||
className="shrink-0 text-[#70707B]"
|
||||
/>
|
||||
) : (
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="shrink-0 text-[#70707B]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SubAgentIndicator.displayName = "SubAgentIndicator";
|
||||
266
deep-agents-ui/src/app/components/TasksFilesSidebar.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
useMemo,
|
||||
useCallback,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import {
|
||||
FileText,
|
||||
CheckCircle,
|
||||
Circle,
|
||||
Clock,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import type { TodoItem, FileItem } from "@/app/types/types";
|
||||
import { useChatContext } from "@/providers/ChatProvider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FileViewDialog } from "@/app/components/FileViewDialog";
|
||||
|
||||
export function FilesPopover({
|
||||
files,
|
||||
setFiles,
|
||||
editDisabled,
|
||||
}: {
|
||||
files: Record<string, string>;
|
||||
setFiles: (files: Record<string, string>) => Promise<void>;
|
||||
editDisabled: boolean;
|
||||
}) {
|
||||
const [selectedFile, setSelectedFile] = useState<FileItem | null>(null);
|
||||
|
||||
const handleSaveFile = useCallback(
|
||||
async (fileName: string, content: string) => {
|
||||
await setFiles({ ...files, [fileName]: content });
|
||||
setSelectedFile({ path: fileName, content: content });
|
||||
},
|
||||
[files, setFiles]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.keys(files).length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center p-4 text-center">
|
||||
<p className="text-xs text-muted-foreground">No files created yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(256px,1fr))] gap-2">
|
||||
{Object.keys(files).map((file) => {
|
||||
const filePath = String(file);
|
||||
const rawContent = files[file];
|
||||
let fileContent: string;
|
||||
if (
|
||||
typeof rawContent === "object" &&
|
||||
rawContent !== null &&
|
||||
"content" in rawContent
|
||||
) {
|
||||
const contentArray = (rawContent as { content: unknown }).content;
|
||||
if (Array.isArray(contentArray)) {
|
||||
fileContent = contentArray.join("\n");
|
||||
} else {
|
||||
fileContent = String(contentArray || "");
|
||||
}
|
||||
} else {
|
||||
fileContent = String(rawContent || "");
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={filePath}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelectedFile({ path: filePath, content: fileContent })
|
||||
}
|
||||
className="cursor-pointer space-y-1 truncate rounded-md border border-border px-2 py-3 shadow-sm transition-colors"
|
||||
style={{
|
||||
backgroundColor: "var(--color-file-button)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor =
|
||||
"var(--color-file-button-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor =
|
||||
"var(--color-file-button)";
|
||||
}}
|
||||
>
|
||||
<FileText
|
||||
size={24}
|
||||
className="mx-auto text-muted-foreground"
|
||||
/>
|
||||
<span className="mx-auto block w-full truncate break-words text-center text-sm leading-relaxed text-foreground">
|
||||
{filePath}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedFile && (
|
||||
<FileViewDialog
|
||||
file={selectedFile}
|
||||
onSaveFile={handleSaveFile}
|
||||
onClose={() => setSelectedFile(null)}
|
||||
editDisabled={editDisabled}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const TasksFilesSidebar = React.memo<{
|
||||
todos: TodoItem[];
|
||||
files: Record<string, string>;
|
||||
setFiles: (files: Record<string, string>) => Promise<void>;
|
||||
}>(({ todos, files, setFiles }) => {
|
||||
const { isLoading, interrupt } = useChatContext();
|
||||
const [tasksOpen, setTasksOpen] = useState(false);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
|
||||
// Track previous counts to detect when content goes from empty to having items
|
||||
const prevTodosCount = useRef(todos.length);
|
||||
const prevFilesCount = useRef(Object.keys(files).length);
|
||||
|
||||
// Auto-expand when todos go from empty to having content
|
||||
useEffect(() => {
|
||||
if (prevTodosCount.current === 0 && todos.length > 0) {
|
||||
setTasksOpen(true);
|
||||
}
|
||||
prevTodosCount.current = todos.length;
|
||||
}, [todos.length]);
|
||||
|
||||
// Auto-expand when files go from empty to having content
|
||||
const filesCount = Object.keys(files).length;
|
||||
useEffect(() => {
|
||||
if (prevFilesCount.current === 0 && filesCount > 0) {
|
||||
setFilesOpen(true);
|
||||
}
|
||||
prevFilesCount.current = filesCount;
|
||||
}, [filesCount]);
|
||||
|
||||
const getStatusIcon = useCallback((status: TodoItem["status"]) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return (
|
||||
<CheckCircle
|
||||
size={12}
|
||||
className="text-success/80"
|
||||
/>
|
||||
);
|
||||
case "in_progress":
|
||||
return (
|
||||
<Clock
|
||||
size={12}
|
||||
className="text-warning/80"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Circle
|
||||
size={10}
|
||||
className="text-tertiary/70"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const groupedTodos = useMemo(() => {
|
||||
return {
|
||||
pending: todos.filter((t) => t.status === "pending"),
|
||||
in_progress: todos.filter((t) => t.status === "in_progress"),
|
||||
completed: todos.filter((t) => t.status === "completed"),
|
||||
};
|
||||
}, [todos]);
|
||||
|
||||
const groupedLabels = {
|
||||
pending: "Pending",
|
||||
in_progress: "In Progress",
|
||||
completed: "Completed",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-0 w-full flex-1">
|
||||
<div className="font-inter flex h-full w-full flex-col p-0">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 pb-1.5 pt-2">
|
||||
<span className="text-xs font-semibold tracking-wide text-zinc-600">
|
||||
AGENT TASKS
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setTasksOpen((v) => !v)}
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-transform duration-200 hover:bg-muted",
|
||||
tasksOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
aria-label="Toggle tasks panel"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{tasksOpen && (
|
||||
<div className="bg-muted-secondary rounded-xl px-3 pb-2">
|
||||
<ScrollArea className="h-full">
|
||||
{todos.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center p-4 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No tasks created yet
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-1 p-0.5">
|
||||
{Object.entries(groupedTodos).map(([status, todos]) => (
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-tertiary">
|
||||
{groupedLabels[status as keyof typeof groupedLabels]}
|
||||
</h3>
|
||||
{todos.map((todo, index) => (
|
||||
<div
|
||||
key={`${status}_${todo.id}_${index}`}
|
||||
className="mb-1.5 flex items-start gap-2 rounded-sm p-1 text-sm"
|
||||
>
|
||||
{getStatusIcon(todo.status)}
|
||||
<span className="flex-1 break-words leading-relaxed text-inherit">
|
||||
{todo.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between px-3 pb-1.5 pt-2">
|
||||
<span className="text-xs font-semibold tracking-wide text-zinc-600">
|
||||
FILE SYSTEM
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setFilesOpen((v) => !v)}
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-transform duration-200 hover:bg-muted",
|
||||
filesOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
aria-label="Toggle files panel"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{filesOpen && (
|
||||
<FilesPopover
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
editDisabled={isLoading === true || interrupt !== undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TasksFilesSidebar.displayName = "TasksFilesSidebar";
|
||||
369
deep-agents-ui/src/app/components/ThreadList.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { Loader2, MessageSquare, X } from "lucide-react";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ThreadItem } from "@/app/hooks/useThreads";
|
||||
import { useThreads } from "@/app/hooks/useThreads";
|
||||
|
||||
type StatusFilter = "all" | "idle" | "busy" | "interrupted" | "error";
|
||||
|
||||
const GROUP_LABELS = {
|
||||
interrupted: "Requiring Attention",
|
||||
today: "Today",
|
||||
yesterday: "Yesterday",
|
||||
week: "This Week",
|
||||
older: "Older",
|
||||
} as const;
|
||||
|
||||
const STATUS_COLORS: Record<ThreadItem["status"], string> = {
|
||||
idle: "bg-green-500",
|
||||
busy: "bg-blue-500",
|
||||
interrupted: "bg-orange-500",
|
||||
error: "bg-red-600",
|
||||
};
|
||||
|
||||
function getThreadColor(status: ThreadItem["status"]): string {
|
||||
return STATUS_COLORS[status] ?? "bg-gray-400";
|
||||
}
|
||||
|
||||
function formatTime(date: Date, now = new Date()): string {
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return format(date, "HH:mm");
|
||||
if (days === 1) return "Yesterday";
|
||||
if (days < 7) return format(date, "EEEE");
|
||||
return format(date, "MM/dd");
|
||||
}
|
||||
|
||||
function StatusFilterItem({
|
||||
status,
|
||||
label,
|
||||
badge,
|
||||
}: {
|
||||
status: ThreadItem["status"];
|
||||
label: string;
|
||||
badge?: number;
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block size-2 rounded-full",
|
||||
getThreadColor(status)
|
||||
)}
|
||||
/>
|
||||
{label}
|
||||
{badge !== undefined && badge > 0 && (
|
||||
<span className="ml-1 inline-flex items-center justify-center rounded-full bg-red-600 px-1.5 py-0.5 text-xs font-bold leading-none text-white">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<p className="text-sm text-red-600">Failed to load threads</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="space-y-2 p-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="h-16 w-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<MessageSquare className="mb-2 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm text-muted-foreground">No threads found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ThreadListProps {
|
||||
onThreadSelect: (id: string) => void;
|
||||
onMutateReady?: (mutate: () => void) => void;
|
||||
onClose?: () => void;
|
||||
onInterruptCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
export function ThreadList({
|
||||
onThreadSelect,
|
||||
onMutateReady,
|
||||
onClose,
|
||||
onInterruptCountChange,
|
||||
}: ThreadListProps) {
|
||||
const [currentThreadId] = useQueryState("threadId");
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
|
||||
const threads = useThreads({
|
||||
status: statusFilter === "all" ? undefined : statusFilter,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const flattened = useMemo(() => {
|
||||
return threads.data?.flat() ?? [];
|
||||
}, [threads.data]);
|
||||
|
||||
const isLoadingMore =
|
||||
threads.size > 0 && threads.data?.[threads.size - 1] == null;
|
||||
const isEmpty = threads.data?.at(0)?.length === 0;
|
||||
const isReachingEnd = isEmpty || (threads.data?.at(-1)?.length ?? 0) < 20;
|
||||
|
||||
// Group threads by time and status
|
||||
const grouped = useMemo(() => {
|
||||
const now = new Date();
|
||||
const groups: Record<keyof typeof GROUP_LABELS, ThreadItem[]> = {
|
||||
interrupted: [],
|
||||
today: [],
|
||||
yesterday: [],
|
||||
week: [],
|
||||
older: [],
|
||||
};
|
||||
|
||||
flattened.forEach((thread) => {
|
||||
if (thread.status === "interrupted") {
|
||||
groups.interrupted.push(thread);
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = now.getTime() - thread.updatedAt.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) {
|
||||
groups.today.push(thread);
|
||||
} else if (days === 1) {
|
||||
groups.yesterday.push(thread);
|
||||
} else if (days < 7) {
|
||||
groups.week.push(thread);
|
||||
} else {
|
||||
groups.older.push(thread);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [flattened]);
|
||||
|
||||
const interruptedCount = useMemo(() => {
|
||||
return flattened.filter((t) => t.status === "interrupted").length;
|
||||
}, [flattened]);
|
||||
|
||||
// Expose thread list revalidation to parent component
|
||||
// Use refs to create a stable callback that always calls the latest mutate function
|
||||
const onMutateReadyRef = useRef(onMutateReady);
|
||||
const mutateRef = useRef(threads.mutate);
|
||||
|
||||
useEffect(() => {
|
||||
onMutateReadyRef.current = onMutateReady;
|
||||
}, [onMutateReady]);
|
||||
|
||||
useEffect(() => {
|
||||
mutateRef.current = threads.mutate;
|
||||
}, [threads.mutate]);
|
||||
|
||||
const mutateFn = useCallback(() => {
|
||||
mutateRef.current();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onMutateReadyRef.current?.(mutateFn);
|
||||
// Only run once on mount to avoid infinite loops
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Notify parent of interrupt count changes
|
||||
useEffect(() => {
|
||||
onInterruptCountChange?.(interruptedCount);
|
||||
}, [interruptedCount, onInterruptCountChange]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
{/* Header with title, filter, and close button */}
|
||||
<div className="grid flex-shrink-0 grid-cols-[1fr_auto] items-center gap-3 border-b border-border p-4">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Threads</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(v) => setStatusFilter(v as StatusFilter)}
|
||||
>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Active</SelectLabel>
|
||||
<SelectItem value="idle">
|
||||
<StatusFilterItem
|
||||
status="idle"
|
||||
label="Idle"
|
||||
/>
|
||||
</SelectItem>
|
||||
<SelectItem value="busy">
|
||||
<StatusFilterItem
|
||||
status="busy"
|
||||
label="Busy"
|
||||
/>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Attention</SelectLabel>
|
||||
<SelectItem value="interrupted">
|
||||
<StatusFilterItem
|
||||
status="interrupted"
|
||||
label="Interrupted"
|
||||
badge={interruptedCount}
|
||||
/>
|
||||
</SelectItem>
|
||||
<SelectItem value="error">
|
||||
<StatusFilterItem
|
||||
status="error"
|
||||
label="Error"
|
||||
/>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8"
|
||||
aria-label="Close threads sidebar"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-0 flex-1">
|
||||
{threads.error && <ErrorState message={threads.error.message} />}
|
||||
|
||||
{!threads.error && !threads.data && threads.isLoading && (
|
||||
<LoadingState />
|
||||
)}
|
||||
|
||||
{!threads.error && !threads.isLoading && isEmpty && <EmptyState />}
|
||||
|
||||
{!threads.error && !isEmpty && (
|
||||
<div className="box-border w-full max-w-full overflow-hidden p-2">
|
||||
{(
|
||||
Object.keys(GROUP_LABELS) as Array<keyof typeof GROUP_LABELS>
|
||||
).map((group) => {
|
||||
const groupThreads = grouped[group];
|
||||
if (groupThreads.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group}
|
||||
className="mb-4"
|
||||
>
|
||||
<h4 className="m-0 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{GROUP_LABELS[group]}
|
||||
</h4>
|
||||
<div className="flex flex-col gap-1">
|
||||
{groupThreads.map((thread) => (
|
||||
<button
|
||||
key={thread.id}
|
||||
type="button"
|
||||
onClick={() => onThreadSelect(thread.id)}
|
||||
className={cn(
|
||||
"grid w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors duration-200",
|
||||
"hover:bg-accent",
|
||||
currentThreadId === thread.id
|
||||
? "border border-primary bg-accent hover:bg-accent"
|
||||
: "border border-transparent bg-transparent"
|
||||
)}
|
||||
aria-current={currentThreadId === thread.id}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Title + Timestamp Row */}
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<h3 className="truncate text-sm font-semibold">
|
||||
{thread.title}
|
||||
</h3>
|
||||
<span className="ml-2 flex-shrink-0 text-xs text-muted-foreground">
|
||||
{formatTime(thread.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Description + Status Row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="flex-1 truncate text-sm text-muted-foreground">
|
||||
{thread.description}
|
||||
</p>
|
||||
<div className="ml-2 flex-shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
getThreadColor(thread.status)
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!isReachingEnd && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => threads.setSize(threads.size + 1)}
|
||||
disabled={isLoadingMore}
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
"Load More"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
287
deep-agents-ui/src/app/components/ToolApprovalInterrupt.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AlertCircle, Check, X, Pencil } from "lucide-react";
|
||||
import type { ActionRequest, ReviewConfig } from "@/app/types/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ToolApprovalInterruptProps {
|
||||
actionRequest: ActionRequest;
|
||||
reviewConfig?: ReviewConfig;
|
||||
onResume: (value: any) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ToolApprovalInterrupt({
|
||||
actionRequest,
|
||||
reviewConfig,
|
||||
onResume,
|
||||
isLoading,
|
||||
}: ToolApprovalInterruptProps) {
|
||||
const [rejectionMessage, setRejectionMessage] = useState("");
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedArgs, setEditedArgs] = useState<Record<string, unknown>>({});
|
||||
const [showRejectionInput, setShowRejectionInput] = useState(false);
|
||||
|
||||
const allowedDecisions = reviewConfig?.allowedDecisions ?? [
|
||||
"approve",
|
||||
"reject",
|
||||
"edit",
|
||||
];
|
||||
|
||||
const handleApprove = () => {
|
||||
onResume({
|
||||
decisions: [{ type: "approve" }],
|
||||
});
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
if (showRejectionInput) {
|
||||
onResume({
|
||||
decisions: [
|
||||
{
|
||||
type: "reject",
|
||||
message: rejectionMessage.trim(),
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
setShowRejectionInput(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectConfirm = () => {
|
||||
onResume({
|
||||
decisions: [
|
||||
{
|
||||
type: "reject",
|
||||
message: rejectionMessage.trim(),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
if (isEditing) {
|
||||
onResume({
|
||||
decisions: [
|
||||
{
|
||||
type: "edit",
|
||||
edited_action: {
|
||||
name: actionRequest.name,
|
||||
args: editedArgs,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
setIsEditing(false);
|
||||
setEditedArgs({});
|
||||
}
|
||||
};
|
||||
|
||||
const startEditing = () => {
|
||||
setIsEditing(true);
|
||||
setEditedArgs(JSON.parse(JSON.stringify(actionRequest.args)));
|
||||
setShowRejectionInput(false);
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setIsEditing(false);
|
||||
setEditedArgs({});
|
||||
};
|
||||
|
||||
const updateEditedArg = (key: string, value: string) => {
|
||||
try {
|
||||
const parsedValue =
|
||||
value.trim().startsWith("{") || value.trim().startsWith("[")
|
||||
? JSON.parse(value)
|
||||
: value;
|
||||
setEditedArgs((prev) => ({ ...prev, [key]: parsedValue }));
|
||||
} catch {
|
||||
setEditedArgs((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-md border border-border bg-muted/30 p-4">
|
||||
{/* Header */}
|
||||
<div className="mb-3 flex items-center gap-2 text-foreground">
|
||||
<AlertCircle
|
||||
size={16}
|
||||
className="text-yellow-600 dark:text-yellow-400"
|
||||
/>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">
|
||||
Approval Required
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{actionRequest.description && (
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
{actionRequest.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Tool Info Card */}
|
||||
<div className="mb-4 rounded-sm border border-border bg-background p-3">
|
||||
<div className="mb-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Tool
|
||||
</span>
|
||||
<p className="mt-1 font-mono text-sm font-medium text-foreground">
|
||||
{actionRequest.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div>
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Edit Arguments
|
||||
</span>
|
||||
<div className="mt-2 space-y-3">
|
||||
{Object.entries(actionRequest.args).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<label className="mb-1 block text-xs font-medium text-foreground">
|
||||
{key}
|
||||
</label>
|
||||
<Textarea
|
||||
value={
|
||||
editedArgs[key] !== undefined
|
||||
? typeof editedArgs[key] === "string"
|
||||
? (editedArgs[key] as string)
|
||||
: JSON.stringify(editedArgs[key], null, 2)
|
||||
: typeof value === "string"
|
||||
? value
|
||||
: JSON.stringify(value, null, 2)
|
||||
}
|
||||
onChange={(e) => updateEditedArg(key, e.target.value)}
|
||||
className="font-mono text-xs"
|
||||
rows={
|
||||
typeof value === "string" && value.length < 100 ? 2 : 4
|
||||
}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Arguments
|
||||
</span>
|
||||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-all rounded-sm border border-border bg-muted/40 p-2 font-mono text-xs text-foreground">
|
||||
{JSON.stringify(actionRequest.args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rejection Message Input */}
|
||||
{showRejectionInput && !isEditing && (
|
||||
<div className="mb-4">
|
||||
<label className="mb-2 block text-xs font-medium text-foreground">
|
||||
Rejection Message (optional)
|
||||
</label>
|
||||
<Textarea
|
||||
value={rejectionMessage}
|
||||
onChange={(e) => setRejectionMessage(e.target.value)}
|
||||
placeholder="Explain why you're rejecting this action..."
|
||||
className="text-sm"
|
||||
rows={2}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={cancelEditing}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleEdit}
|
||||
disabled={isLoading}
|
||||
className="bg-green-600 text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700"
|
||||
>
|
||||
<Check size={14} />
|
||||
{isLoading ? "Saving..." : "Save & Approve"}
|
||||
</Button>
|
||||
</>
|
||||
) : showRejectionInput ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowRejectionInput(false);
|
||||
setRejectionMessage("");
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleRejectConfirm}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Rejecting..." : "Confirm Reject"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{allowedDecisions.includes("reject") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReject}
|
||||
disabled={isLoading}
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<X size={14} />
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
{allowedDecisions.includes("edit") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={startEditing}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{allowedDecisions.includes("approve") && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleApprove}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
"bg-green-600 text-white hover:bg-green-700",
|
||||
"dark:bg-green-600 dark:hover:bg-green-700"
|
||||
)}
|
||||
>
|
||||
<Check size={14} />
|
||||
{isLoading ? "Approving..." : "Approve"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
230
deep-agents-ui/src/app/components/ToolCallBox.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useCallback } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Terminal,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
CircleCheckBigIcon,
|
||||
StopCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolCall, ActionRequest, ReviewConfig } from "@/app/types/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
|
||||
import { ToolApprovalInterrupt } from "@/app/components/ToolApprovalInterrupt";
|
||||
|
||||
interface ToolCallBoxProps {
|
||||
toolCall: ToolCall;
|
||||
uiComponent?: any;
|
||||
stream?: any;
|
||||
graphId?: string;
|
||||
actionRequest?: ActionRequest;
|
||||
reviewConfig?: ReviewConfig;
|
||||
onResume?: (value: any) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const ToolCallBox = React.memo<ToolCallBoxProps>(
|
||||
({
|
||||
toolCall,
|
||||
uiComponent,
|
||||
stream,
|
||||
graphId,
|
||||
actionRequest,
|
||||
reviewConfig,
|
||||
onResume,
|
||||
isLoading,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(
|
||||
() => !!uiComponent || !!actionRequest
|
||||
);
|
||||
const [expandedArgs, setExpandedArgs] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
);
|
||||
|
||||
const { name, args, result, status } = useMemo(() => {
|
||||
return {
|
||||
name: toolCall.name || "Unknown Tool",
|
||||
args: toolCall.args || {},
|
||||
result: toolCall.result,
|
||||
status: toolCall.status || "completed",
|
||||
};
|
||||
}, [toolCall]);
|
||||
|
||||
const statusIcon = useMemo(() => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return <CircleCheckBigIcon />;
|
||||
case "error":
|
||||
return (
|
||||
<AlertCircle
|
||||
size={14}
|
||||
className="text-destructive"
|
||||
/>
|
||||
);
|
||||
case "pending":
|
||||
return (
|
||||
<Loader2
|
||||
size={14}
|
||||
className="animate-spin"
|
||||
/>
|
||||
);
|
||||
case "interrupted":
|
||||
return (
|
||||
<StopCircle
|
||||
size={14}
|
||||
className="text-orange-500"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Terminal
|
||||
size={14}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const toggleArgExpanded = useCallback((argKey: string) => {
|
||||
setExpandedArgs((prev) => ({
|
||||
...prev,
|
||||
[argKey]: !prev[argKey],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const hasContent = result || Object.keys(args).length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full overflow-hidden rounded-lg border-none shadow-none outline-none transition-colors duration-200 hover:bg-accent",
|
||||
isExpanded && hasContent && "bg-accent"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleExpanded}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 border-none px-2 py-2 text-left shadow-none outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-default"
|
||||
)}
|
||||
disabled={!hasContent}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{statusIcon}
|
||||
<span className="text-[15px] font-medium tracking-[-0.6px] text-foreground">
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
{hasContent &&
|
||||
(isExpanded ? (
|
||||
<ChevronUp
|
||||
size={14}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{isExpanded && hasContent && (
|
||||
<div className="px-4 pb-4">
|
||||
{uiComponent && stream && graphId ? (
|
||||
<div className="mt-4">
|
||||
<LoadExternalComponent
|
||||
key={uiComponent.id}
|
||||
stream={stream}
|
||||
message={uiComponent}
|
||||
namespace={graphId}
|
||||
meta={{ status, args, result: result ?? "No Result Yet" }}
|
||||
/>
|
||||
</div>
|
||||
) : actionRequest && onResume ? (
|
||||
// Show tool approval UI when there's an action request but no GenUI
|
||||
<div className="mt-4">
|
||||
<ToolApprovalInterrupt
|
||||
actionRequest={actionRequest}
|
||||
reviewConfig={reviewConfig}
|
||||
onResume={onResume}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{Object.keys(args).length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Arguments
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(args).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-sm border border-border"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleArgExpanded(key)}
|
||||
className="flex w-full items-center justify-between bg-muted/30 p-2 text-left text-xs font-medium transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<span className="font-mono">{key}</span>
|
||||
{expandedArgs[key] ? (
|
||||
<ChevronUp
|
||||
size={12}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{expandedArgs[key] && (
|
||||
<div className="border-t border-border bg-muted/20 p-2">
|
||||
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs leading-6 text-foreground">
|
||||
{typeof value === "string"
|
||||
? value
|
||||
: JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<div className="mt-4">
|
||||
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Result
|
||||
</h4>
|
||||
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-all rounded-sm border border-border bg-muted/40 p-2 font-mono text-xs leading-7 text-foreground">
|
||||
{typeof result === "string"
|
||||
? result
|
||||
: JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ToolCallBox.displayName = "ToolCallBox";
|
||||
BIN
deep-agents-ui/src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
395
deep-agents-ui/src/app/globals.css
Normal file
@@ -0,0 +1,395 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
/* Remove default focus box-shadows */
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Set default outline color to match brand instead of browser blue */
|
||||
* {
|
||||
outline-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
:root {
|
||||
/* App-specific color variables */
|
||||
--color-primary: #1c3c3c;
|
||||
--color-user-message: #076699;
|
||||
--color-user-message-bg: #e8f4f8;
|
||||
--color-avatar-bg: #e8ebeb;
|
||||
--color-secondary: #1c3c3c;
|
||||
--color-success: #10b981;
|
||||
--color-warning: #f59e0b;
|
||||
--color-error: #ef4444;
|
||||
--color-background: #f9f9f9;
|
||||
--color-subagent-hover: #bbc4c4;
|
||||
--color-surface: #f9fafb;
|
||||
--color-border: #e5e7eb;
|
||||
--color-border-light: #f3f4f6;
|
||||
--color-text-primary: #111827;
|
||||
--color-text-secondary: #6b7280;
|
||||
--color-text-tertiary: #9ca3af;
|
||||
--color-file-button: #ffffff;
|
||||
--color-file-button-hover: #e5e7eb;
|
||||
|
||||
/* Dark theme colors */
|
||||
--color-primary-dark: #2dd4bf;
|
||||
--color-user-message-dark: #076699;
|
||||
--color-avatar-bg-dark: #bcb2fd;
|
||||
--color-secondary-dark: #2dd4bf;
|
||||
--color-success-dark: #34d399;
|
||||
--color-warning-dark: #fbbf24;
|
||||
--color-error-dark: #f87171;
|
||||
--color-background-dark: #0f0f0f;
|
||||
--color-subagent-hover-dark: #d0c9fe;
|
||||
--color-surface-dark: #1a1a1a;
|
||||
--color-border-dark: #2d2d2d;
|
||||
--color-border-light-dark: #232323;
|
||||
--color-text-primary-dark: #f3f4f6;
|
||||
--color-text-secondary-dark: #9ca3af;
|
||||
--color-text-tertiary-dark: #6b7280;
|
||||
|
||||
/* Spacing variables */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
--spacing-2xl: 3rem;
|
||||
|
||||
/* Font family variables */
|
||||
--font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
--font-family-mono: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono",
|
||||
Consolas, "Courier New", monospace;
|
||||
|
||||
/* Font size variables */
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-base: 1rem;
|
||||
--font-size-lg: 1.125rem;
|
||||
--font-size-xl: 1.25rem;
|
||||
--font-size-2xl: 1.5rem;
|
||||
--font-size-3xl: 1.875rem;
|
||||
|
||||
/* Font weight variables */
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
|
||||
/* Line height variables */
|
||||
--line-height-tight: 1.25;
|
||||
--line-height-normal: 1.5;
|
||||
--line-height-relaxed: 1.75;
|
||||
|
||||
/* Border radius variables */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadow variables */
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
|
||||
/* Transition variables */
|
||||
--transition-base: 200ms ease;
|
||||
|
||||
/* Layout variables */
|
||||
--sidebar-width: 320px;
|
||||
--sidebar-collapsed-width: 60px;
|
||||
--header-height: 64px;
|
||||
--panel-width: 40vw;
|
||||
--chat-max-width: 900px;
|
||||
|
||||
/* Tailwind/Radix UI component variables */
|
||||
--radius: 0.5rem;
|
||||
--background: 0 0% 98%;
|
||||
--foreground: 220 13% 13%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 220 13% 13%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 220 13% 13%;
|
||||
--primary: 180 35% 17%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 220 13% 91%;
|
||||
--secondary-foreground: 220 13% 13%;
|
||||
--muted: 220 13% 95%;
|
||||
--muted-foreground: 220 9% 46%;
|
||||
--accent: 220 13% 95%;
|
||||
--accent-foreground: 220 13% 13%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 220 13% 91%;
|
||||
--input: 220 13% 91%;
|
||||
--ring: 180 35% 17%;
|
||||
--sidebar: 220 13% 95%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
/* App-specific color variables */
|
||||
--color-primary: #1c3c3c;
|
||||
--color-user-message: #065a8a;
|
||||
--color-user-message-bg: #2d2d2d;
|
||||
--color-avatar-bg: #1c3c3c;
|
||||
--color-secondary: #bbc4c4;
|
||||
--color-success: #34d399;
|
||||
--color-warning: #fbbf24;
|
||||
--color-error: #f87171;
|
||||
--color-background: #202020;
|
||||
--color-subagent-hover: #1e3f3f;
|
||||
--color-surface: #2a2a2a;
|
||||
--color-border: #404040;
|
||||
--color-border-light: #353535;
|
||||
--color-text-primary: #f3f4f6;
|
||||
--color-text-secondary: #9ca3af;
|
||||
--color-text-tertiary: #6b7280;
|
||||
--color-file-button: #2a2a2a;
|
||||
--color-file-button-hover: #353535;
|
||||
|
||||
/* Tailwind/Radix UI component variables for dark mode */
|
||||
--radius: 0.5rem;
|
||||
--background: 0 0% 13%;
|
||||
--foreground: 220 13% 95%;
|
||||
--card: 0 0% 18%;
|
||||
--card-foreground: 220 13% 95%;
|
||||
--popover: 0 0% 18%;
|
||||
--popover-foreground: 220 13% 95%;
|
||||
--primary: 174 72% 56%;
|
||||
--primary-foreground: 0 0% 13%;
|
||||
--secondary: 0 0% 25%;
|
||||
--secondary-foreground: 220 13% 95%;
|
||||
--muted: 0 0% 22%;
|
||||
--muted-foreground: 220 9% 70%;
|
||||
--accent: 0 0% 22%;
|
||||
--accent-foreground: 220 13% 95%;
|
||||
--destructive: 0 63% 71%;
|
||||
--destructive-foreground: 0 0% 13%;
|
||||
--border: 0 0% 28%;
|
||||
--input: 0 0% 28%;
|
||||
--ring: 174 72% 56%;
|
||||
--sidebar: 0 0% 18%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-background);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
h4 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
h5 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
h6 {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
transition: opacity 200ms ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
|
||||
"Courier New", monospace;
|
||||
font-size: 0.9em;
|
||||
padding: 0.125em 0.25em;
|
||||
background-color: var(--color-surface);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
|
||||
"Courier New", monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.75;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-surface);
|
||||
border-radius: 0.375rem;
|
||||
overflow-x: auto;
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding-left: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
/* Optimization Window animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar styles */
|
||||
.scrollbar-pretty {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.scrollbar-pretty::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-pretty::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-pretty::-webkit-scrollbar-thumb {
|
||||
background-color: #d1d5db;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.scrollbar-pretty::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Global diff highlighting styles */
|
||||
.word-added {
|
||||
background-color: rgba(46, 160, 67, 0.4);
|
||||
color: #ffffff;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.word-removed {
|
||||
background-color: rgba(248, 81, 73, 0.4);
|
||||
color: #ffffff;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: #f85149;
|
||||
}
|
||||
166
deep-agents-ui/src/app/hooks/useChat.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||
import {
|
||||
type Message,
|
||||
type Assistant,
|
||||
type Checkpoint,
|
||||
} from "@langchain/langgraph-sdk";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { UseStreamThread } from "@langchain/langgraph-sdk/react";
|
||||
import type { TodoItem } from "@/app/types/types";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
import { useQueryState } from "nuqs";
|
||||
|
||||
export type StateType = {
|
||||
messages: Message[];
|
||||
todos: TodoItem[];
|
||||
files: Record<string, string>;
|
||||
email?: {
|
||||
id?: string;
|
||||
subject?: string;
|
||||
page_content?: string;
|
||||
};
|
||||
ui?: any;
|
||||
};
|
||||
|
||||
export function useChat({
|
||||
activeAssistant,
|
||||
onHistoryRevalidate,
|
||||
thread,
|
||||
}: {
|
||||
activeAssistant: Assistant | null;
|
||||
onHistoryRevalidate?: () => void;
|
||||
thread?: UseStreamThread<StateType>;
|
||||
}) {
|
||||
const [threadId, setThreadId] = useQueryState("threadId");
|
||||
const client = useClient();
|
||||
|
||||
const stream = useStream<StateType>({
|
||||
assistantId: activeAssistant?.assistant_id || "",
|
||||
client: client ?? undefined,
|
||||
reconnectOnMount: true,
|
||||
threadId: threadId ?? null,
|
||||
onThreadId: setThreadId,
|
||||
defaultHeaders: { "x-auth-scheme": "langsmith" },
|
||||
fetchStateHistory: true,
|
||||
// Revalidate thread list when stream finishes, errors, or creates new thread
|
||||
onFinish: onHistoryRevalidate,
|
||||
onError: onHistoryRevalidate,
|
||||
onCreated: onHistoryRevalidate,
|
||||
experimental_thread: thread,
|
||||
});
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(content: string) => {
|
||||
const newMessage: Message = { id: uuidv4(), type: "human", content };
|
||||
stream.submit(
|
||||
{ messages: [newMessage] },
|
||||
{
|
||||
optimisticValues: (prev) => ({
|
||||
messages: [...(prev.messages ?? []), newMessage],
|
||||
}),
|
||||
config: { ...(activeAssistant?.config ?? {}), recursion_limit: 100 },
|
||||
}
|
||||
);
|
||||
// Update thread list immediately when sending a message
|
||||
onHistoryRevalidate?.();
|
||||
},
|
||||
[stream, activeAssistant?.config, onHistoryRevalidate]
|
||||
);
|
||||
|
||||
const runSingleStep = useCallback(
|
||||
(
|
||||
messages: Message[],
|
||||
checkpoint?: Checkpoint,
|
||||
isRerunningSubagent?: boolean,
|
||||
optimisticMessages?: Message[]
|
||||
) => {
|
||||
if (checkpoint) {
|
||||
stream.submit(undefined, {
|
||||
...(optimisticMessages
|
||||
? { optimisticValues: { messages: optimisticMessages } }
|
||||
: {}),
|
||||
config: activeAssistant?.config,
|
||||
checkpoint: checkpoint,
|
||||
...(isRerunningSubagent
|
||||
? { interruptAfter: ["tools"] }
|
||||
: { interruptBefore: ["tools"] }),
|
||||
});
|
||||
} else {
|
||||
stream.submit(
|
||||
{ messages },
|
||||
{ config: activeAssistant?.config, interruptBefore: ["tools"] }
|
||||
);
|
||||
}
|
||||
},
|
||||
[stream, activeAssistant?.config]
|
||||
);
|
||||
|
||||
const setFiles = useCallback(
|
||||
async (files: Record<string, string>) => {
|
||||
if (!threadId) return;
|
||||
// TODO: missing a way how to revalidate the internal state
|
||||
// I think we do want to have the ability to externally manage the state
|
||||
await client.threads.updateState(threadId, { values: { files } });
|
||||
},
|
||||
[client, threadId]
|
||||
);
|
||||
|
||||
const continueStream = useCallback(
|
||||
(hasTaskToolCall?: boolean) => {
|
||||
stream.submit(undefined, {
|
||||
config: {
|
||||
...(activeAssistant?.config || {}),
|
||||
recursion_limit: 100,
|
||||
},
|
||||
...(hasTaskToolCall
|
||||
? { interruptAfter: ["tools"] }
|
||||
: { interruptBefore: ["tools"] }),
|
||||
});
|
||||
// Update thread list when continuing stream
|
||||
onHistoryRevalidate?.();
|
||||
},
|
||||
[stream, activeAssistant?.config, onHistoryRevalidate]
|
||||
);
|
||||
|
||||
const markCurrentThreadAsResolved = useCallback(() => {
|
||||
stream.submit(null, { command: { goto: "__end__", update: null } });
|
||||
// Update thread list when marking thread as resolved
|
||||
onHistoryRevalidate?.();
|
||||
}, [stream, onHistoryRevalidate]);
|
||||
|
||||
const resumeInterrupt = useCallback(
|
||||
(value: any) => {
|
||||
stream.submit(null, { command: { resume: value } });
|
||||
// Update thread list when resuming from interrupt
|
||||
onHistoryRevalidate?.();
|
||||
},
|
||||
[stream, onHistoryRevalidate]
|
||||
);
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
stream.stop();
|
||||
}, [stream]);
|
||||
|
||||
return {
|
||||
stream,
|
||||
todos: stream.values.todos ?? [],
|
||||
files: stream.values.files ?? {},
|
||||
email: stream.values.email,
|
||||
ui: stream.values.ui,
|
||||
setFiles,
|
||||
messages: stream.messages,
|
||||
isLoading: stream.isLoading,
|
||||
isThreadLoading: stream.isThreadLoading,
|
||||
interrupt: stream.interrupt,
|
||||
getMessagesMetadata: stream.getMessagesMetadata,
|
||||
sendMessage,
|
||||
runSingleStep,
|
||||
continueStream,
|
||||
stopStream,
|
||||
markCurrentThreadAsResolved,
|
||||
resumeInterrupt,
|
||||
};
|
||||
}
|
||||
136
deep-agents-ui/src/app/hooks/useThreads.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import type { Thread } from "@langchain/langgraph-sdk";
|
||||
import { Client } from "@langchain/langgraph-sdk";
|
||||
import { getConfig } from "@/lib/config";
|
||||
|
||||
export interface ThreadItem {
|
||||
id: string;
|
||||
updatedAt: Date;
|
||||
status: Thread["status"];
|
||||
title: string;
|
||||
description: string;
|
||||
assistantId?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
export function useThreads(props: {
|
||||
status?: Thread["status"];
|
||||
limit?: number;
|
||||
}) {
|
||||
const pageSize = props.limit || DEFAULT_PAGE_SIZE;
|
||||
|
||||
return useSWRInfinite(
|
||||
(pageIndex: number, previousPageData: ThreadItem[] | null) => {
|
||||
const config = getConfig();
|
||||
const apiKey =
|
||||
config?.langsmithApiKey ||
|
||||
process.env.NEXT_PUBLIC_LANGSMITH_API_KEY ||
|
||||
"";
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the previous page returned no items, we've reached the end
|
||||
if (previousPageData && previousPageData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "threads" as const,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
deploymentUrl: config.deploymentUrl,
|
||||
assistantId: config.assistantId,
|
||||
apiKey,
|
||||
status: props?.status,
|
||||
};
|
||||
},
|
||||
async ({
|
||||
deploymentUrl,
|
||||
assistantId,
|
||||
apiKey,
|
||||
status,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
}: {
|
||||
kind: "threads";
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
deploymentUrl: string;
|
||||
assistantId: string;
|
||||
apiKey: string;
|
||||
status?: Thread["status"];
|
||||
}) => {
|
||||
const client = new Client({
|
||||
apiUrl: deploymentUrl,
|
||||
defaultHeaders: apiKey ? { "X-Api-Key": apiKey } : {},
|
||||
});
|
||||
|
||||
// Check if assistantId is a UUID (deployed) or graph name (local)
|
||||
const isUUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
assistantId
|
||||
);
|
||||
|
||||
const threads = await client.threads.search({
|
||||
limit: pageSize,
|
||||
offset: pageIndex * pageSize,
|
||||
sortBy: "updated_at" as const,
|
||||
sortOrder: "desc" as const,
|
||||
status,
|
||||
// Only filter by assistant_id metadata for deployed graphs (UUIDs)
|
||||
// Local dev graphs don't set this metadata
|
||||
...(isUUID ? { metadata: { assistant_id: assistantId } } : {}),
|
||||
});
|
||||
|
||||
return threads.map((thread): ThreadItem => {
|
||||
let title = "Untitled Thread";
|
||||
let description = "";
|
||||
|
||||
try {
|
||||
if (thread.values && typeof thread.values === "object") {
|
||||
const values = thread.values as any;
|
||||
const firstHumanMessage = values.messages.find(
|
||||
(m: any) => m.type === "human"
|
||||
);
|
||||
if (firstHumanMessage?.content) {
|
||||
const content =
|
||||
typeof firstHumanMessage.content === "string"
|
||||
? firstHumanMessage.content
|
||||
: firstHumanMessage.content[0]?.text || "";
|
||||
title = content.slice(0, 50) + (content.length > 50 ? "..." : "");
|
||||
}
|
||||
const firstAiMessage = values.messages.find(
|
||||
(m: any) => m.type === "ai"
|
||||
);
|
||||
if (firstAiMessage?.content) {
|
||||
const content =
|
||||
typeof firstAiMessage.content === "string"
|
||||
? firstAiMessage.content
|
||||
: firstAiMessage.content[0]?.text || "";
|
||||
description = content.slice(0, 100);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fallback to thread ID
|
||||
title = `Thread ${thread.thread_id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: thread.thread_id,
|
||||
updatedAt: new Date(thread.updated_at),
|
||||
status: thread.status,
|
||||
title,
|
||||
description,
|
||||
assistantId,
|
||||
};
|
||||
});
|
||||
},
|
||||
{
|
||||
revalidateFirstPage: true,
|
||||
revalidateOnFocus: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
27
deep-agents-ui/src/app/layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Inter } from "next/font/google";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body
|
||||
className={inter.className}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<NuqsAdapter>{children}</NuqsAdapter>
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
293
deep-agents-ui/src/app/page.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, Suspense } from "react";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { getConfig, saveConfig, StandaloneConfig } from "@/lib/config";
|
||||
import { ConfigDialog } from "@/app/components/ConfigDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Assistant } from "@langchain/langgraph-sdk";
|
||||
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||
import { Settings, MessagesSquare, SquarePen } from "lucide-react";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { ThreadList } from "@/app/components/ThreadList";
|
||||
import { ChatProvider } from "@/providers/ChatProvider";
|
||||
import { ChatInterface } from "@/app/components/ChatInterface";
|
||||
|
||||
interface HomePageInnerProps {
|
||||
config: StandaloneConfig;
|
||||
configDialogOpen: boolean;
|
||||
setConfigDialogOpen: (open: boolean) => void;
|
||||
handleSaveConfig: (config: StandaloneConfig) => void;
|
||||
}
|
||||
|
||||
function HomePageInner({
|
||||
config,
|
||||
configDialogOpen,
|
||||
setConfigDialogOpen,
|
||||
handleSaveConfig,
|
||||
}: HomePageInnerProps) {
|
||||
const client = useClient();
|
||||
const [threadId, setThreadId] = useQueryState("threadId");
|
||||
const [sidebar, setSidebar] = useQueryState("sidebar");
|
||||
|
||||
const [mutateThreads, setMutateThreads] = useState<(() => void) | null>(null);
|
||||
const [interruptCount, setInterruptCount] = useState(0);
|
||||
const [assistant, setAssistant] = useState<Assistant | null>(null);
|
||||
|
||||
const fetchAssistant = useCallback(async () => {
|
||||
const isUUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
config.assistantId
|
||||
);
|
||||
|
||||
if (isUUID) {
|
||||
// We should try to fetch the assistant directly with this UUID
|
||||
try {
|
||||
const data = await client.assistants.get(config.assistantId);
|
||||
setAssistant(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch assistant:", error);
|
||||
setAssistant({
|
||||
assistant_id: config.assistantId,
|
||||
graph_id: config.assistantId,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
config: {},
|
||||
metadata: {},
|
||||
version: 1,
|
||||
name: "Assistant",
|
||||
context: {},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
// We should try to list out the assistants for this graph, and then use the default one.
|
||||
// TODO: Paginate this search, but 100 should be enough for graph name
|
||||
const assistants = await client.assistants.search({
|
||||
graphId: config.assistantId,
|
||||
limit: 100,
|
||||
});
|
||||
const defaultAssistant = assistants.find(
|
||||
(assistant) => assistant.metadata?.["created_by"] === "system"
|
||||
);
|
||||
if (defaultAssistant === undefined) {
|
||||
throw new Error("No default assistant found");
|
||||
}
|
||||
setAssistant(defaultAssistant);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to find default assistant from graph_id: try setting the assistant_id directly:",
|
||||
error
|
||||
);
|
||||
setAssistant({
|
||||
assistant_id: config.assistantId,
|
||||
graph_id: config.assistantId,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
config: {},
|
||||
metadata: {},
|
||||
version: 1,
|
||||
name: config.assistantId,
|
||||
context: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [client, config.assistantId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAssistant();
|
||||
}, [fetchAssistant]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfigDialog
|
||||
open={configDialogOpen}
|
||||
onOpenChange={setConfigDialogOpen}
|
||||
onSave={handleSaveConfig}
|
||||
initialConfig={config}
|
||||
/>
|
||||
<div className="flex h-screen flex-col">
|
||||
<header className="flex h-16 items-center justify-between border-b border-border px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-xl font-semibold">Deep Agent UI</h1>
|
||||
{!sidebar && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSidebar("1")}
|
||||
className="rounded-md border border-border bg-card p-3 text-foreground hover:bg-accent"
|
||||
>
|
||||
<MessagesSquare className="mr-2 h-4 w-4" />
|
||||
Threads
|
||||
{interruptCount > 0 && (
|
||||
<span className="ml-2 inline-flex min-h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] text-destructive-foreground">
|
||||
{interruptCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">Assistant:</span>{" "}
|
||||
{config.assistantId}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setConfigDialogOpen(true)}
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setThreadId(null)}
|
||||
disabled={!threadId}
|
||||
className="border-[#2F6868] bg-[#2F6868] text-white hover:bg-[#2F6868]/80"
|
||||
>
|
||||
<SquarePen className="mr-2 h-4 w-4" />
|
||||
New Thread
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
autoSaveId="standalone-chat"
|
||||
>
|
||||
{sidebar && (
|
||||
<>
|
||||
<ResizablePanel
|
||||
id="thread-history"
|
||||
order={1}
|
||||
defaultSize={25}
|
||||
minSize={20}
|
||||
className="relative min-w-[380px]"
|
||||
>
|
||||
<ThreadList
|
||||
onThreadSelect={async (id) => {
|
||||
await setThreadId(id);
|
||||
}}
|
||||
onMutateReady={(fn) => setMutateThreads(() => fn)}
|
||||
onClose={() => setSidebar(null)}
|
||||
onInterruptCountChange={setInterruptCount}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ResizablePanel
|
||||
id="chat"
|
||||
className="relative flex flex-col"
|
||||
order={2}
|
||||
>
|
||||
<ChatProvider
|
||||
activeAssistant={assistant}
|
||||
onHistoryRevalidate={() => mutateThreads?.()}
|
||||
>
|
||||
<ChatInterface assistant={assistant} />
|
||||
</ChatProvider>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HomePageContent() {
|
||||
const [config, setConfig] = useState<StandaloneConfig | null>(null);
|
||||
const [configDialogOpen, setConfigDialogOpen] = useState(false);
|
||||
const [assistantId, setAssistantId] = useQueryState("assistantId");
|
||||
|
||||
// On mount, check for saved config, otherwise show config dialog
|
||||
useEffect(() => {
|
||||
const savedConfig = getConfig();
|
||||
if (savedConfig) {
|
||||
setConfig(savedConfig);
|
||||
if (!assistantId) {
|
||||
setAssistantId(savedConfig.assistantId);
|
||||
}
|
||||
} else {
|
||||
setConfigDialogOpen(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// If config changes, update the assistantId
|
||||
useEffect(() => {
|
||||
if (config && !assistantId) {
|
||||
setAssistantId(config.assistantId);
|
||||
}
|
||||
}, [config, assistantId, setAssistantId]);
|
||||
|
||||
const handleSaveConfig = useCallback((newConfig: StandaloneConfig) => {
|
||||
saveConfig(newConfig);
|
||||
setConfig(newConfig);
|
||||
}, []);
|
||||
|
||||
const langsmithApiKey =
|
||||
config?.langsmithApiKey || process.env.NEXT_PUBLIC_LANGSMITH_API_KEY || "";
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<>
|
||||
<ConfigDialog
|
||||
open={configDialogOpen}
|
||||
onOpenChange={setConfigDialogOpen}
|
||||
onSave={handleSaveConfig}
|
||||
/>
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold">Welcome to Standalone Chat</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Configure your deployment to get started
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setConfigDialogOpen(true)}
|
||||
className="mt-4"
|
||||
>
|
||||
Open Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ClientProvider
|
||||
deploymentUrl={config.deploymentUrl}
|
||||
apiKey={langsmithApiKey}
|
||||
>
|
||||
<HomePageInner
|
||||
config={config}
|
||||
configDialogOpen={configDialogOpen}
|
||||
setConfigDialogOpen={setConfigDialogOpen}
|
||||
handleSaveConfig={handleSaveConfig}
|
||||
/>
|
||||
</ClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<HomePageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
57
deep-agents-ui/src/app/types/types.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: string;
|
||||
status: "pending" | "completed" | "error" | "interrupted";
|
||||
}
|
||||
|
||||
export interface SubAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
subAgentName: string;
|
||||
input: Record<string, unknown>;
|
||||
output?: Record<string, unknown>;
|
||||
status: "pending" | "active" | "completed" | "error";
|
||||
}
|
||||
|
||||
export interface FileItem {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface TodoItem {
|
||||
id: string;
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface InterruptData {
|
||||
value: any;
|
||||
ns?: string[];
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface ActionRequest {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ReviewConfig {
|
||||
actionName: string;
|
||||
allowedDecisions?: string[];
|
||||
}
|
||||
|
||||
export interface ToolApprovalInterruptData {
|
||||
action_requests: ActionRequest[];
|
||||
review_configs?: ReviewConfig[];
|
||||
}
|
||||
157
deep-agents-ui/src/app/utils/utils.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Message } from "@langchain/langgraph-sdk";
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function extractStringFromMessageContent(message: Message): string {
|
||||
return typeof message.content === "string"
|
||||
? message.content
|
||||
: Array.isArray(message.content)
|
||||
? message.content
|
||||
.filter(
|
||||
(c: unknown) =>
|
||||
(typeof c === "object" &&
|
||||
c !== null &&
|
||||
"type" in c &&
|
||||
(c as { type: string }).type === "text") ||
|
||||
typeof c === "string"
|
||||
)
|
||||
.map((c: unknown) =>
|
||||
typeof c === "string"
|
||||
? c
|
||||
: typeof c === "object" && c !== null && "text" in c
|
||||
? (c as { text?: string }).text || ""
|
||||
: ""
|
||||
)
|
||||
.join("")
|
||||
: "";
|
||||
}
|
||||
|
||||
export function extractSubAgentContent(data: unknown): string {
|
||||
if (typeof data === "string") {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (data && typeof data === "object") {
|
||||
const dataObj = data as Record<string, unknown>;
|
||||
|
||||
// Try to extract description first
|
||||
if (dataObj.description && typeof dataObj.description === "string") {
|
||||
return dataObj.description;
|
||||
}
|
||||
|
||||
// Then try prompt
|
||||
if (dataObj.prompt && typeof dataObj.prompt === "string") {
|
||||
return dataObj.prompt;
|
||||
}
|
||||
|
||||
// For output objects, try result
|
||||
if (dataObj.result && typeof dataObj.result === "string") {
|
||||
return dataObj.result;
|
||||
}
|
||||
|
||||
// Fallback to JSON stringification
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
// Fallback for any other type
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
export function isPreparingToCallTaskTool(messages: Message[]): boolean {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
return (
|
||||
(lastMessage.type === "ai" &&
|
||||
lastMessage.tool_calls?.some(
|
||||
(call: { name?: string }) => call.name === "task"
|
||||
)) ||
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
export function formatMessageForLLM(message: Message): string {
|
||||
let role: string;
|
||||
if (message.type === "human") {
|
||||
role = "Human";
|
||||
} else if (message.type === "ai") {
|
||||
role = "Assistant";
|
||||
} else if (message.type === "tool") {
|
||||
role = `Tool Result`;
|
||||
} else {
|
||||
role = message.type || "Unknown";
|
||||
}
|
||||
|
||||
const timestamp = message.id ? ` (${message.id.slice(0, 8)})` : "";
|
||||
|
||||
let contentText = "";
|
||||
|
||||
// Extract content text
|
||||
if (typeof message.content === "string") {
|
||||
contentText = message.content;
|
||||
} else if (Array.isArray(message.content)) {
|
||||
const textParts: string[] = [];
|
||||
|
||||
message.content.forEach((part: any) => {
|
||||
if (typeof part === "string") {
|
||||
textParts.push(part);
|
||||
} else if (part && typeof part === "object" && part.type === "text") {
|
||||
textParts.push(part.text || "");
|
||||
}
|
||||
// Ignore other types like tool_use in content - we handle tool calls separately
|
||||
});
|
||||
|
||||
contentText = textParts.join("\n\n").trim();
|
||||
}
|
||||
|
||||
// For tool messages, include additional tool metadata
|
||||
if (message.type === "tool") {
|
||||
const toolName = (message as any).name || "unknown_tool";
|
||||
const toolCallId = (message as any).tool_call_id || "";
|
||||
role = `Tool Result [${toolName}]`;
|
||||
if (toolCallId) {
|
||||
role += ` (call_id: ${toolCallId.slice(0, 8)})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool calls from .tool_calls property (for AI messages)
|
||||
const toolCallsText: string[] = [];
|
||||
if (
|
||||
message.type === "ai" &&
|
||||
message.tool_calls &&
|
||||
Array.isArray(message.tool_calls) &&
|
||||
message.tool_calls.length > 0
|
||||
) {
|
||||
message.tool_calls.forEach((call: any) => {
|
||||
const toolName = call.name || "unknown_tool";
|
||||
const toolArgs = call.args ? JSON.stringify(call.args, null, 2) : "{}";
|
||||
toolCallsText.push(`[Tool Call: ${toolName}]\nArguments: ${toolArgs}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Combine content and tool calls
|
||||
const parts: string[] = [];
|
||||
if (contentText) {
|
||||
parts.push(contentText);
|
||||
}
|
||||
if (toolCallsText.length > 0) {
|
||||
parts.push(...toolCallsText);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return `${role}${timestamp}: [Empty message]`;
|
||||
}
|
||||
|
||||
if (parts.length === 1) {
|
||||
return `${role}${timestamp}: ${parts[0]}`;
|
||||
}
|
||||
|
||||
return `${role}${timestamp}:\n${parts.join("\n\n")}`;
|
||||
}
|
||||
|
||||
export function formatConversationForLLM(messages: Message[]): string {
|
||||
const formattedMessages = messages.map(formatMessageForLLM);
|
||||
return formattedMessages.join("\n\n---\n\n");
|
||||
}
|
||||
59
deep-agents-ui/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
163
deep-agents-ui/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return (
|
||||
<DialogPrimitive.Root
|
||||
data-slot="dialog"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<DialogPrimitive.Trigger
|
||||
data-slot="dialog-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return (
|
||||
<DialogPrimitive.Portal
|
||||
data-slot="dialog-portal"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="focus:outline-hidden absolute right-4 top-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg font-semibold leading-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
21
deep-agents-ui/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-blue-200 selection:text-gray-900 file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30 dark:selection:bg-blue-600 dark:selection:text-white md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
26
deep-agents-ui/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
45
deep-agents-ui/src/components/ui/resizable.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { GripVertical } from "lucide-react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel;
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean;
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
);
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
58
deep-agents-ui/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none p-px transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
160
deep-agents-ui/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] origin-[--radix-select-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
15
deep-agents-ui/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
55
deep-agents-ui/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
height: "20px",
|
||||
width: "36px",
|
||||
alignItems: "center",
|
||||
borderRadius: "9999px",
|
||||
border: "1px solid #d1d5db",
|
||||
backgroundColor: "var(--color-border)",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 0.2s",
|
||||
}}
|
||||
data-state-styles={{
|
||||
checked: {
|
||||
backgroundColor: "var(--color-primary)",
|
||||
},
|
||||
}}
|
||||
className={cn(
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:!bg-[var(--color-primary)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
style={{
|
||||
display: "block",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
borderRadius: "9999px",
|
||||
backgroundColor: "white",
|
||||
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.2)",
|
||||
transition: "transform 0.2s",
|
||||
transform: "translateX(1px)",
|
||||
}}
|
||||
className="data-[state=checked]:!translate-x-[17px]"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
||||
66
deep-agents-ui/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium text-foreground transition-[color,box-shadow] focus-visible:border-ring focus-visible:outline-1 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:shadow-sm dark:text-muted-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
23
deep-agents-ui/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
42
deep-agents-ui/src/components/ui/tooltip-icon-button.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import { Button } from "./button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "./tooltip";
|
||||
|
||||
interface TooltipIconButtonProps {
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function TooltipIconButton({
|
||||
icon,
|
||||
onClick,
|
||||
tooltip,
|
||||
disabled,
|
||||
}: TooltipIconButtonProps) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
69
deep-agents-ui/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root
|
||||
data-slot="tooltip"
|
||||
{...props}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return (
|
||||
<TooltipPrimitive.Trigger
|
||||
data-slot="tooltip-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"text-primary-foreground origin-(--radix-tooltip-content-transform-origin) z-50 w-fit text-balance rounded-md bg-primary px-3 py-1.5 text-xs animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-primary fill-primary" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
25
deep-agents-ui/src/lib/config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface StandaloneConfig {
|
||||
deploymentUrl: string;
|
||||
assistantId: string;
|
||||
langsmithApiKey?: string;
|
||||
}
|
||||
|
||||
const CONFIG_KEY = "deep-agent-config";
|
||||
|
||||
export function getConfig(): StandaloneConfig | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
const stored = localStorage.getItem(CONFIG_KEY);
|
||||
if (!stored) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveConfig(config: StandaloneConfig): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
|
||||
}
|
||||
6
deep-agents-ui/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
37
deep-agents-ui/src/providers/ChatProvider.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, createContext, useContext } from "react";
|
||||
import { Assistant } from "@langchain/langgraph-sdk";
|
||||
import { type StateType, useChat } from "@/app/hooks/useChat";
|
||||
import type { UseStreamThread } from "@langchain/langgraph-sdk/react";
|
||||
|
||||
interface ChatProviderProps {
|
||||
children: ReactNode;
|
||||
activeAssistant: Assistant | null;
|
||||
onHistoryRevalidate?: () => void;
|
||||
thread?: UseStreamThread<StateType>;
|
||||
}
|
||||
|
||||
export function ChatProvider({
|
||||
children,
|
||||
activeAssistant,
|
||||
onHistoryRevalidate,
|
||||
thread,
|
||||
}: ChatProviderProps) {
|
||||
const chat = useChat({ activeAssistant, onHistoryRevalidate, thread });
|
||||
return <ChatContext.Provider value={chat}>{children}</ChatContext.Provider>;
|
||||
}
|
||||
|
||||
export type ChatContextType = ReturnType<typeof useChat>;
|
||||
|
||||
export const ChatContext = createContext<ChatContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
export function useChatContext() {
|
||||
const context = useContext(ChatContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useChatContext must be used within a ChatProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
47
deep-agents-ui/src/providers/ClientProvider.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo, ReactNode } from "react";
|
||||
import { Client } from "@langchain/langgraph-sdk";
|
||||
|
||||
interface ClientContextValue {
|
||||
client: Client;
|
||||
}
|
||||
|
||||
const ClientContext = createContext<ClientContextValue | null>(null);
|
||||
|
||||
interface ClientProviderProps {
|
||||
children: ReactNode;
|
||||
deploymentUrl: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export function ClientProvider({
|
||||
children,
|
||||
deploymentUrl,
|
||||
apiKey,
|
||||
}: ClientProviderProps) {
|
||||
const client = useMemo(() => {
|
||||
return new Client({
|
||||
apiUrl: deploymentUrl,
|
||||
defaultHeaders: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": apiKey,
|
||||
},
|
||||
});
|
||||
}, [deploymentUrl, apiKey]);
|
||||
|
||||
const value = useMemo(() => ({ client }), [client]);
|
||||
|
||||
return (
|
||||
<ClientContext.Provider value={value}>{children}</ClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useClient(): Client {
|
||||
const context = useContext(ClientContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useClient must be used within a ClientProvider");
|
||||
}
|
||||
return context.client;
|
||||
}
|
||||
488
deep-agents-ui/tailwind.config.mjs
Normal file
@@ -0,0 +1,488 @@
|
||||
import { blackA, green, mauve, slate, violet } from "@radix-ui/colors";
|
||||
import plugin from "tailwindcss/plugin";
|
||||
import containerQueries from "@tailwindcss/container-queries";
|
||||
import typography from "@tailwindcss/typography";
|
||||
import forms from "@tailwindcss/forms";
|
||||
import tailwindcssAnimate from "tailwindcss-animate";
|
||||
import headlessui from "@headlessui/tailwindcss";
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
darkMode: ["class", '[data-joy-color-scheme="dark"]'],
|
||||
theme: {
|
||||
extend: {
|
||||
fontSize: {
|
||||
xxs: [
|
||||
"0.75rem", // 12px
|
||||
{
|
||||
lineHeight: "1.125rem", // 18px
|
||||
},
|
||||
],
|
||||
xs: [
|
||||
"0.8125rem", // 13px
|
||||
{
|
||||
lineHeight: "1.125rem", // 18px
|
||||
},
|
||||
],
|
||||
sm: [
|
||||
"0.875rem", // 14px
|
||||
{
|
||||
lineHeight: "1.25rem", // 20px
|
||||
},
|
||||
],
|
||||
base: [
|
||||
"1rem", // 16px
|
||||
{
|
||||
lineHeight: "1.5rem", // 24px
|
||||
},
|
||||
],
|
||||
lg: [
|
||||
"1.125rem", // 18px
|
||||
{
|
||||
lineHeight: "1.75rem", // 28px
|
||||
letterSpacing: "-0.01em", // tracking-tight
|
||||
},
|
||||
],
|
||||
xl: [
|
||||
"1.25rem", // 20px
|
||||
{
|
||||
lineHeight: "1.875rem", // 30px
|
||||
letterSpacing: "-0.01em", // tracking-tight
|
||||
},
|
||||
],
|
||||
},
|
||||
fontFamily: {
|
||||
mono: [
|
||||
`"Fira Code"`,
|
||||
`ui-monospace`,
|
||||
`SFMono-Regular`,
|
||||
`Menlo`,
|
||||
`Monaco`,
|
||||
`Consolas`,
|
||||
`"Liberation Mono"`,
|
||||
`"Courier New"`,
|
||||
`monospace`,
|
||||
],
|
||||
},
|
||||
letterSpacing: {
|
||||
tighter: "-0.04em",
|
||||
tight: "-0.03em",
|
||||
snug: "-0.02em",
|
||||
normal: "0",
|
||||
wide: "0.03em",
|
||||
},
|
||||
lineHeight: {
|
||||
tight: "1.20",
|
||||
},
|
||||
backgroundImage: {
|
||||
navMenu: "linear-gradient(132deg, #4499F7 0%, #3FCDD6 100%)",
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
xs: "3px",
|
||||
},
|
||||
boxShadow: {
|
||||
xs: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
},
|
||||
backgroundColor: {
|
||||
primary: "var(--bg-primary)",
|
||||
"primary-hover": "var(--bg-primary_hover)",
|
||||
secondary: "var(--bg-secondary)",
|
||||
"secondary-hover": "var(--bg-secondary_hover)",
|
||||
tertiary: "var(--bg-tertiary)",
|
||||
quaternary: "var(--bg-quaternary)",
|
||||
|
||||
"brand-primary": "var(--bg-brand-primary)",
|
||||
"brand-primary-hover": "var(--bg-brand-primary_hover)",
|
||||
"brand-secondary": "var(--bg-brand-secondary)",
|
||||
"brand-tertiary": "var(--bg-brand-tertiary)",
|
||||
purple: "var(--bg-purple)",
|
||||
|
||||
"success-primary": "var(--bg-success-primary)",
|
||||
"success-secondary": "var(--bg-success-secondary)",
|
||||
"success-strong": "var(--bg-success-strong)",
|
||||
"error-primary": "var(--bg-error-primary)",
|
||||
"error-secondary": "var(--bg-error-secondary)",
|
||||
"error-strong": "var(--bg-error-strong)",
|
||||
"error-strong-hover": "var(--bg-error-strong-hover)",
|
||||
"warning-primary": "var(--bg-warning-primary)",
|
||||
"warning-secondary": "var(--bg-warning-secondary)",
|
||||
"warning-strong": "var(--bg-warning-strong)",
|
||||
},
|
||||
borderColor: {
|
||||
primary: "var(--border-primary)",
|
||||
secondary: "var(--border-secondary)",
|
||||
tertiary: "var(--border-tertiary)",
|
||||
error: "var(--border-error)",
|
||||
"error-strong": "var(--border-error-strong)",
|
||||
brand: "var(--border-brand)",
|
||||
"brand-strong": "var(--border-brand-strong)",
|
||||
"brand-subtle": "var(--border-brand-subtle)",
|
||||
strong: "var(--border-strong)",
|
||||
warning: "var(--border-warning)",
|
||||
success: "var(--border-success)",
|
||||
purple: "var(--border-purple)",
|
||||
"status-green": "var(--border-status-green)",
|
||||
"status-orange": "var(--border-status-orange)",
|
||||
"status-yellow": "var(--border-status-yellow)",
|
||||
"status-red": "var(--border-status-red)",
|
||||
},
|
||||
textColor: {
|
||||
primary: "var(--text-primary)",
|
||||
secondary: "var(--text-secondary)",
|
||||
tertiary: "var(--text-tertiary)",
|
||||
quaternary: "var(--text-quaternary)",
|
||||
disabled: "var(--text-disabled)",
|
||||
error: "var(--text-error)",
|
||||
warning: "var(--text-warning)",
|
||||
success: "var(--text-success)",
|
||||
placeholder: "var(--text-placeholder)",
|
||||
purple: "var(--text-purple)",
|
||||
"brand-primary": "var(--text-brand-primary)",
|
||||
"brand-secondary": "var(--text-brand-secondary)",
|
||||
"brand-tertiary": "var(--text-brand-tertiary)",
|
||||
"brand-disabled": "var(--text-brand-disabled)",
|
||||
"status-green": "var(--text-status-green)",
|
||||
"status-orange": "var(--text-status-orange)",
|
||||
"status-yellow": "var(--text-status-yellow)",
|
||||
"status-red": "var(--text-status-red)",
|
||||
"button-primary": "var(--text-button-primary)",
|
||||
},
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: "hsl(var(--sidebar))",
|
||||
},
|
||||
chart: {
|
||||
1: "hsl(var(--chart-1))",
|
||||
2: "hsl(var(--chart-2))",
|
||||
3: "hsl(var(--chart-3))",
|
||||
4: "hsl(var(--chart-4))",
|
||||
5: "hsl(var(--chart-5))",
|
||||
},
|
||||
ls: {
|
||||
blue: "hsl(211.5, 91.8%, 61.8%)",
|
||||
black: "hsl(var(--ls-black))",
|
||||
green: {
|
||||
600: "hsl(122, 63%, 38%)",
|
||||
},
|
||||
white: "var(--white)",
|
||||
black: "var(--black)",
|
||||
red: {
|
||||
25: "var(--red-25)",
|
||||
50: "var(--red-50)",
|
||||
100: "var(--red-100)",
|
||||
200: "var(--red-200)",
|
||||
300: "var(--red-300)",
|
||||
400: "var(--red-400)",
|
||||
500: "var(--red-500)",
|
||||
600: "var(--red-600)",
|
||||
700: "var(--red-700)",
|
||||
800: "var(--red-800)",
|
||||
900: "var(--red-900)",
|
||||
950: "var(--red-950)",
|
||||
},
|
||||
orange: {
|
||||
25: "var(--orange-25)",
|
||||
50: "var(--orange-50)",
|
||||
100: "var(--orange-100)",
|
||||
200: "var(--orange-200)",
|
||||
300: "var(--orange-300)",
|
||||
400: "var(--orange-400)",
|
||||
500: "var(--orange-500)",
|
||||
600: "var(--orange-600)",
|
||||
700: "var(--orange-700)",
|
||||
800: "var(--orange-800)",
|
||||
900: "var(--orange-900)",
|
||||
950: "var(--orange-950)",
|
||||
},
|
||||
gray: {
|
||||
50: "var(--gray-50)",
|
||||
100: "var(--gray-100)",
|
||||
200: "var(--gray-200)",
|
||||
300: "var(--gray-300)",
|
||||
400: "var(--gray-400)",
|
||||
500: "var(--gray-500)",
|
||||
600: "var(--gray-600)",
|
||||
700: "var(--gray-700)",
|
||||
800: "var(--gray-800)",
|
||||
900: "var(--gray-900)",
|
||||
950: "var(--gray-950)",
|
||||
},
|
||||
green: {
|
||||
25: "var(--green-25)",
|
||||
50: "var(--green-50)",
|
||||
100: "var(--green-100)",
|
||||
200: "var(--green-200)",
|
||||
300: "var(--green-300)",
|
||||
400: "var(--green-400)",
|
||||
500: "var(--green-500)",
|
||||
600: "var(--green-600)",
|
||||
700: "var(--green-700)",
|
||||
800: "var(--green-800)",
|
||||
900: "var(--green-900)",
|
||||
950: "var(--green-950)",
|
||||
},
|
||||
},
|
||||
brand: {
|
||||
green: {
|
||||
25: "var(--brand-25)",
|
||||
50: "var(--brand-50)",
|
||||
100: "var(--brand-100)",
|
||||
200: "var(--brand-200)",
|
||||
300: "var(--brand-300)",
|
||||
400: "var(--brand-400)",
|
||||
500: "var(--brand-500)",
|
||||
600: "var(--brand-600)",
|
||||
700: "var(--brand-700)",
|
||||
800: "var(--brand-800)",
|
||||
900: "var(--brand-900)",
|
||||
950: "var(--brand-950)",
|
||||
},
|
||||
},
|
||||
},
|
||||
keyframes: {
|
||||
hide: {
|
||||
from: { opacity: 1 },
|
||||
to: { opacity: 0 },
|
||||
},
|
||||
slideIn: {
|
||||
from: {
|
||||
transform: "translateX(calc(100% + var(--viewport-padding)))",
|
||||
},
|
||||
to: { transform: "translateX(0)" },
|
||||
},
|
||||
swipeOut: {
|
||||
from: { transform: "translateX(var(--radix-toast-swipe-end-x))" },
|
||||
to: { transform: "translateX(calc(100% + var(--viewport-padding)))" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
hide: "hide 100ms ease-in",
|
||||
slideIn: "slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
swipeOut: "swipeOut 100ms ease-out",
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
playground: {
|
||||
css: {
|
||||
"h1, h2, h3, h4, h5, h6": {
|
||||
fontWeight: "bold",
|
||||
},
|
||||
h1: {
|
||||
fontSize: "24px",
|
||||
},
|
||||
h2: {
|
||||
fontSize: "20px",
|
||||
},
|
||||
h3: {
|
||||
fontSize: "18px",
|
||||
},
|
||||
h4: {
|
||||
fontSize: "16px",
|
||||
},
|
||||
h5: {
|
||||
fontSize: "14px",
|
||||
},
|
||||
h6: {
|
||||
fontSize: "12px",
|
||||
},
|
||||
ul: {
|
||||
marginLeft: "20px !important",
|
||||
listStyleType: "disc !important",
|
||||
},
|
||||
ol: {
|
||||
marginLeft: "20px !important",
|
||||
listStyleType: "decimal !important",
|
||||
},
|
||||
a: {
|
||||
color: "#287977",
|
||||
textDecoration: "underline",
|
||||
"&:hover": {
|
||||
textDecoration: "underline",
|
||||
},
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
th: {
|
||||
padding: "0.5rem",
|
||||
border: "1px solid var(--gray-100)",
|
||||
fontWeight: "bold",
|
||||
textAlign: "left",
|
||||
},
|
||||
td: {
|
||||
padding: "0.5rem",
|
||||
border: "1px solid var(--gray-100)",
|
||||
},
|
||||
},
|
||||
blockquote: {
|
||||
borderLeft: "2px solid var(--gray-100)",
|
||||
paddingLeft: "1rem",
|
||||
marginLeft: "0",
|
||||
fontStyle: "italic",
|
||||
},
|
||||
|
||||
"s, strike, del": {
|
||||
textDecoration: "line-through",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
containerQueries,
|
||||
typography,
|
||||
forms,
|
||||
tailwindcssAnimate,
|
||||
headlessui,
|
||||
plugin(({ addUtilities, addBase }) => {
|
||||
addBase({
|
||||
input: {
|
||||
borderWidth: "0",
|
||||
padding: "0",
|
||||
},
|
||||
// Global scrollbar styles for all scrollable elements
|
||||
"html, body, *": {
|
||||
"scrollbar-width": "thin",
|
||||
"scrollbar-color": "var(--scrollbar-thumb) var(--bg-primary)",
|
||||
},
|
||||
"html::-webkit-scrollbar, body::-webkit-scrollbar, *::-webkit-scrollbar":
|
||||
{
|
||||
width: "8px",
|
||||
background: "var(--bg-primary)",
|
||||
},
|
||||
"html::-webkit-scrollbar-track, body::-webkit-scrollbar-track, *::-webkit-scrollbar-track":
|
||||
{
|
||||
background: "var(--bg-primary)",
|
||||
},
|
||||
"html::-webkit-scrollbar-thumb, body::-webkit-scrollbar-track, *::-webkit-scrollbar-thumb":
|
||||
{
|
||||
background: "var(--scrollbar-thumb)",
|
||||
"border-radius": "4px",
|
||||
},
|
||||
"html::-webkit-scrollbar-thumb:hover, body::-webkit-scrollbar-thumb:hover, *::-webkit-scrollbar-thumb:hover":
|
||||
{
|
||||
background: "var(--scrollbar-thumb-hover)",
|
||||
},
|
||||
});
|
||||
addUtilities({
|
||||
".no-scrollbar": {
|
||||
"scrollbar-width": "none",
|
||||
"&::-webkit-scrollbar": {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// https://github.com/tailwindlabs/tailwindcss/discussions/12127
|
||||
addUtilities({
|
||||
".break-anywhere": {
|
||||
"@supports (overflow-wrap: anywhere)": {
|
||||
"overflow-wrap": "anywhere",
|
||||
},
|
||||
"@supports not (overflow-wrap: anywhere)": {
|
||||
"word-break": "break-word",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
addUtilities({
|
||||
".no-number-spinner": {
|
||||
MozAppearance: "textfield",
|
||||
"&::-webkit-outer-spin-button": {
|
||||
WebkitAppearance: "none !important",
|
||||
margin: 0,
|
||||
},
|
||||
"&::-webkit-inner-spin-button": {
|
||||
WebkitAppearance: "none !important",
|
||||
margin: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
addUtilities({
|
||||
".text-security": {
|
||||
textSecurity: "disc",
|
||||
WebkitTextSecurity: "disc",
|
||||
MozTextSecurity: "disc",
|
||||
},
|
||||
});
|
||||
|
||||
addUtilities({
|
||||
".display-sm": {
|
||||
fontSize: "1rem", // 16px
|
||||
lineHeight: "1.5rem", // 24px
|
||||
fontWeight: "600", // semibold
|
||||
},
|
||||
".display-base": {
|
||||
fontSize: "1.5rem", // 24px
|
||||
lineHeight: "2rem", // 32px
|
||||
letterSpacing: "-0.01em", // tracking-tight
|
||||
},
|
||||
".display-lg": {
|
||||
fontSize: "1.875rem", // 30px
|
||||
lineHeight: "2.375rem", // 38px
|
||||
letterSpacing: "-0.01em", // tracking-tight
|
||||
},
|
||||
".display-xl": {
|
||||
fontSize: "2.25rem", // 36px
|
||||
lineHeight: "2.75rem", // 44px
|
||||
letterSpacing: "-0.01em", // tracking-tight
|
||||
},
|
||||
".display-2xl": {
|
||||
fontSize: "3rem", // 48px
|
||||
lineHeight: "3.75rem", // 60px
|
||||
letterSpacing: "-0.01em", // tracking-tight
|
||||
},
|
||||
".caps-label-sm": {
|
||||
fontSize: "0.875rem", // 14px
|
||||
lineHeight: "1.25rem", // 20px
|
||||
letterSpacing: "0.02625rem", // 0.42px
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
".caps-label-xs": {
|
||||
fontSize: "0.75rem", // 14px
|
||||
lineHeight: "1.125rem", // 20px
|
||||
letterSpacing: "0.0225rem", // 0.42px
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
});
|
||||
}),
|
||||
],
|
||||
};
|
||||
41
deep-agents-ui/tsconfig.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
5270
deep-agents-ui/yarn.lock
Normal file
215
deepagents_sourcecode/.gitignore
vendored
Normal file
@@ -0,0 +1,215 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[codz]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
#poetry.toml
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||
#pdm.lock
|
||||
#pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# pixi
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
||||
#pixi.lock
|
||||
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
||||
# in the .venv directory. It is recommended not to include this directory in version control.
|
||||
.pixi
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.envrc
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Abstra
|
||||
# Abstra is an AI-powered process automation framework.
|
||||
# Ignore directories containing user credentials, local state, and settings.
|
||||
# Learn more at https://abstra.io/docs
|
||||
.abstra/
|
||||
|
||||
# Visual Studio Code
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# you could uncomment the following to ignore the entire vscode folder
|
||||
# .vscode/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Cursor
|
||||
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
||||
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
||||
# refer to https://docs.cursor.com/context/ignore-files
|
||||
.cursorignore
|
||||
.cursorindexingignore
|
||||
|
||||
# Marimo
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# LangGraph
|
||||
.langgraph_api
|
||||
|
||||
#claude
|
||||
.claude
|
||||
|
||||
.idea
|
||||
21
deepagents_sourcecode/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Harrison Chase
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
320
deepagents_sourcecode/README.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# 🚀🧠 Deep Agents
|
||||
|
||||
Agents can increasingly tackle long-horizon tasks, [with agent task length doubling every 7 months](https://metr.org/blog/2025-03-19-measuring-ai-ability-to-complete-long-tasks/)! But, long horizon tasks often span dozens of tool calls, which present cost and reliability challenges. Popular agents such as [Claude Code](https://code.claude.com/docs) and [Manus](https://www.youtube.com/watch?v=6_BcCthVvb8) use some common principles to address these challenges, including **planning** (prior to task execution), **computer access** (giving the agent access to a shell and a filesystem), and **sub-agent delegation** (isolated task execution). `deepagents` is a simple agent harness that implements these tools, but is open source and easily extendable with your own custom tools and instructions.
|
||||
|
||||
<img src=".github/images/deepagents_banner.png" alt="deep agent" width="100%"/>
|
||||
|
||||
## 📚 Resources
|
||||
|
||||
- **[Documentation](https://docs.langchain.com/oss/python/deepagents/overview)** - Full overview and API reference
|
||||
- **[Korean Documentation](docs/DeepAgents_Documentation_KR.md)** - DeepAgents Technical Documentation (KR)
|
||||
- **[Quickstarts Repo](https://github.com/langchain-ai/deepagents-quickstarts)** - Examples and use-cases
|
||||
- **[CLI](libs/deepagents-cli/)** - Interactive command-line interface with skills, memory, and HITL workflows
|
||||
|
||||
## 🚀 Quickstart
|
||||
|
||||
You can give `deepagents` custom tools. Below, we'll optionally provide the `tavily` tool to search the web. This tool will be added to the `deepagents` build-in tools (see below).
|
||||
|
||||
```bash
|
||||
pip install deepagents tavily-python
|
||||
```
|
||||
|
||||
Set `TAVILY_API_KEY` in your environment ([get one here](https://www.tavily.com/)):
|
||||
|
||||
```python
|
||||
import os
|
||||
from deepagents import create_deep_agent
|
||||
from tavily import TavilyClient
|
||||
|
||||
tavily_client = TavilyClient(api_key=os.environ["TAVILY_API_KEY"])
|
||||
|
||||
def internet_search(query: str, max_results: int = 5):
|
||||
"""Run a web search"""
|
||||
return tavily_client.search(query, max_results=max_results)
|
||||
|
||||
agent = create_deep_agent(
|
||||
tools=[internet_search],
|
||||
system_prompt="Conduct research and write a polished report.",
|
||||
)
|
||||
|
||||
result = agent.invoke({"messages": [{"role": "user", "content": "What is LangGraph?"}]})
|
||||
```
|
||||
|
||||
The agent created with `create_deep_agent` is compiled [LangGraph StateGraph](https://docs.langchain.com/oss/python/langgraph/overview), so it can be used with streaming, human-in-the-loop, memory, or Studio just like any LangGraph agent. See our [quickstarts repo](https://github.com/langchain-ai/deepagents-quickstarts) for more examples.
|
||||
|
||||
## Customizing Deep Agents
|
||||
|
||||
There are several parameters you can pass to `create_deep_agent`.
|
||||
|
||||
### `model`
|
||||
|
||||
By default, `deepagents` uses `"claude-sonnet-4-5-20250929"`. You can customize this by passing any [LangChain model object](https://python.langchain.com/docs/integrations/chat/).
|
||||
|
||||
```python
|
||||
from langchain.chat_models import init_chat_model
|
||||
from deepagents import create_deep_agent
|
||||
|
||||
model = init_chat_model("openai:gpt-4o")
|
||||
agent = create_deep_agent(
|
||||
model=model,
|
||||
)
|
||||
```
|
||||
|
||||
### `system_prompt`
|
||||
|
||||
You can provide a `system_prompt` parameter to `create_deep_agent()`. This custom prompt is **appended to** default instructions that are automatically injected by middleware.
|
||||
|
||||
When writing a custom system prompt, you should:
|
||||
|
||||
- ✅ Define domain-specific workflows (e.g., research methodology, data analysis steps)
|
||||
- ✅ Provide concrete examples for your use case
|
||||
- ✅ Add specialized guidance (e.g., "batch similar research tasks into a single TODO")
|
||||
- ✅ Define stopping criteria and resource limits
|
||||
- ✅ Explain how tools work together in your workflow
|
||||
|
||||
**Don't:**
|
||||
|
||||
- ❌ Re-explain what standard tools do (already covered by middleware)
|
||||
- ❌ Duplicate middleware instructions about tool usage
|
||||
- ❌ Contradict default instructions (work with them, not against them)
|
||||
|
||||
```python
|
||||
from deepagents import create_deep_agent
|
||||
research_instructions = """your custom system prompt"""
|
||||
agent = create_deep_agent(
|
||||
system_prompt=research_instructions,
|
||||
)
|
||||
```
|
||||
|
||||
See our [quickstarts repo](https://github.com/langchain-ai/deepagents-quickstarts) for more examples.
|
||||
|
||||
### `tools`
|
||||
|
||||
Provide custom tools to your agent (in addition to [Built-in Tools](#built-in-tools)):
|
||||
|
||||
```python
|
||||
from deepagents import create_deep_agent
|
||||
|
||||
def internet_search(query: str) -> str:
|
||||
"""Run a web search"""
|
||||
return tavily_client.search(query)
|
||||
|
||||
agent = create_deep_agent(tools=[internet_search])
|
||||
```
|
||||
|
||||
You can also connect MCP tools via [langchain-mcp-adapters](https://github.com/langchain-ai/langchain-mcp-adapters):
|
||||
|
||||
```python
|
||||
from langchain_mcp_adapters.client import MultiServerMCPClient
|
||||
from deepagents import create_deep_agent
|
||||
|
||||
async def main():
|
||||
mcp_client = MultiServerMCPClient(...)
|
||||
mcp_tools = await mcp_client.get_tools()
|
||||
agent = create_deep_agent(tools=mcp_tools)
|
||||
|
||||
async for chunk in agent.astream({"messages": [{"role": "user", "content": "..."}]}):
|
||||
chunk["messages"][-1].pretty_print()
|
||||
```
|
||||
|
||||
### `middleware`
|
||||
|
||||
Deep agents use [middleware](https://docs.langchain.com/oss/python/langchain/middleware) for extensibility (see [Built-in Tools](#built-in-tools) for defaults). Add custom middleware to inject tools, modify prompts, or hook into the agent lifecycle:
|
||||
|
||||
```python
|
||||
from langchain_core.tools import tool
|
||||
from deepagents import create_deep_agent
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
|
||||
@tool
|
||||
def get_weather(city: str) -> str:
|
||||
"""Get the weather in a city."""
|
||||
return f"The weather in {city} is sunny."
|
||||
|
||||
class WeatherMiddleware(AgentMiddleware):
|
||||
tools = [get_weather]
|
||||
|
||||
agent = create_deep_agent(middleware=[WeatherMiddleware()])
|
||||
```
|
||||
|
||||
### `subagents`
|
||||
|
||||
The main agent can delegate work to sub-agents via the `task` tool (see [Built-in Tools](#built-in-tools)). You can supply custom sub-agents for context isolation and custom instructions:
|
||||
|
||||
```python
|
||||
from deepagents import create_deep_agent
|
||||
|
||||
research_subagent = {
|
||||
"name": "research-agent",
|
||||
"description": "Used to research in-depth questions",
|
||||
"system_prompt": "You are an expert researcher",
|
||||
"tools": [internet_search],
|
||||
"model": "openai:gpt-4o", # Optional, defaults to main agent model
|
||||
}
|
||||
|
||||
agent = create_deep_agent(subagents=[research_subagent])
|
||||
```
|
||||
|
||||
For complex cases, pass a pre-built LangGraph graph:
|
||||
|
||||
```python
|
||||
from deepagents import CompiledSubAgent, create_deep_agent
|
||||
|
||||
custom_graph = create_agent(model=..., tools=..., system_prompt=...)
|
||||
|
||||
agent = create_deep_agent(
|
||||
subagents=[CompiledSubAgent(
|
||||
name="data-analyzer",
|
||||
description="Specialized agent for data analysis",
|
||||
runnable=custom_graph
|
||||
)]
|
||||
)
|
||||
```
|
||||
|
||||
See the [subagents documentation](https://docs.langchain.com/oss/python/deepagents/subagents) for more details.
|
||||
|
||||
### `interrupt_on`
|
||||
|
||||
Some tools may be sensitive and require human approval before execution. Deepagents supports human-in-the-loop workflows through LangGraph’s interrupt capabilities. You can configure which tools require approval using a checkpointer.
|
||||
|
||||
These tool configs are passed to our prebuilt [HITL middleware](https://docs.langchain.com/oss/python/langchain/middleware#human-in-the-loop) so that the agent pauses execution and waits for feedback from the user before executing configured tools.
|
||||
|
||||
```python
|
||||
from langchain_core.tools import tool
|
||||
from deepagents import create_deep_agent
|
||||
|
||||
@tool
|
||||
def get_weather(city: str) -> str:
|
||||
"""Get the weather in a city."""
|
||||
return f"The weather in {city} is sunny."
|
||||
|
||||
agent = create_deep_agent(
|
||||
model="anthropic:claude-sonnet-4-20250514",
|
||||
tools=[get_weather],
|
||||
interrupt_on={
|
||||
"get_weather": {
|
||||
"allowed_decisions": ["approve", "edit", "reject"]
|
||||
},
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
See the [human-in-the-loop documentation](https://docs.langchain.com/oss/python/deepagents/human-in-the-loop) for more details.
|
||||
|
||||
### `backend`
|
||||
|
||||
Deep agents use pluggable backends to control how filesystem operations work. By default, files are stored in the agent's ephemeral state. You can configure different backends for local disk access, persistent cross-conversation storage, or hybrid routing.
|
||||
|
||||
```python
|
||||
from deepagents import create_deep_agent
|
||||
from deepagents.backends import FilesystemBackend
|
||||
|
||||
agent = create_deep_agent(
|
||||
backend=FilesystemBackend(root_dir="/path/to/project"),
|
||||
)
|
||||
```
|
||||
|
||||
Available backends include:
|
||||
|
||||
- **StateBackend** (default): Ephemeral files stored in agent state
|
||||
- **FilesystemBackend**: Real disk operations under a root directory
|
||||
- **StoreBackend**: Persistent storage using LangGraph Store
|
||||
- **CompositeBackend**: Route different paths to different backends
|
||||
|
||||
See the [backends documentation](https://docs.langchain.com/oss/python/deepagents/backends) for more details.
|
||||
|
||||
### Long-term Memory
|
||||
|
||||
Deep agents can maintain persistent memory across conversations using a `CompositeBackend` that routes specific paths to durable storage.
|
||||
|
||||
This enables hybrid memory where working files remain ephemeral while important data (like user preferences or knowledge bases) persists across threads.
|
||||
|
||||
```python
|
||||
from deepagents import create_deep_agent
|
||||
from deepagents.backends import CompositeBackend, StateBackend, StoreBackend
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
|
||||
agent = create_deep_agent(
|
||||
backend=CompositeBackend(
|
||||
default=StateBackend(),
|
||||
routes={"/memories/": StoreBackend(store=InMemoryStore())},
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
Files under `/memories/` will persist across all conversations, while other paths remain temporary. Use cases include:
|
||||
|
||||
- Preserving user preferences across sessions
|
||||
- Building knowledge bases from multiple conversations
|
||||
- Self-improving instructions based on feedback
|
||||
- Maintaining research progress across sessions
|
||||
|
||||
See the [long-term memory documentation](https://docs.langchain.com/oss/python/deepagents/long-term-memory) for more details.
|
||||
|
||||
## Built-in Tools
|
||||
|
||||
<img src=".github/images/deepagents_tools.png" alt="deep agent" width="600"/>
|
||||
|
||||
Every deep agent created with `create_deep_agent` comes with a standard set of tools:
|
||||
|
||||
| Tool Name | Description | Provided By |
|
||||
|-----------|-------------|-------------|
|
||||
| `write_todos` | Create and manage structured task lists for tracking progress through complex workflows | TodoListMiddleware |
|
||||
| `read_todos` | Read the current todo list state | TodoListMiddleware |
|
||||
| `ls` | List all files in a directory (requires absolute path) | FilesystemMiddleware |
|
||||
| `read_file` | Read content from a file with optional pagination (offset/limit parameters) | FilesystemMiddleware |
|
||||
| `write_file` | Create a new file or completely overwrite an existing file | FilesystemMiddleware |
|
||||
| `edit_file` | Perform exact string replacements in files | FilesystemMiddleware |
|
||||
| `glob` | Find files matching a pattern (e.g., `**/*.py`) | FilesystemMiddleware |
|
||||
| `grep` | Search for text patterns within files | FilesystemMiddleware |
|
||||
| `execute`* | Run shell commands in a sandboxed environment | FilesystemMiddleware |
|
||||
| `task` | Delegate tasks to specialized sub-agents with isolated context windows | SubAgentMiddleware |
|
||||
|
||||
The `execute` tool is only available if the backend implements `SandboxBackendProtocol`. By default, it uses the in-memory state backend which does not support command execution. As shown, these tools (along with other capabilities) are provided by default middleware:
|
||||
|
||||
See the [agent harness documentation](https://docs.langchain.com/oss/python/deepagents/harness) for more details on built-in tools and capabilities.
|
||||
|
||||
## Built-in Middleware
|
||||
|
||||
`deepagents` uses middleware under the hood. Here is the list of the middleware used.
|
||||
|
||||
| Middleware | Purpose |
|
||||
|------------|---------|
|
||||
| **TodoListMiddleware** | Task planning and progress tracking |
|
||||
| **FilesystemMiddleware** | File operations and context offloading (auto-saves large results) |
|
||||
| **SubAgentMiddleware** | Delegate tasks to isolated sub-agents |
|
||||
| **SummarizationMiddleware** | Auto-summarizes when context exceeds 170k tokens |
|
||||
| **AnthropicPromptCachingMiddleware** | Caches system prompts to reduce costs (Anthropic only) |
|
||||
| **PatchToolCallsMiddleware** | Fixes dangling tool calls from interruptions |
|
||||
| **HumanInTheLoopMiddleware** | Pauses execution for human approval (requires `interrupt_on` config) |
|
||||
|
||||
## Built-in prompts
|
||||
|
||||
The middleware automatically adds instructions about the standard tools. Your custom instructions should **complement, not duplicate** these defaults:
|
||||
|
||||
#### From [TodoListMiddleware](https://github.com/langchain-ai/langchain/blob/master/libs/langchain/langchain/agents/middleware/todo.py)
|
||||
|
||||
- Explains when to use `write_todos` and `read_todos`
|
||||
- Guidance on marking tasks completed
|
||||
- Best practices for todo list management
|
||||
- When NOT to use todos (simple tasks)
|
||||
|
||||
#### From [FilesystemMiddleware](libs/deepagents/deepagents/middleware/filesystem.py)
|
||||
|
||||
- Lists all filesystem tools (`ls`, `read_file`, `write_file`, `edit_file`, `glob`, `grep`, `execute`*)
|
||||
- Explains that file paths must start with `/`
|
||||
- Describes each tool's purpose and parameters
|
||||
- Notes about context offloading for large tool results
|
||||
|
||||
#### From [SubAgentMiddleware](libs/deepagents/deepagents/middleware/subagents.py)
|
||||
|
||||
- Explains the `task()` tool for delegating to sub-agents
|
||||
- When to use sub-agents vs when NOT to use them
|
||||
- Guidance on parallel execution
|
||||
- Subagent lifecycle (spawn → run → return → reconcile)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Trust Model
|
||||
|
||||
Deepagents follows a "trust the LLM" model similar to Claude Code. The agent can perform any action the underlying tools allow. Security boundaries should be enforced at the tool/sandbox level, not by expecting the LLM to self-police.
|
||||
56
deepagents_sourcecode/libs/acp/Makefile
Normal file
@@ -0,0 +1,56 @@
|
||||
.PHONY: all lint format test help
|
||||
|
||||
# Default target executed when no arguments are given to make.
|
||||
all: help
|
||||
|
||||
######################
|
||||
# TESTING AND COVERAGE
|
||||
######################
|
||||
|
||||
# Define a variable for the test file path.
|
||||
TEST_FILE ?= tests/
|
||||
|
||||
test:
|
||||
uv run pytest --disable-socket --allow-unix-socket $(TEST_FILE) --timeout 10
|
||||
|
||||
test_watch:
|
||||
uv run ptw . -- $(TEST_FILE)
|
||||
|
||||
toad:
|
||||
uv run toad acp "deepacp"
|
||||
|
||||
|
||||
######################
|
||||
# LINTING AND FORMATTING
|
||||
######################
|
||||
|
||||
# Define a variable for Python and notebook files.
|
||||
lint format: PYTHON_FILES=deepagents_acp/ tests/
|
||||
lint_diff format_diff: PYTHON_FILES=$(shell git diff --relative=. --name-only --diff-filter=d master | grep -E '\.py$$|\.ipynb$$')
|
||||
|
||||
lint lint_diff:
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run ruff check $(PYTHON_FILES) --diff
|
||||
# [ "$(PYTHON_FILES)" = "" ] || uv run mypy $(PYTHON_FILES)
|
||||
|
||||
format format_diff:
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES)
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run ruff check --fix $(PYTHON_FILES)
|
||||
|
||||
|
||||
|
||||
######################
|
||||
# HELP
|
||||
######################
|
||||
|
||||
help:
|
||||
@echo '===================='
|
||||
@echo '-- LINTING --'
|
||||
@echo 'format - run code formatters'
|
||||
@echo 'lint - run linters'
|
||||
@echo '-- TESTS --'
|
||||
@echo 'test - run unit tests'
|
||||
@echo 'test TEST_FILE=<test_file> - run all tests in file'
|
||||
@echo '-- DOCUMENTATION tasks are from the top-level Makefile --'
|
||||
|
||||
|
||||
3
deepagents_sourcecode/libs/acp/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# ACP
|
||||
|
||||
Work in progress support for Agent Client Protocol
|
||||
655
deepagents_sourcecode/libs/acp/deepagents_acp/server.py
Normal file
@@ -0,0 +1,655 @@
|
||||
"""DeepAgents ACP server implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import Any, Literal
|
||||
|
||||
from acp import (
|
||||
Agent,
|
||||
AgentSideConnection,
|
||||
PROTOCOL_VERSION,
|
||||
stdio_streams,
|
||||
)
|
||||
from acp.schema import (
|
||||
AgentMessageChunk,
|
||||
InitializeRequest,
|
||||
InitializeResponse,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
SessionNotification,
|
||||
TextContentBlock,
|
||||
Implementation,
|
||||
AgentThoughtChunk,
|
||||
ToolCallProgress,
|
||||
ContentToolCallContent,
|
||||
LoadSessionResponse,
|
||||
SetSessionModeResponse,
|
||||
SetSessionModelResponse,
|
||||
CancelNotification,
|
||||
LoadSessionRequest,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModelRequest,
|
||||
AgentPlanUpdate,
|
||||
PlanEntry,
|
||||
PermissionOption,
|
||||
RequestPermissionRequest,
|
||||
AllowedOutcome,
|
||||
DeniedOutcome,
|
||||
ToolCall as ACPToolCall,
|
||||
)
|
||||
from deepagents import create_deep_agent
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk, ToolMessage
|
||||
from langchain_core.messages.content import ToolCall
|
||||
from langchain_core.tools import tool
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from langgraph.graph.state import CompiledStateGraph
|
||||
from langgraph.types import Command, Interrupt
|
||||
|
||||
|
||||
class DeepagentsACP(Agent):
|
||||
"""ACP Agent implementation wrapping deepagents."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connection: AgentSideConnection,
|
||||
agent_graph: CompiledStateGraph,
|
||||
) -> None:
|
||||
"""Initialize the DeepAgents agent.
|
||||
|
||||
Args:
|
||||
connection: The ACP connection for communicating with the client
|
||||
agent_graph: A compiled LangGraph StateGraph (output of create_deep_agent)
|
||||
"""
|
||||
self._connection = connection
|
||||
self._agent_graph = agent_graph
|
||||
self._sessions: dict[str, dict[str, Any]] = {}
|
||||
# Track tool calls by ID for matching with ToolMessages
|
||||
# Maps tool_call_id -> ToolCall TypedDict
|
||||
self._tool_calls: dict[str, ToolCall] = {}
|
||||
|
||||
async def initialize(
|
||||
self,
|
||||
params: InitializeRequest,
|
||||
) -> InitializeResponse:
|
||||
"""Initialize the agent and return capabilities."""
|
||||
return InitializeResponse(
|
||||
protocolVersion=PROTOCOL_VERSION,
|
||||
agentInfo=Implementation(
|
||||
name="DeepAgents ACP Server",
|
||||
version="0.1.0",
|
||||
title="DeepAgents ACP Server",
|
||||
),
|
||||
)
|
||||
|
||||
async def newSession(
|
||||
self,
|
||||
params: NewSessionRequest,
|
||||
) -> NewSessionResponse:
|
||||
"""Create a new session with a deepagents instance."""
|
||||
session_id = str(uuid.uuid4())
|
||||
# Store session state with the shared agent graph
|
||||
self._sessions[session_id] = {
|
||||
"agent": self._agent_graph,
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
}
|
||||
|
||||
return NewSessionResponse(sessionId=session_id)
|
||||
|
||||
async def _handle_ai_message_chunk(
|
||||
self,
|
||||
params: PromptRequest,
|
||||
message: AIMessageChunk,
|
||||
) -> None:
|
||||
"""Handle an AIMessageChunk and send appropriate notifications.
|
||||
|
||||
Args:
|
||||
params: The prompt request parameters
|
||||
message: An AIMessageChunk from the streaming response
|
||||
|
||||
Note:
|
||||
According to LangChain's content block types, message.content_blocks
|
||||
returns a list of ContentBlock unions. Each block is a TypedDict with
|
||||
a "type" field that discriminates the block type:
|
||||
- TextContentBlock: type="text", has "text" field
|
||||
- ReasoningContentBlock: type="reasoning", has "reasoning" field
|
||||
- ToolCallChunk: type="tool_call_chunk"
|
||||
- And many others (image, audio, video, etc.)
|
||||
"""
|
||||
for block in message.content_blocks:
|
||||
# All content blocks have a "type" field for discrimination
|
||||
block_type = block.get("type")
|
||||
|
||||
if block_type == "text":
|
||||
# TextContentBlock has a required "text" field
|
||||
text = block.get("text", "")
|
||||
if not text: # Only yield non-empty text
|
||||
continue
|
||||
await self._connection.sessionUpdate(
|
||||
SessionNotification(
|
||||
update=AgentMessageChunk(
|
||||
content=TextContentBlock(text=text, type="text"),
|
||||
sessionUpdate="agent_message_chunk",
|
||||
),
|
||||
sessionId=params.sessionId,
|
||||
)
|
||||
)
|
||||
elif block_type == "reasoning":
|
||||
# ReasoningContentBlock has a "reasoning" field (NotRequired)
|
||||
reasoning = block.get("reasoning", "")
|
||||
if not reasoning:
|
||||
continue
|
||||
|
||||
await self._connection.sessionUpdate(
|
||||
SessionNotification(
|
||||
update=AgentThoughtChunk(
|
||||
content=TextContentBlock(text=reasoning, type="text"),
|
||||
sessionUpdate="agent_thought_chunk",
|
||||
),
|
||||
sessionId=params.sessionId,
|
||||
)
|
||||
)
|
||||
|
||||
async def _handle_completed_tool_calls(
|
||||
self,
|
||||
params: PromptRequest,
|
||||
message: AIMessage,
|
||||
) -> None:
|
||||
"""Handle completed tool calls from an AIMessage and send notifications.
|
||||
|
||||
Args:
|
||||
params: The prompt request parameters
|
||||
message: An AIMessage containing tool_calls
|
||||
|
||||
Note:
|
||||
According to LangChain's AIMessage type:
|
||||
- message.tool_calls: list[ToolCall] where ToolCall is a TypedDict with:
|
||||
- name: str (required)
|
||||
- args: dict[str, Any] (required)
|
||||
- id: str | None (required field, but can be None)
|
||||
- type: Literal["tool_call"] (optional, NotRequired)
|
||||
"""
|
||||
# Use direct attribute access - tool_calls is a defined field on AIMessage
|
||||
if not message.tool_calls:
|
||||
return
|
||||
|
||||
for tool_call in message.tool_calls:
|
||||
# Access TypedDict fields directly (they're required fields)
|
||||
tool_call_id = tool_call["id"] # str | None
|
||||
tool_name = tool_call["name"] # str
|
||||
tool_args = tool_call["args"] # dict[str, Any]
|
||||
|
||||
# Skip tool calls without an ID (shouldn't happen in practice)
|
||||
if tool_call_id is None:
|
||||
continue
|
||||
|
||||
# Skip todo tool calls as they're handled separately
|
||||
if tool_name == "todo":
|
||||
raise NotImplementedError("TODO tool call handling not implemented yet")
|
||||
|
||||
# Send tool call progress update showing the tool is running
|
||||
await self._connection.sessionUpdate(
|
||||
SessionNotification(
|
||||
update=ToolCallProgress(
|
||||
sessionUpdate="tool_call_update",
|
||||
toolCallId=tool_call_id,
|
||||
title=tool_name,
|
||||
rawInput=tool_args,
|
||||
status="pending",
|
||||
),
|
||||
sessionId=params.sessionId,
|
||||
)
|
||||
)
|
||||
|
||||
# Store the tool call for later matching with ToolMessage
|
||||
self._tool_calls[tool_call_id] = tool_call
|
||||
|
||||
async def _handle_tool_message(
|
||||
self,
|
||||
params: PromptRequest,
|
||||
tool_call: ToolCall,
|
||||
message: ToolMessage,
|
||||
) -> None:
|
||||
"""Handle a ToolMessage and send appropriate notifications.
|
||||
|
||||
Args:
|
||||
params: The prompt request parameters
|
||||
tool_call: The original ToolCall that this message is responding to
|
||||
message: A ToolMessage containing the tool execution result
|
||||
|
||||
Note:
|
||||
According to LangChain's ToolMessage type (inherits from BaseMessage):
|
||||
- message.content: str | list[str | dict] (from BaseMessage)
|
||||
- message.tool_call_id: str (specific to ToolMessage)
|
||||
- message.status: str | None (e.g., "error" for failed tool calls)
|
||||
"""
|
||||
# Determine status based on message status or content
|
||||
status: Literal["completed", "failed"] = "completed"
|
||||
if hasattr(message, "status") and message.status == "error":
|
||||
status = "failed"
|
||||
|
||||
# Build content blocks if message has content
|
||||
content_blocks = []
|
||||
for content_block in message.content_blocks:
|
||||
if content_block.get("type") == "text":
|
||||
text = content_block.get("text", "")
|
||||
if text:
|
||||
content_blocks.append(
|
||||
ContentToolCallContent(
|
||||
type="content",
|
||||
content=TextContentBlock(text=text, type="text"),
|
||||
)
|
||||
)
|
||||
# Send tool call progress update with the result
|
||||
await self._connection.sessionUpdate(
|
||||
SessionNotification(
|
||||
update=ToolCallProgress(
|
||||
sessionUpdate="tool_call_update",
|
||||
toolCallId=message.tool_call_id,
|
||||
title=tool_call["name"],
|
||||
content=content_blocks,
|
||||
rawOutput=message.content,
|
||||
status=status,
|
||||
),
|
||||
sessionId=params.sessionId,
|
||||
)
|
||||
)
|
||||
|
||||
async def _handle_todo_update(
|
||||
self,
|
||||
params: PromptRequest,
|
||||
todos: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Handle todo list updates from the tools node.
|
||||
|
||||
Args:
|
||||
params: The prompt request parameters
|
||||
todos: List of todo dictionaries with 'content' and 'status' fields
|
||||
|
||||
Note:
|
||||
Todos come from the deepagents graph's write_todos tool and have the structure:
|
||||
[{'content': 'Task description', 'status': 'pending'|'in_progress'|'completed'}, ...]
|
||||
"""
|
||||
# Convert todos to PlanEntry objects
|
||||
entries = []
|
||||
for todo in todos:
|
||||
# Extract fields from todo dict
|
||||
content = todo.get("content", "")
|
||||
status = todo.get("status", "pending")
|
||||
|
||||
# Validate and cast status to PlanEntryStatus
|
||||
if status not in ("pending", "in_progress", "completed"):
|
||||
status = "pending"
|
||||
|
||||
# Create PlanEntry with default priority of "medium"
|
||||
entry = PlanEntry(
|
||||
content=content,
|
||||
status=status, # type: ignore
|
||||
priority="medium",
|
||||
)
|
||||
entries.append(entry)
|
||||
|
||||
# Send plan update notification
|
||||
await self._connection.sessionUpdate(
|
||||
SessionNotification(
|
||||
update=AgentPlanUpdate(
|
||||
sessionUpdate="plan",
|
||||
entries=entries,
|
||||
),
|
||||
sessionId=params.sessionId,
|
||||
)
|
||||
)
|
||||
|
||||
async def _handle_interrupt(
|
||||
self,
|
||||
params: PromptRequest,
|
||||
interrupt: Interrupt,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Handle a LangGraph interrupt and request permission from the client.
|
||||
|
||||
Args:
|
||||
params: The prompt request parameters
|
||||
interrupt: The interrupt from LangGraph containing action_requests and review_configs
|
||||
|
||||
Returns:
|
||||
List of decisions to pass to Command(resume={...})
|
||||
|
||||
Note:
|
||||
The interrupt.value contains:
|
||||
- action_requests: [{'name': str, 'args': dict, 'description': str}, ...]
|
||||
- review_configs: [{'action_name': str, 'allowed_decisions': list[str]}, ...]
|
||||
"""
|
||||
interrupt_data = interrupt.value
|
||||
action_requests = interrupt_data.get("action_requests", [])
|
||||
review_configs = interrupt_data.get("review_configs", [])
|
||||
|
||||
# Create a mapping of action names to their allowed decisions
|
||||
allowed_decisions_map = {}
|
||||
for review_config in review_configs:
|
||||
action_name = review_config.get("action_name")
|
||||
allowed_decisions = review_config.get("allowed_decisions", [])
|
||||
allowed_decisions_map[action_name] = allowed_decisions
|
||||
|
||||
# Collect decisions for all action requests
|
||||
decisions = []
|
||||
|
||||
for action_request in action_requests:
|
||||
tool_name = action_request.get("name")
|
||||
tool_args = action_request.get("args", {})
|
||||
|
||||
# Get allowed decisions for this action
|
||||
allowed_decisions = allowed_decisions_map.get(
|
||||
tool_name, ["approve", "reject"]
|
||||
)
|
||||
|
||||
# Build permission options based on allowed decisions
|
||||
options = []
|
||||
if "approve" in allowed_decisions:
|
||||
options.append(
|
||||
PermissionOption(
|
||||
optionId="allow-once",
|
||||
name="Allow once",
|
||||
kind="allow_once",
|
||||
)
|
||||
)
|
||||
if "reject" in allowed_decisions:
|
||||
options.append(
|
||||
PermissionOption(
|
||||
optionId="reject-once",
|
||||
name="Reject",
|
||||
kind="reject_once",
|
||||
)
|
||||
)
|
||||
# Generate a tool call ID for this permission request
|
||||
# We need to find the corresponding tool call from the stored calls
|
||||
# For now, use a generated ID
|
||||
tool_call_id = f"perm_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create ACP ToolCall object for the permission request
|
||||
acp_tool_call = ACPToolCall(
|
||||
toolCallId=tool_call_id,
|
||||
title=tool_name,
|
||||
rawInput=tool_args,
|
||||
status="pending",
|
||||
)
|
||||
|
||||
# Send permission request to client
|
||||
response = await self._connection.requestPermission(
|
||||
RequestPermissionRequest(
|
||||
sessionId=params.sessionId,
|
||||
toolCall=acp_tool_call,
|
||||
options=options,
|
||||
)
|
||||
)
|
||||
|
||||
# Convert ACP response to LangGraph decision
|
||||
outcome = response.outcome
|
||||
|
||||
if isinstance(outcome, AllowedOutcome):
|
||||
option_id = outcome.optionId
|
||||
if option_id == "allow-once":
|
||||
# Check if this was actually an edit option
|
||||
selected_option = next(
|
||||
(opt for opt in options if opt.optionId == option_id), None
|
||||
)
|
||||
if selected_option and selected_option.field_meta:
|
||||
# This is an edit - for now, just approve
|
||||
# TODO: Implement actual edit functionality
|
||||
decisions.append({"type": "approve"})
|
||||
else:
|
||||
decisions.append({"type": "approve"})
|
||||
elif option_id == "edit":
|
||||
# Edit option - for now, just approve
|
||||
# TODO: Implement actual edit functionality to collect edited args
|
||||
decisions.append({"type": "approve"})
|
||||
elif isinstance(outcome, DeniedOutcome):
|
||||
decisions.append(
|
||||
{
|
||||
"type": "reject",
|
||||
"message": "Action rejected by user",
|
||||
}
|
||||
)
|
||||
|
||||
return decisions
|
||||
|
||||
async def _stream_and_handle_updates(
|
||||
self,
|
||||
params: PromptRequest,
|
||||
agent: Any,
|
||||
stream_input: dict[str, Any] | Command,
|
||||
config: dict[str, Any],
|
||||
) -> list[Interrupt]:
|
||||
"""Stream agent execution and handle updates, returning any interrupts.
|
||||
|
||||
Args:
|
||||
params: The prompt request parameters
|
||||
agent: The agent to stream from
|
||||
stream_input: Input to pass to agent.astream (initial message or Command)
|
||||
config: Configuration with thread_id
|
||||
|
||||
Returns:
|
||||
List of interrupts that occurred during streaming
|
||||
"""
|
||||
interrupts = []
|
||||
|
||||
async for stream_mode, data in agent.astream(
|
||||
stream_input,
|
||||
config=config,
|
||||
stream_mode=["messages", "updates"],
|
||||
):
|
||||
if stream_mode == "messages":
|
||||
# Handle streaming message chunks (AIMessageChunk)
|
||||
message, metadata = data
|
||||
if isinstance(message, AIMessageChunk):
|
||||
await self._handle_ai_message_chunk(params, message)
|
||||
elif stream_mode == "updates":
|
||||
# Handle completed node updates
|
||||
for node_name, update in data.items():
|
||||
# Check for interrupts
|
||||
if node_name == "__interrupt__":
|
||||
# Extract interrupts from the update
|
||||
interrupts.extend(update)
|
||||
continue
|
||||
|
||||
# Only process model and tools nodes
|
||||
if node_name not in ("model", "tools"):
|
||||
continue
|
||||
|
||||
# Handle todos from tools node
|
||||
if node_name == "tools" and "todos" in update:
|
||||
todos = update.get("todos", [])
|
||||
if todos:
|
||||
await self._handle_todo_update(params, todos)
|
||||
|
||||
# Get messages from the update
|
||||
messages = update.get("messages", [])
|
||||
if not messages:
|
||||
continue
|
||||
|
||||
# Process the last message from this node
|
||||
last_message = messages[-1]
|
||||
|
||||
# Handle completed AI messages from model node
|
||||
if node_name == "model" and isinstance(last_message, AIMessage):
|
||||
# Check if this AIMessage has tool calls
|
||||
if last_message.tool_calls:
|
||||
await self._handle_completed_tool_calls(
|
||||
params, last_message
|
||||
)
|
||||
|
||||
# Handle tool execution results from tools node
|
||||
elif node_name == "tools" and isinstance(last_message, ToolMessage):
|
||||
# Look up the original tool call by ID
|
||||
tool_call = self._tool_calls.get(last_message.tool_call_id)
|
||||
if tool_call:
|
||||
await self._handle_tool_message(
|
||||
params, tool_call, last_message
|
||||
)
|
||||
|
||||
return interrupts
|
||||
|
||||
async def prompt(
|
||||
self,
|
||||
params: PromptRequest,
|
||||
) -> PromptResponse:
|
||||
"""Handle a user prompt and stream responses."""
|
||||
session_id = params.sessionId
|
||||
session = self._sessions.get(session_id)
|
||||
|
||||
# Extract text from prompt content blocks
|
||||
prompt_text = ""
|
||||
for block in params.prompt:
|
||||
if hasattr(block, "text"):
|
||||
prompt_text += block.text
|
||||
elif isinstance(block, dict) and "text" in block:
|
||||
prompt_text += block["text"]
|
||||
|
||||
# Stream the agent's response
|
||||
agent = session["agent"]
|
||||
thread_id = session["thread_id"]
|
||||
config = {"configurable": {"thread_id": thread_id}}
|
||||
|
||||
# Start with the initial user message
|
||||
stream_input: dict[str, Any] | Command = {
|
||||
"messages": [{"role": "user", "content": prompt_text}]
|
||||
}
|
||||
|
||||
# Loop until there are no more interrupts
|
||||
while True:
|
||||
# Stream and collect any interrupts
|
||||
interrupts = await self._stream_and_handle_updates(
|
||||
params, agent, stream_input, config
|
||||
)
|
||||
|
||||
# If no interrupts, we're done
|
||||
if not interrupts:
|
||||
break
|
||||
|
||||
# Process each interrupt and collect decisions
|
||||
all_decisions = []
|
||||
for interrupt in interrupts:
|
||||
decisions = await self._handle_interrupt(params, interrupt)
|
||||
all_decisions.extend(decisions)
|
||||
|
||||
# Prepare to resume with the collected decisions
|
||||
stream_input = Command(resume={"decisions": all_decisions})
|
||||
|
||||
return PromptResponse(stopReason="end_turn")
|
||||
|
||||
async def authenticate(self, params: Any) -> Any | None:
|
||||
"""Authenticate (optional)."""
|
||||
# Authentication not required for now
|
||||
return None
|
||||
|
||||
async def extMethod(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Handle extension methods (optional)."""
|
||||
raise NotImplementedError(f"Extension method {method} not supported")
|
||||
|
||||
async def extNotification(self, method: str, params: dict[str, Any]) -> None:
|
||||
"""Handle extension notifications (optional)."""
|
||||
pass
|
||||
|
||||
async def cancel(self, params: CancelNotification) -> None:
|
||||
"""Cancel a running session."""
|
||||
# TODO: Implement cancellation logic
|
||||
pass
|
||||
|
||||
async def loadSession(
|
||||
self,
|
||||
params: LoadSessionRequest,
|
||||
) -> LoadSessionResponse | None:
|
||||
"""Load an existing session (optional)."""
|
||||
# Not implemented yet - would need to serialize/deserialize session state
|
||||
return None
|
||||
|
||||
async def setSessionMode(
|
||||
self,
|
||||
params: SetSessionModeRequest,
|
||||
) -> SetSessionModeResponse | None:
|
||||
"""Set session mode (optional)."""
|
||||
# Could be used to switch between different agent modes
|
||||
return None
|
||||
|
||||
async def setSessionModel(
|
||||
self,
|
||||
params: SetSessionModelRequest,
|
||||
) -> SetSessionModelResponse | None:
|
||||
"""Set session model (optional)."""
|
||||
# Not supported - model is configured at agent graph creation time
|
||||
return None
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Main entry point for running the ACP server."""
|
||||
# from deepagents_cli.agent import create_agent_with_config
|
||||
# from deepagents_cli.config import create_model
|
||||
# from deepagents_cli.tools import fetch_url, http_request, web_search
|
||||
#
|
||||
# # Create model using CLI configuration
|
||||
# model = create_model()
|
||||
#
|
||||
# # Setup tools - conditionally include web_search if Tavily is available
|
||||
# tools = [http_request, fetch_url]
|
||||
# if os.environ.get("TAVILY_API_KEY"):
|
||||
# tools.append(web_search)
|
||||
#
|
||||
# # Create CLI agent with shell access and other CLI features
|
||||
# # Using default assistant_id "agent" for ACP server
|
||||
# agent_graph, composite_backend = create_agent_with_config(
|
||||
# model=model,
|
||||
# assistant_id="agent",
|
||||
# tools=tools,
|
||||
# sandbox=None, # Local mode
|
||||
# sandbox_type=None,
|
||||
# system_prompt=None, # Use default CLI system prompt
|
||||
# auto_approve=False, # Require user approval for destructive operations
|
||||
# enable_memory=True, # Enable persistent memory
|
||||
# enable_skills=True, # Enable custom skills
|
||||
# enable_shell=True, # Enable shell access
|
||||
# )
|
||||
#
|
||||
# Define default tools
|
||||
|
||||
from langchain.agents.middleware import HumanInTheLoopMiddleware
|
||||
|
||||
@tool()
|
||||
def get_weather(location: str) -> str:
|
||||
"""Get the weather for a given location."""
|
||||
return f"The weather in {location} is sunny with a high of 75°F."
|
||||
|
||||
# Create the agent graph with default configuration
|
||||
model = ChatAnthropic(
|
||||
model_name="claude-sonnet-4-5-20250929",
|
||||
max_tokens=20000,
|
||||
)
|
||||
|
||||
agent_graph = create_deep_agent(
|
||||
model=model,
|
||||
tools=[get_weather],
|
||||
checkpointer=InMemorySaver(),
|
||||
middleware=[
|
||||
HumanInTheLoopMiddleware(
|
||||
interrupt_on={
|
||||
"get_weather": True,
|
||||
}
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Start the ACP server
|
||||
reader, writer = await stdio_streams()
|
||||
AgentSideConnection(lambda conn: DeepagentsACP(conn, agent_graph), writer, reader)
|
||||
await asyncio.Event().wait()
|
||||
|
||||
|
||||
def cli_main() -> None:
|
||||
"""Synchronous CLI entry point for the ACP server."""
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli_main()
|
||||
58
deepagents_sourcecode/libs/acp/pyproject.toml
Normal file
@@ -0,0 +1,58 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "deepagents-acp"
|
||||
version = "0.0.1"
|
||||
description = "Agent Client Protocol integration for DeepAgents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
license = {text = "MIT"}
|
||||
authors = [
|
||||
]
|
||||
maintainers = [
|
||||
]
|
||||
keywords = ["agent", "acp", "agent-client-protocol", "deepagents", "ai-agents"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-client-protocol>=0.6.2",
|
||||
"deepagents",
|
||||
"deepagents-cli",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"batrachian-toad>=0.5.2",
|
||||
]
|
||||
test = [
|
||||
"pytest>=8.3.4",
|
||||
"pytest-asyncio>=0.25.3",
|
||||
"pytest-cov>=6.0.0",
|
||||
"pytest-mock>=3.14.0",
|
||||
"pytest-socket>=0.7.0",
|
||||
"pytest-timeout>=2.3.1",
|
||||
"ruff>=0.9.7",
|
||||
"dirty-equals>=0.11",
|
||||
]
|
||||
|
||||
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/langchain-ai/deepagents"
|
||||
Repository = "https://github.com/langchain-ai/deepagents"
|
||||
Issues = "https://github.com/langchain-ai/deepagents/issues"
|
||||
|
||||
[project.scripts]
|
||||
deepacp = "deepagents_acp.server:cli_main"
|
||||
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto" # or "strict"
|
||||
0
deepagents_sourcecode/libs/acp/tests/__init__.py
Normal file
231
deepagents_sourcecode/libs/acp/tests/chat_model.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""Fake chat models for testing purposes."""
|
||||
|
||||
import re
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from langchain_core.callbacks import CallbackManagerForLLMRun
|
||||
from langchain_core.language_models import LanguageModelInput
|
||||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage
|
||||
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
|
||||
from langchain_core.runnables import Runnable
|
||||
from langchain_core.tools import BaseTool
|
||||
|
||||
|
||||
class GenericFakeChatModel(BaseChatModel):
|
||||
"""Generic fake chat model that can be used to test the chat model interface.
|
||||
|
||||
* Chat model should be usable in both sync and async tests
|
||||
* Invokes `on_llm_new_token` to allow for testing of callback related code for new
|
||||
tokens.
|
||||
* Includes configurable logic to break messages into chunks for streaming.
|
||||
|
||||
Args:
|
||||
messages: An iterator over messages (use `iter()` to convert a list)
|
||||
stream_delimiter: How to chunk content when streaming. Options:
|
||||
- None (default): Return content in a single chunk (no streaming)
|
||||
- A string delimiter (e.g., " "): Split content on this delimiter,
|
||||
preserving the delimiter as separate chunks
|
||||
- A regex pattern (e.g., r"(\\s)"): Split using the pattern with a capture
|
||||
group to preserve delimiters
|
||||
|
||||
Examples:
|
||||
# No streaming - single chunk
|
||||
model = GenericFakeChatModel(messages=iter([AIMessage(content="Hello world")]))
|
||||
|
||||
# Stream on whitespace
|
||||
model = GenericFakeChatModel(
|
||||
messages=iter([AIMessage(content="Hello world")]),
|
||||
stream_delimiter=" "
|
||||
)
|
||||
# Yields: "Hello", " ", "world"
|
||||
|
||||
# Stream on whitespace (regex) - more flexible
|
||||
model = GenericFakeChatModel(
|
||||
messages=iter([AIMessage(content="Hello world")]),
|
||||
stream_delimiter=r"(\s)"
|
||||
)
|
||||
# Yields: "Hello", " ", "world"
|
||||
"""
|
||||
|
||||
messages: Iterator[AIMessage | str]
|
||||
"""Get an iterator over messages.
|
||||
|
||||
This can be expanded to accept other types like Callables / dicts / strings
|
||||
to make the interface more generic if needed.
|
||||
|
||||
!!! note
|
||||
if you want to pass a list, you can use `iter` to convert it to an iterator.
|
||||
"""
|
||||
|
||||
stream_delimiter: str | None = None
|
||||
"""Delimiter for chunking content during streaming.
|
||||
|
||||
- None (default): No chunking, returns content in a single chunk
|
||||
- String: Split content on this exact string, preserving delimiter as chunks
|
||||
- Regex pattern: Use re.split() with the pattern (use capture groups to preserve delimiters)
|
||||
"""
|
||||
|
||||
@override
|
||||
def _generate(
|
||||
self,
|
||||
messages: list[BaseMessage],
|
||||
stop: list[str] | None = None,
|
||||
run_manager: CallbackManagerForLLMRun | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ChatResult:
|
||||
message = next(self.messages)
|
||||
message_ = AIMessage(content=message) if isinstance(message, str) else message
|
||||
generation = ChatGeneration(message=message_)
|
||||
return ChatResult(generations=[generation])
|
||||
|
||||
def _stream(
|
||||
self,
|
||||
messages: list[BaseMessage],
|
||||
stop: list[str] | None = None,
|
||||
run_manager: CallbackManagerForLLMRun | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Iterator[ChatGenerationChunk]:
|
||||
chat_result = self._generate(
|
||||
messages, stop=stop, run_manager=run_manager, **kwargs
|
||||
)
|
||||
if not isinstance(chat_result, ChatResult):
|
||||
msg = (
|
||||
f"Expected generate to return a ChatResult, "
|
||||
f"but got {type(chat_result)} instead."
|
||||
)
|
||||
raise ValueError(msg) # noqa: TRY004
|
||||
|
||||
message = chat_result.generations[0].message
|
||||
|
||||
if not isinstance(message, AIMessage):
|
||||
msg = (
|
||||
f"Expected invoke to return an AIMessage, "
|
||||
f"but got {type(message)} instead."
|
||||
)
|
||||
raise ValueError(msg) # noqa: TRY004
|
||||
|
||||
content = message.content
|
||||
tool_calls = message.tool_calls if hasattr(message, "tool_calls") else []
|
||||
|
||||
if content:
|
||||
if not isinstance(content, str):
|
||||
msg = "Expected content to be a string."
|
||||
raise ValueError(msg)
|
||||
|
||||
# Chunk content based on stream_delimiter configuration
|
||||
if self.stream_delimiter is None:
|
||||
# No streaming - return entire content in a single chunk
|
||||
content_chunks = [content]
|
||||
else:
|
||||
# Split content using the delimiter
|
||||
# Use re.split to support both string and regex patterns
|
||||
content_chunks = cast(
|
||||
"list[str]", re.split(self.stream_delimiter, content)
|
||||
)
|
||||
# Remove empty strings that can result from splitting
|
||||
content_chunks = [chunk for chunk in content_chunks if chunk]
|
||||
|
||||
for idx, token in enumerate(content_chunks):
|
||||
# Include tool_calls only in the last chunk
|
||||
is_last = idx == len(content_chunks) - 1
|
||||
chunk_tool_calls = tool_calls if is_last else []
|
||||
|
||||
chunk = ChatGenerationChunk(
|
||||
message=AIMessageChunk(
|
||||
content=token,
|
||||
id=message.id,
|
||||
tool_calls=chunk_tool_calls,
|
||||
)
|
||||
)
|
||||
if (
|
||||
is_last
|
||||
and isinstance(chunk.message, AIMessageChunk)
|
||||
and not message.additional_kwargs
|
||||
):
|
||||
chunk.message.chunk_position = "last"
|
||||
if run_manager:
|
||||
run_manager.on_llm_new_token(token, chunk=chunk)
|
||||
yield chunk
|
||||
elif tool_calls:
|
||||
# If there's no content but there are tool_calls, yield a single chunk with them
|
||||
chunk = ChatGenerationChunk(
|
||||
message=AIMessageChunk(
|
||||
content="",
|
||||
id=message.id,
|
||||
tool_calls=tool_calls,
|
||||
chunk_position="last",
|
||||
)
|
||||
)
|
||||
if run_manager:
|
||||
run_manager.on_llm_new_token("", chunk=chunk)
|
||||
yield chunk
|
||||
|
||||
if message.additional_kwargs:
|
||||
for key, value in message.additional_kwargs.items():
|
||||
# We should further break down the additional kwargs into chunks
|
||||
# Special case for function call
|
||||
if key == "function_call":
|
||||
for fkey, fvalue in value.items():
|
||||
if isinstance(fvalue, str):
|
||||
# Break function call by `,`
|
||||
fvalue_chunks = cast("list[str]", re.split(r"(,)", fvalue))
|
||||
for fvalue_chunk in fvalue_chunks:
|
||||
chunk = ChatGenerationChunk(
|
||||
message=AIMessageChunk(
|
||||
id=message.id,
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"function_call": {fkey: fvalue_chunk}
|
||||
},
|
||||
)
|
||||
)
|
||||
if run_manager:
|
||||
run_manager.on_llm_new_token(
|
||||
"",
|
||||
chunk=chunk, # No token for function call
|
||||
)
|
||||
yield chunk
|
||||
else:
|
||||
chunk = ChatGenerationChunk(
|
||||
message=AIMessageChunk(
|
||||
id=message.id,
|
||||
content="",
|
||||
additional_kwargs={"function_call": {fkey: fvalue}},
|
||||
)
|
||||
)
|
||||
if run_manager:
|
||||
run_manager.on_llm_new_token(
|
||||
"",
|
||||
chunk=chunk, # No token for function call
|
||||
)
|
||||
yield chunk
|
||||
else:
|
||||
chunk = ChatGenerationChunk(
|
||||
message=AIMessageChunk(
|
||||
id=message.id, content="", additional_kwargs={key: value}
|
||||
)
|
||||
)
|
||||
if run_manager:
|
||||
run_manager.on_llm_new_token(
|
||||
"",
|
||||
chunk=chunk, # No token for function call
|
||||
)
|
||||
yield chunk
|
||||
|
||||
@property
|
||||
def _llm_type(self) -> str:
|
||||
return "generic-fake-chat-model"
|
||||
|
||||
def bind_tools(
|
||||
self,
|
||||
tools: Sequence[dict[str, Any] | type | Callable | BaseTool],
|
||||
*,
|
||||
tool_choice: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Runnable[LanguageModelInput, AIMessage]:
|
||||
"""Override bind_tools to return self for testing purposes."""
|
||||
return self
|
||||
544
deepagents_sourcecode/libs/acp/tests/test_server.py
Normal file
@@ -0,0 +1,544 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from acp.schema import NewSessionRequest, PromptRequest
|
||||
from acp.schema import (
|
||||
TextContentBlock,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
AllowedOutcome,
|
||||
)
|
||||
from dirty_equals import IsUUID
|
||||
from langchain_core.messages import AIMessage, BaseMessage
|
||||
from langchain_core.tools import tool
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
from deepagents_acp.server import DeepagentsACP
|
||||
from tests.chat_model import GenericFakeChatModel
|
||||
|
||||
|
||||
class FakeAgentSideConnection:
|
||||
"""Simple fake implementation of AgentSideConnection for testing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the fake connection with an empty calls list."""
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
self.permission_requests: list[RequestPermissionRequest] = []
|
||||
self.permission_response: RequestPermissionResponse | None = None
|
||||
|
||||
async def sessionUpdate(self, notification: Any) -> None:
|
||||
"""Track sessionUpdate calls."""
|
||||
self.calls.append(notification)
|
||||
|
||||
async def requestPermission(
|
||||
self, request: RequestPermissionRequest
|
||||
) -> RequestPermissionResponse:
|
||||
"""Track permission requests and return a mocked response."""
|
||||
self.permission_requests.append(request)
|
||||
if self.permission_response:
|
||||
return self.permission_response
|
||||
# Default: approve the action
|
||||
return RequestPermissionResponse(
|
||||
outcome=AllowedOutcome(
|
||||
outcome="selected",
|
||||
optionId="allow-once",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@tool(description="Get the current weather for a location")
|
||||
def get_weather_tool(location: str) -> str:
|
||||
"""Get the current weather for a location.
|
||||
|
||||
Args:
|
||||
location: The city and state, e.g. "San Francisco, CA"
|
||||
|
||||
Returns:
|
||||
A string describing the current weather
|
||||
"""
|
||||
# Return fake weather data for testing
|
||||
return f"The weather in {location} is sunny and 72°F"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def deepagents_acp_test_context(
|
||||
messages: list[BaseMessage],
|
||||
prompt_request: PromptRequest,
|
||||
tools: list[Any] | None = None,
|
||||
stream_delimiter: str | None = r"(\s)",
|
||||
middleware: list[Any] | None = None,
|
||||
):
|
||||
"""Context manager for testing DeepagentsACP.
|
||||
|
||||
Args:
|
||||
messages: List of messages for the fake model to return
|
||||
prompt_request: The prompt request to send to the agent
|
||||
tools: List of tools to provide to the agent (defaults to [])
|
||||
stream_delimiter: How to chunk content when streaming (default: r"(\\s)" for whitespace)
|
||||
middleware: Optional middleware to add to the agent graph
|
||||
|
||||
Yields:
|
||||
FakeAgentSideConnection: The connection object that tracks sessionUpdate calls
|
||||
"""
|
||||
from deepagents.graph import create_deep_agent
|
||||
|
||||
connection = FakeAgentSideConnection()
|
||||
model = GenericFakeChatModel(
|
||||
messages=iter(messages),
|
||||
stream_delimiter=stream_delimiter,
|
||||
)
|
||||
tools = tools if tools is not None else []
|
||||
|
||||
# Create the agent graph
|
||||
agent_graph = create_deep_agent(
|
||||
model=model,
|
||||
tools=tools,
|
||||
checkpointer=InMemorySaver(),
|
||||
middleware=middleware or [],
|
||||
)
|
||||
|
||||
deepagents_acp = DeepagentsACP(
|
||||
connection=connection,
|
||||
agent_graph=agent_graph,
|
||||
)
|
||||
|
||||
# Create a new session
|
||||
session_response = await deepagents_acp.newSession(
|
||||
NewSessionRequest(cwd="/tmp", mcpServers=[])
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
|
||||
# Update the prompt request with the session ID
|
||||
prompt_request.sessionId = session_id
|
||||
|
||||
# Call prompt
|
||||
await deepagents_acp.prompt(prompt_request)
|
||||
|
||||
try:
|
||||
yield connection
|
||||
finally:
|
||||
pass
|
||||
|
||||
|
||||
class TestDeepAgentsACP:
|
||||
"""Test suite for DeepagentsACP initialization."""
|
||||
|
||||
async def test_initialization(self) -> None:
|
||||
"""Test that DeepagentsACP can be initialized without errors."""
|
||||
prompt_request = PromptRequest(
|
||||
sessionId="", # Will be set by context manager
|
||||
prompt=[TextContentBlock(text="Hi!", type="text")],
|
||||
)
|
||||
|
||||
async with deepagents_acp_test_context(
|
||||
messages=[AIMessage(content="Hello!")],
|
||||
prompt_request=prompt_request,
|
||||
tools=[get_weather_tool],
|
||||
) as connection:
|
||||
assert len(connection.calls) == 1
|
||||
first_call = connection.calls[0].model_dump()
|
||||
assert first_call == {
|
||||
"field_meta": None,
|
||||
"sessionId": IsUUID,
|
||||
"update": {
|
||||
"content": {
|
||||
"annotations": None,
|
||||
"field_meta": None,
|
||||
"text": "Hello!",
|
||||
"type": "text",
|
||||
},
|
||||
"field_meta": None,
|
||||
"sessionUpdate": "agent_message_chunk",
|
||||
},
|
||||
}
|
||||
|
||||
async def test_tool_call_and_response(self) -> None:
|
||||
"""Test that DeepagentsACP handles tool calls correctly.
|
||||
|
||||
This test verifies that when an AI message contains tool_calls, the agent:
|
||||
1. Detects and executes the tool call
|
||||
2. Sends tool call progress notifications (pending and completed)
|
||||
3. Streams the AI response content as chunks after tool execution
|
||||
|
||||
Note: The FakeChat model streams messages but the agent graph must actually
|
||||
execute the tools for the flow to complete.
|
||||
"""
|
||||
prompt_request = PromptRequest(
|
||||
sessionId="", # Will be set by context manager
|
||||
prompt=[TextContentBlock(text="What's the weather in Paris?", type="text")],
|
||||
)
|
||||
|
||||
# The fake model will be called multiple times by the agent graph:
|
||||
# 1. First call: AI decides to use the tool (with tool_calls)
|
||||
# 2. After tool execution: AI responds with the result
|
||||
async with deepagents_acp_test_context(
|
||||
messages=[
|
||||
AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "get_weather_tool",
|
||||
"args": {"location": "Paris, France"},
|
||||
"id": "call_123",
|
||||
"type": "tool_call",
|
||||
}
|
||||
],
|
||||
),
|
||||
AIMessage(content="The weather in Paris is sunny and 72°F today!"),
|
||||
],
|
||||
prompt_request=prompt_request,
|
||||
tools=[get_weather_tool],
|
||||
) as connection:
|
||||
# Expected call sequence:
|
||||
# Call 0: Tool call progress (status="pending")
|
||||
# Call 1: Tool call progress (status="completed")
|
||||
# Calls 2+: Message chunks for "The weather in Paris is sunny and 72°F today!"
|
||||
|
||||
tool_call_updates = [
|
||||
call.model_dump()
|
||||
for call in connection.calls
|
||||
if call.model_dump()["update"]["sessionUpdate"] == "tool_call_update"
|
||||
]
|
||||
|
||||
# Verify we have exactly 2 tool call updates
|
||||
assert len(tool_call_updates) == 2
|
||||
|
||||
# Verify tool call pending with full structure
|
||||
assert tool_call_updates[0]["update"] == {
|
||||
"sessionUpdate": "tool_call_update",
|
||||
"status": "pending",
|
||||
"toolCallId": "call_123",
|
||||
"title": "get_weather_tool",
|
||||
"rawInput": {"location": "Paris, France"},
|
||||
"content": None,
|
||||
"rawOutput": None,
|
||||
"kind": None,
|
||||
"locations": None,
|
||||
"field_meta": None,
|
||||
}
|
||||
|
||||
# Verify tool call completed with full structure
|
||||
assert tool_call_updates[1]["update"] == {
|
||||
"sessionUpdate": "tool_call_update",
|
||||
"status": "completed",
|
||||
"toolCallId": "call_123",
|
||||
"title": "get_weather_tool",
|
||||
"rawInput": None, # rawInput not included in completed status
|
||||
"content": [
|
||||
{
|
||||
"type": "content",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "The weather in Paris, France is sunny and 72°F",
|
||||
"annotations": None,
|
||||
"field_meta": None,
|
||||
},
|
||||
}
|
||||
],
|
||||
"rawOutput": "The weather in Paris, France is sunny and 72°F",
|
||||
"kind": None,
|
||||
"locations": None,
|
||||
"field_meta": None,
|
||||
}
|
||||
|
||||
# Verify all non-tool-call updates are message chunks
|
||||
message_chunks = [
|
||||
call.model_dump()
|
||||
for call in connection.calls
|
||||
if call.model_dump()["update"]["sessionUpdate"] == "agent_message_chunk"
|
||||
]
|
||||
assert len(message_chunks) > 0
|
||||
for chunk in message_chunks:
|
||||
assert chunk["update"]["sessionUpdate"] == "agent_message_chunk"
|
||||
assert chunk["update"]["content"]["type"] == "text"
|
||||
|
||||
|
||||
async def test_todo_list_handling() -> None:
|
||||
"""Test that DeepagentsACP handles todo list updates correctly."""
|
||||
from deepagents.graph import create_deep_agent
|
||||
|
||||
prompt_request = PromptRequest(
|
||||
sessionId="", # Will be set by context manager
|
||||
prompt=[TextContentBlock(text="Create a shopping list", type="text")],
|
||||
)
|
||||
|
||||
# Create a mock connection to track calls
|
||||
connection = FakeAgentSideConnection()
|
||||
model = GenericFakeChatModel(
|
||||
messages=iter([AIMessage(content="I'll create that shopping list for you.")]),
|
||||
stream_delimiter=r"(\s)",
|
||||
)
|
||||
|
||||
# Create agent graph
|
||||
agent_graph = create_deep_agent(
|
||||
model=model,
|
||||
tools=[get_weather_tool],
|
||||
checkpointer=InMemorySaver(),
|
||||
)
|
||||
|
||||
deepagents_acp = DeepagentsACP(
|
||||
connection=connection,
|
||||
agent_graph=agent_graph,
|
||||
)
|
||||
|
||||
# Create a new session
|
||||
session_response = await deepagents_acp.newSession(
|
||||
NewSessionRequest(cwd="/tmp", mcpServers=[])
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
prompt_request.sessionId = session_id
|
||||
|
||||
# Manually inject a tools update with todos into the agent stream
|
||||
# Simulate the graph's behavior by patching the astream method
|
||||
agent = deepagents_acp._sessions[session_id]["agent"]
|
||||
original_astream = agent.astream
|
||||
|
||||
async def mock_astream(*args, **kwargs):
|
||||
# First yield the normal message chunks
|
||||
async for item in original_astream(*args, **kwargs):
|
||||
yield item
|
||||
|
||||
# Then inject a tools update with todos
|
||||
yield (
|
||||
"updates",
|
||||
{
|
||||
"tools": {
|
||||
"todos": [
|
||||
{"content": "Buy fresh bananas", "status": "pending"},
|
||||
{"content": "Buy whole grain bread", "status": "in_progress"},
|
||||
{"content": "Buy organic eggs", "status": "completed"},
|
||||
],
|
||||
"messages": [],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
agent.astream = mock_astream
|
||||
|
||||
# Call prompt
|
||||
await deepagents_acp.prompt(prompt_request)
|
||||
|
||||
# Find the plan update in the calls
|
||||
plan_updates = [
|
||||
call.model_dump()
|
||||
for call in connection.calls
|
||||
if call.model_dump()["update"]["sessionUpdate"] == "plan"
|
||||
]
|
||||
|
||||
# Verify we got exactly one plan update with correct structure
|
||||
assert len(plan_updates) == 1
|
||||
assert plan_updates[0]["update"] == {
|
||||
"sessionUpdate": "plan",
|
||||
"entries": [
|
||||
{
|
||||
"content": "Buy fresh bananas",
|
||||
"status": "pending",
|
||||
"priority": "medium",
|
||||
"field_meta": None,
|
||||
},
|
||||
{
|
||||
"content": "Buy whole grain bread",
|
||||
"status": "in_progress",
|
||||
"priority": "medium",
|
||||
"field_meta": None,
|
||||
},
|
||||
{
|
||||
"content": "Buy organic eggs",
|
||||
"status": "completed",
|
||||
"priority": "medium",
|
||||
"field_meta": None,
|
||||
},
|
||||
],
|
||||
"field_meta": None,
|
||||
}
|
||||
|
||||
|
||||
async def test_fake_chat_model_streaming() -> None:
|
||||
"""Test to verify GenericFakeChatModel stream_delimiter API.
|
||||
|
||||
This test demonstrates the different streaming modes available via stream_delimiter.
|
||||
"""
|
||||
# Test 1: No streaming (stream_delimiter=None) - single chunk
|
||||
model_no_stream = GenericFakeChatModel(
|
||||
messages=iter([AIMessage(content="Hello world")]),
|
||||
stream_delimiter=None,
|
||||
)
|
||||
chunks = []
|
||||
async for chunk in model_no_stream.astream("test"):
|
||||
chunks.append(chunk)
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].content == "Hello world"
|
||||
|
||||
# Test 2: Stream on whitespace using regex (default behavior)
|
||||
model_whitespace = GenericFakeChatModel(
|
||||
messages=iter([AIMessage(content="Hello world")]),
|
||||
stream_delimiter=r"(\s)",
|
||||
)
|
||||
chunks = []
|
||||
async for chunk in model_whitespace.astream("test"):
|
||||
chunks.append(chunk)
|
||||
# Should split into: "Hello", " ", "world"
|
||||
assert len(chunks) == 3
|
||||
assert chunks[0].content == "Hello"
|
||||
assert chunks[1].content == " "
|
||||
assert chunks[2].content == "world"
|
||||
|
||||
# Test 3: Stream with tool_calls
|
||||
model_with_tools = GenericFakeChatModel(
|
||||
messages=iter(
|
||||
[
|
||||
AIMessage(
|
||||
content="Checking weather",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "get_weather_tool",
|
||||
"args": {"location": "paris, france"},
|
||||
"id": "call_123",
|
||||
"type": "tool_call",
|
||||
}
|
||||
],
|
||||
),
|
||||
]
|
||||
),
|
||||
stream_delimiter=r"(\s)",
|
||||
)
|
||||
chunks = []
|
||||
async for chunk in model_with_tools.astream("test"):
|
||||
chunks.append(chunk)
|
||||
# Tool calls should only be in the last chunk
|
||||
assert len(chunks) > 0
|
||||
assert chunks[-1].tool_calls == [
|
||||
{
|
||||
"name": "get_weather_tool",
|
||||
"args": {"location": "paris, france"},
|
||||
"id": "call_123",
|
||||
"type": "tool_call",
|
||||
}
|
||||
]
|
||||
# Earlier chunks should not have tool_calls
|
||||
for chunk in chunks[:-1]:
|
||||
assert chunk.tool_calls == []
|
||||
|
||||
|
||||
async def test_human_in_the_loop_approval() -> None:
|
||||
"""Test that DeepagentsACP handles HITL interrupts and permission requests correctly."""
|
||||
from langchain.agents.middleware import HumanInTheLoopMiddleware
|
||||
from deepagents.graph import create_deep_agent
|
||||
|
||||
prompt_request = PromptRequest(
|
||||
sessionId="", # Will be set below
|
||||
prompt=[TextContentBlock(text="What's the weather in Tokyo?", type="text")],
|
||||
)
|
||||
|
||||
# Create connection with permission response configured
|
||||
connection = FakeAgentSideConnection()
|
||||
# Set up the connection to approve the tool call
|
||||
connection.permission_response = RequestPermissionResponse(
|
||||
outcome=AllowedOutcome(
|
||||
outcome="selected",
|
||||
optionId="allow-once",
|
||||
)
|
||||
)
|
||||
|
||||
model = GenericFakeChatModel(
|
||||
messages=iter(
|
||||
[
|
||||
# First message: AI decides to call the tool
|
||||
AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "get_weather_tool",
|
||||
"args": {"location": "Tokyo, Japan"},
|
||||
"id": "call_tokyo_123",
|
||||
"type": "tool_call",
|
||||
}
|
||||
],
|
||||
),
|
||||
# Second message: AI responds with the weather result after tool execution
|
||||
AIMessage(content="The weather in Tokyo is sunny and 72°F!"),
|
||||
]
|
||||
),
|
||||
stream_delimiter=r"(\s)",
|
||||
)
|
||||
|
||||
# Create agent graph with HITL middleware
|
||||
agent_graph = create_deep_agent(
|
||||
model=model,
|
||||
tools=[get_weather_tool],
|
||||
checkpointer=InMemorySaver(),
|
||||
middleware=[HumanInTheLoopMiddleware(interrupt_on={"get_weather_tool": True})],
|
||||
)
|
||||
|
||||
deepagents_acp = DeepagentsACP(
|
||||
connection=connection,
|
||||
agent_graph=agent_graph,
|
||||
)
|
||||
|
||||
# Create a new session
|
||||
session_response = await deepagents_acp.newSession(
|
||||
NewSessionRequest(cwd="/tmp", mcpServers=[])
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
prompt_request.sessionId = session_id
|
||||
|
||||
# Call prompt - this should trigger HITL
|
||||
await deepagents_acp.prompt(prompt_request)
|
||||
|
||||
# Verify that a permission request was made with correct structure
|
||||
assert len(connection.permission_requests) == 1
|
||||
perm_request = connection.permission_requests[0]
|
||||
|
||||
assert {
|
||||
"sessionId": perm_request.sessionId,
|
||||
"toolCall": {
|
||||
"title": perm_request.toolCall.title,
|
||||
"rawInput": perm_request.toolCall.rawInput,
|
||||
"status": perm_request.toolCall.status,
|
||||
},
|
||||
"option_ids": [opt.optionId for opt in perm_request.options],
|
||||
} == {
|
||||
"sessionId": session_id,
|
||||
"toolCall": {
|
||||
"title": "get_weather_tool",
|
||||
"rawInput": {"location": "Tokyo, Japan"},
|
||||
"status": "pending",
|
||||
},
|
||||
"option_ids": ["allow-once", "reject-once"],
|
||||
}
|
||||
|
||||
# Verify that tool execution happened after approval
|
||||
tool_call_updates = [
|
||||
call.model_dump()
|
||||
for call in connection.calls
|
||||
if call.model_dump()["update"]["sessionUpdate"] == "tool_call_update"
|
||||
]
|
||||
|
||||
assert len(tool_call_updates) == 2
|
||||
assert tool_call_updates[0]["update"] == {
|
||||
"sessionUpdate": "tool_call_update",
|
||||
"status": "pending",
|
||||
"title": "get_weather_tool",
|
||||
"toolCallId": "call_tokyo_123",
|
||||
"rawInput": {"location": "Tokyo, Japan"},
|
||||
"content": None,
|
||||
"rawOutput": None,
|
||||
"kind": None,
|
||||
"locations": None,
|
||||
"field_meta": None,
|
||||
}
|
||||
|
||||
# Check completed status
|
||||
completed_update = tool_call_updates[1]["update"]
|
||||
assert completed_update["sessionUpdate"] == "tool_call_update"
|
||||
assert completed_update["status"] == "completed"
|
||||
assert completed_update["title"] == "get_weather_tool"
|
||||
assert "Tokyo, Japan" in completed_update["rawOutput"]
|
||||
|
||||
# Verify final AI message was streamed
|
||||
message_chunks = [
|
||||
call
|
||||
for call in connection.calls
|
||||
if call.model_dump()["update"]["sessionUpdate"] == "agent_message_chunk"
|
||||
]
|
||||
assert len(message_chunks) > 0
|
||||
1872
deepagents_sourcecode/libs/acp/uv.lock
generated
Normal file
66
deepagents_sourcecode/libs/deepagents-cli/Makefile
Normal file
@@ -0,0 +1,66 @@
|
||||
.PHONY: all lint format test help run test_integration test_watch
|
||||
|
||||
# Default target executed when no arguments are given to make.
|
||||
all: help
|
||||
|
||||
######################
|
||||
# TESTING AND COVERAGE
|
||||
######################
|
||||
|
||||
# Define a variable for the test file path.
|
||||
TEST_FILE ?= tests/unit_tests
|
||||
INTEGRATION_FILES ?= tests/integration_tests
|
||||
|
||||
test:
|
||||
uv run pytest --disable-socket --allow-unix-socket $(TEST_FILE)
|
||||
|
||||
test_integration:
|
||||
uv run pytest $(INTEGRATION_FILES)
|
||||
|
||||
test_watch:
|
||||
uv run ptw . -- $(TEST_FILE)
|
||||
|
||||
run:
|
||||
uvx --no-cache --reinstall .
|
||||
|
||||
|
||||
######################
|
||||
# LINTING AND FORMATTING
|
||||
######################
|
||||
|
||||
# Define a variable for Python and notebook files.
|
||||
lint format: PYTHON_FILES=deepagents_cli/ tests/
|
||||
lint_diff format_diff: PYTHON_FILES=$(shell git diff --relative=. --name-only --diff-filter=d master | grep -E '\.py$$|\.ipynb$$')
|
||||
|
||||
lint lint_diff:
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff
|
||||
@if [ "$(LINT)" != "minimal" ]; then \
|
||||
if [ "$(PYTHON_FILES)" != "" ]; then \
|
||||
uv run ruff check $(PYTHON_FILES) --diff; \
|
||||
fi; \
|
||||
fi
|
||||
# [ "$(PYTHON_FILES)" = "" ] || uv run mypy $(PYTHON_FILES)
|
||||
|
||||
format format_diff:
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES)
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run ruff check --fix $(PYTHON_FILES)
|
||||
|
||||
format_unsafe:
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run ruff format --unsafe-fixes $(PYTHON_FILES)
|
||||
|
||||
|
||||
######################
|
||||
# HELP
|
||||
######################
|
||||
|
||||
help:
|
||||
@echo '===================='
|
||||
@echo '-- LINTING --'
|
||||
@echo 'format - run code formatters'
|
||||
@echo 'lint - run linters'
|
||||
@echo '-- TESTS --'
|
||||
@echo 'test - run unit tests'
|
||||
@echo 'test TEST_FILE=<test_file> - run all tests in file'
|
||||
@echo '-- DOCUMENTATION tasks are from the top-level Makefile --'
|
||||
|
||||
|
||||
369
deepagents_sourcecode/libs/deepagents-cli/README.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# 🚀🧠 Deep Agents CLI
|
||||
|
||||
The [deepagents](https://github.com/langchain-ai/deepagents) CLI is an open source coding assistant that runs in your terminal, similar to Claude Code.
|
||||
|
||||
**Key Features:**
|
||||
- **Built-in Tools**: File operations (read, write, edit, glob, grep), shell commands, web search, and subagent delegation
|
||||
- **Customizable Skills**: Add domain-specific capabilities through a progressive disclosure skill system
|
||||
- **Persistent Memory**: Agent remembers your preferences, coding style, and project context across sessions
|
||||
- **Project-Aware**: Automatically detects project roots and loads project-specific configurations
|
||||
|
||||
<img src="cli-banner.jpg" alt="deep agent" width="100%"/>
|
||||
|
||||
## 🚀 Quickstart
|
||||
|
||||
`deepagents-cli` is a Python package that can be installed via pip or uv.
|
||||
|
||||
**Install via pip:**
|
||||
```bash
|
||||
pip install deepagents-cli
|
||||
```
|
||||
|
||||
**Or using uv (recommended):**
|
||||
```bash
|
||||
# Create a virtual environment
|
||||
uv venv
|
||||
|
||||
# Install the package
|
||||
uv pip install deepagents-cli
|
||||
```
|
||||
|
||||
**Run the agent in your terminal:**
|
||||
```bash
|
||||
deepagents
|
||||
```
|
||||
|
||||
**Get help:**
|
||||
```bash
|
||||
deepagents help
|
||||
```
|
||||
|
||||
**Common options:**
|
||||
```bash
|
||||
# Use a specific agent configuration
|
||||
deepagents --agent mybot
|
||||
|
||||
# Use a specific model (auto-detects provider)
|
||||
deepagents --model claude-sonnet-4-5-20250929
|
||||
deepagents --model gpt-4o
|
||||
|
||||
# Auto-approve tool usage (skip human-in-the-loop prompts)
|
||||
deepagents --auto-approve
|
||||
|
||||
# Execute code in a remote sandbox
|
||||
deepagents --sandbox modal # or runloop, daytona
|
||||
deepagents --sandbox-id dbx_123 # reuse existing sandbox
|
||||
```
|
||||
|
||||
Type naturally as you would in a chat interface. The agent will use its built-in tools, skills, and memory to help you with tasks.
|
||||
|
||||
## Model Configuration
|
||||
|
||||
The CLI supports three LLM providers with automatic provider detection based on model name:
|
||||
|
||||
**Supported Providers:**
|
||||
- **OpenAI** - Models like `gpt-4o`, `gpt-5-mini`, `o1-preview`, `o3-mini` (default: `gpt-5-mini`)
|
||||
- **Anthropic** - Models like `claude-sonnet-4-5-20250929`, `claude-3-opus-20240229` (default: `claude-sonnet-4-5-20250929`)
|
||||
- **Google** - Models like `gemini-3-pro-preview`, `gemini-1.5-pro` (default: `gemini-3-pro-preview`)
|
||||
|
||||
**Specify model at startup:**
|
||||
```bash
|
||||
# Auto-detects Anthropic from model name pattern
|
||||
deepagents --model claude-sonnet-4-5-20250929
|
||||
|
||||
# Auto-detects OpenAI from model name pattern
|
||||
deepagents --model gpt-4o
|
||||
```
|
||||
|
||||
**Or use environment variables:**
|
||||
```bash
|
||||
# Set provider-specific model defaults
|
||||
export ANTHROPIC_MODEL="claude-sonnet-4-5-20250929"
|
||||
export OPENAI_MODEL="gpt-4o"
|
||||
export GOOGLE_MODEL="gemini-1.5-pro"
|
||||
|
||||
# Set API keys (required)
|
||||
export ANTHROPIC_API_KEY="your-key"
|
||||
export OPENAI_API_KEY="your-key"
|
||||
export GOOGLE_API_KEY="your-key"
|
||||
```
|
||||
|
||||
**Model name conventions:**
|
||||
|
||||
Model names follow each provider's official naming convention:
|
||||
- **OpenAI**: See [OpenAI Models Documentation](https://platform.openai.com/docs/models)
|
||||
- **Anthropic**: See [Anthropic Models Documentation](https://docs.anthropic.com/en/docs/about-claude/models)
|
||||
- **Google**: See [Google Gemini Models Documentation](https://ai.google.dev/gemini-api/docs/models/gemini)
|
||||
|
||||
The active model is displayed at startup in the CLI interface.
|
||||
|
||||
## Built-in Tools
|
||||
|
||||
The agent comes with the following built-in tools (always available without configuration):
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `ls` | List files and directories |
|
||||
| `read_file` | Read contents of a file |
|
||||
| `write_file` | Create or overwrite a file |
|
||||
| `edit_file` | Make targeted edits to existing files |
|
||||
| `glob` | Find files matching a pattern (e.g., `**/*.py`) |
|
||||
| `grep` | Search for text patterns across files |
|
||||
| `shell` | Execute shell commands (local mode) |
|
||||
| `execute` | Execute commands in remote sandbox (sandbox mode) |
|
||||
| `web_search` | Search the web using Tavily API |
|
||||
| `fetch_url` | Fetch and convert web pages to markdown |
|
||||
| `task` | Delegate work to subagents for parallel execution |
|
||||
| `write_todos` | Create and manage task lists for complex work |
|
||||
|
||||
> [!WARNING]
|
||||
> **Human-in-the-Loop (HITL) Approval Required**
|
||||
>
|
||||
> Potentially destructive operations require user approval before execution:
|
||||
> - **File operations**: `write_file`, `edit_file`
|
||||
> - **Command execution**: `shell`, `execute`
|
||||
> - **External requests**: `web_search`, `fetch_url`
|
||||
> - **Delegation**: `task` (subagents)
|
||||
>
|
||||
> Each operation will prompt for approval showing the action details. Use `--auto-approve` to skip prompts:
|
||||
> ```bash
|
||||
> deepagents --auto-approve
|
||||
> ```
|
||||
|
||||
## Agent Configuration
|
||||
|
||||
Each agent has its own configuration directory at `~/.deepagents/<agent_name>/`, with default `agent`.
|
||||
|
||||
```bash
|
||||
# List all configured agents
|
||||
deepagents list
|
||||
|
||||
# Create a new agent
|
||||
deepagents create <agent_name>
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### LangSmith Tracing
|
||||
|
||||
The CLI supports separate LangSmith project configuration for agent tracing vs user code tracing:
|
||||
|
||||
**Agent Tracing** - Traces deepagents operations (tool calls, agent decisions):
|
||||
```bash
|
||||
export DEEPAGENTS_LANGSMITH_PROJECT="my-agent-project"
|
||||
```
|
||||
|
||||
**User Code Tracing** - Traces code executed via shell commands:
|
||||
```bash
|
||||
export LANGSMITH_PROJECT="my-user-code-project"
|
||||
```
|
||||
|
||||
**Complete Setup Example:**
|
||||
```bash
|
||||
# Enable LangSmith tracing
|
||||
export LANGCHAIN_TRACING_V2=true
|
||||
export LANGCHAIN_API_KEY="your-api-key"
|
||||
|
||||
# Configure separate projects
|
||||
export DEEPAGENTS_LANGSMITH_PROJECT="agent-traces"
|
||||
export LANGSMITH_PROJECT="user-code-traces"
|
||||
|
||||
# Run deepagents
|
||||
deepagents
|
||||
```
|
||||
|
||||
When both are configured, the CLI displays:
|
||||
```
|
||||
✓ LangSmith tracing enabled: Deepagents → 'agent-traces'
|
||||
User code (shell) → 'user-code-traces'
|
||||
```
|
||||
|
||||
**Why separate projects?**
|
||||
- Keep agent operations separate from your application code traces
|
||||
- Easier debugging by isolating agent vs user code behavior
|
||||
- Different retention policies or access controls per project
|
||||
|
||||
**Backwards Compatibility:**
|
||||
If `DEEPAGENTS_LANGSMITH_PROJECT` is not set, both agent and user code trace to the same project specified by `LANGSMITH_PROJECT`.
|
||||
|
||||
## Customization
|
||||
|
||||
There are two primary ways to customize any agent: **memory** and **skills**.
|
||||
|
||||
Each agent has its own global configuration directory at `~/.deepagents/<agent_name>/`:
|
||||
|
||||
```
|
||||
~/.deepagents/<agent_name>/
|
||||
├── agent.md # Auto-loaded global personality/style
|
||||
└── skills/ # Auto-loaded agent-specific skills
|
||||
├── web-research/
|
||||
│ └── SKILL.md
|
||||
└── langgraph-docs/
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
Projects can extend the global configuration with project-specific instructions and skills:
|
||||
|
||||
```
|
||||
my-project/
|
||||
├── .git/
|
||||
└── .deepagents/
|
||||
├── agent.md # Project-specific instructions
|
||||
└── skills/ # Project-specific skills
|
||||
└── custom-tool/
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
The CLI automatically detects project roots (via `.git`) and loads:
|
||||
- Project-specific `agent.md` from `[project-root]/.deepagents/agent.md`
|
||||
- Project-specific skills from `[project-root]/.deepagents/skills/`
|
||||
|
||||
Both global and project configurations are loaded together, allowing you to:
|
||||
- Keep general coding style/preferences in global agent.md
|
||||
- Add project-specific context, conventions, or guidelines in project agent.md
|
||||
- Share project-specific skills with your team (committed to version control)
|
||||
- Override global skills with project-specific versions (when skill names match)
|
||||
|
||||
### agent.md files
|
||||
|
||||
`agent.md` files provide persistent memory that is always loaded at session start. Both global and project-level `agent.md` files are loaded together and injected into the system prompt.
|
||||
|
||||
**Global `agent.md`** (`~/.deepagents/agent/agent.md`)
|
||||
- Your personality, style, and universal coding preferences
|
||||
- General tone and communication style
|
||||
- Universal coding preferences (formatting, type hints, etc.)
|
||||
- Tool usage patterns that apply everywhere
|
||||
- Workflows and methodologies that don't change per-project
|
||||
|
||||
**Project `agent.md`** (`.deepagents/agent.md` in project root)
|
||||
- Project-specific context and conventions
|
||||
- Project architecture and design patterns
|
||||
- Coding conventions specific to this codebase
|
||||
- Testing strategies and deployment processes
|
||||
- Team guidelines and project structure
|
||||
|
||||
**How it works (AgentMemoryMiddleware):**
|
||||
- Loads both files at startup and injects into system prompt as `<user_memory>` and `<project_memory>`
|
||||
- Appends [memory management instructions](deepagents_cli/agent_memory.py#L44-L158) on when/how to update memory files
|
||||
|
||||
**When the agent updates memory:**
|
||||
- IMMEDIATELY when you describe how it should behave
|
||||
- IMMEDIATELY when you give feedback on its work
|
||||
- When you explicitly ask it to remember something
|
||||
- When patterns or preferences emerge from your interactions
|
||||
|
||||
The agent uses `edit_file` to update memories when learning preferences or receiving feedback.
|
||||
|
||||
### Project memory files
|
||||
|
||||
Beyond `agent.md`, you can create additional memory files in `.deepagents/` for structured project knowledge. These work similarly to [Anthropic's Memory Tool](https://platform.claude.com/docs/en/agents-and-tools/tool-use/memory-tool). The agent receives [detailed instructions](deepagents_cli/agent_memory.py#L123-L158) on when to read and update these files.
|
||||
|
||||
**How it works:**
|
||||
1. Create markdown files in `[project-root]/.deepagents/` (e.g., `api-design.md`, `architecture.md`, `deployment.md`)
|
||||
2. The agent checks these files when relevant to a task (not auto-loaded into every prompt)
|
||||
3. The agent uses `write_file` or `edit_file` to create/update memory files when learning project patterns
|
||||
|
||||
**Example workflow:**
|
||||
```bash
|
||||
# Agent discovers deployment pattern and saves it
|
||||
.deepagents/
|
||||
├── agent.md # Always loaded (personality + conventions)
|
||||
├── architecture.md # Loaded on-demand (system design)
|
||||
└── deployment.md # Loaded on-demand (deploy procedures)
|
||||
```
|
||||
|
||||
**When the agent reads memory files:**
|
||||
- At the start of new sessions (checks what files exist)
|
||||
- Before answering questions about project-specific topics
|
||||
- When you reference past work or patterns
|
||||
- When performing tasks that match saved knowledge domains
|
||||
|
||||
**Benefits:**
|
||||
- **Persistent learning**: Agent remembers project patterns across sessions
|
||||
- **Team collaboration**: Share project knowledge through version control
|
||||
- **Contextual retrieval**: Load only relevant memory when needed (reduces token usage)
|
||||
- **Structured knowledge**: Organize information by domain (APIs, architecture, deployment, etc.)
|
||||
|
||||
### Skills
|
||||
|
||||
Skills are reusable agent capabilities that provide specialized workflows and domain knowledge. Example skills are provided in the `examples/skills/` directory:
|
||||
|
||||
- **web-research** - Structured web research workflow with planning, parallel delegation, and synthesis
|
||||
- **langgraph-docs** - LangGraph documentation lookup and guidance
|
||||
|
||||
To use an example skill globally with the default agent, just copy them to the agent's skills global or project-level skills directory:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.deepagents/agent/skills
|
||||
cp -r examples/skills/web-research ~/.deepagents/agent/skills/
|
||||
```
|
||||
|
||||
To manage skills:
|
||||
|
||||
```bash
|
||||
# List all skills (global + project)
|
||||
deepagents skills list
|
||||
|
||||
# List only project skills
|
||||
deepagents skills list --project
|
||||
|
||||
# Create a new global skill from template
|
||||
deepagents skills create my-skill
|
||||
|
||||
# Create a new project skill
|
||||
deepagents skills create my-tool --project
|
||||
|
||||
# View detailed information about a skill
|
||||
deepagents skills info web-research
|
||||
|
||||
# View info for a project skill only
|
||||
deepagents skills info my-tool --project
|
||||
```
|
||||
|
||||
To use skills (e.g., the langgraph-docs skill), just type a request relevant to a skill and the skill will be used automatically.
|
||||
|
||||
```bash
|
||||
$ deepagents
|
||||
$ "create a agent.py script that implements a LangGraph agent"
|
||||
```
|
||||
|
||||
Skills follow Anthropic's [progressive disclosure pattern](https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills) - the agent knows skills exist but only reads full instructions when needed.
|
||||
|
||||
1. **At startup** - SkillsMiddleware scans `~/.deepagents/agent/skills/` and `.deepagents/skills/` directories
|
||||
2. **Parse metadata** - Extracts YAML frontmatter (name + description) from each `SKILL.md` file
|
||||
3. **Inject into prompt** - Adds skill list with descriptions to system prompt: "Available Skills: web-research - Use for web research tasks..."
|
||||
4. **Progressive loading** - Agent reads full `SKILL.md` content with `read_file` only when a task matches the skill's description
|
||||
5. **Execute workflow** - Agent follows the step-by-step instructions in the skill file
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
To run the test suite:
|
||||
|
||||
```bash
|
||||
uv sync --all-groups
|
||||
|
||||
make test
|
||||
```
|
||||
|
||||
### Running During Development
|
||||
|
||||
```bash
|
||||
# From libs/deepagents-cli directory
|
||||
uv run deepagents
|
||||
|
||||
# Or install in editable mode
|
||||
uv pip install -e .
|
||||
deepagents
|
||||
```
|
||||
|
||||
### Modifying the CLI
|
||||
|
||||
- **UI changes** → Edit `ui.py` or `input.py`
|
||||
- **Add new tools** → Edit `tools.py`
|
||||
- **Change execution flow** → Edit `execution.py`
|
||||
- **Add commands** → Edit `commands.py`
|
||||
- **Agent configuration** → Edit `agent.py`
|
||||
- **Skills system** → Edit `skills/` modules
|
||||
- **Constants/colors** → Edit `config.py`
|
||||
BIN
deepagents_sourcecode/libs/deepagents-cli/cli-banner.jpg
Normal file
|
After Width: | Height: | Size: 200 KiB |
@@ -0,0 +1,5 @@
|
||||
"""DeepAgents CLI - Interactive AI coding assistant."""
|
||||
|
||||
from deepagents_cli.main import cli_main
|
||||
|
||||
__all__ = ["cli_main"]
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Allow running the CLI as: python -m deepagents.cli."""
|
||||
|
||||
from deepagents_cli.main import cli_main
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli_main()
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Version information for deepagents-cli."""
|
||||
|
||||
__version__ = "0.0.12"
|
||||
@@ -0,0 +1,454 @@
|
||||
"""CLI를 위한 에이전트 관리 및 생성."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from deepagents import create_deep_agent
|
||||
from deepagents.backends import CompositeBackend
|
||||
from deepagents.backends.filesystem import FilesystemBackend
|
||||
from deepagents.backends.sandbox import SandboxBackendProtocol
|
||||
from langchain.agents.middleware import (
|
||||
InterruptOnConfig,
|
||||
)
|
||||
from langchain.agents.middleware.types import AgentState
|
||||
from langchain.messages import ToolCall
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from langgraph.pregel import Pregel
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from deepagents_cli.agent_memory import AgentMemoryMiddleware
|
||||
from deepagents_cli.config import COLORS, config, console, get_default_coding_instructions, settings
|
||||
from deepagents_cli.integrations.sandbox_factory import get_default_working_dir
|
||||
from deepagents_cli.shell import ShellMiddleware
|
||||
from deepagents_cli.skills import SkillsMiddleware
|
||||
|
||||
|
||||
def list_agents() -> None:
|
||||
"""사용 가능한 모든 에이전트를 나열합니다."""
|
||||
agents_dir = settings.user_deepagents_dir
|
||||
|
||||
if not agents_dir.exists() or not any(agents_dir.iterdir()):
|
||||
console.print("[yellow]에이전트를 찾을 수 없습니다.[/yellow]")
|
||||
console.print(
|
||||
"[dim]처음 사용할 때 ~/.deepagents/에 에이전트가 생성됩니다.[/dim]",
|
||||
style=COLORS["dim"],
|
||||
)
|
||||
return
|
||||
|
||||
console.print("\n[bold]사용 가능한 에이전트:[/bold]\n", style=COLORS["primary"])
|
||||
|
||||
for agent_path in sorted(agents_dir.iterdir()):
|
||||
if agent_path.is_dir():
|
||||
agent_name = agent_path.name
|
||||
agent_md = agent_path / "agent.md"
|
||||
|
||||
if agent_md.exists():
|
||||
console.print(f" • [bold]{agent_name}[/bold]", style=COLORS["primary"])
|
||||
console.print(f" {agent_path}", style=COLORS["dim"])
|
||||
else:
|
||||
console.print(f" • [bold]{agent_name}[/bold] [dim](미완성)[/dim]", style=COLORS["tool"])
|
||||
console.print(f" {agent_path}", style=COLORS["dim"])
|
||||
|
||||
console.print()
|
||||
|
||||
|
||||
def reset_agent(agent_name: str, source_agent: str | None = None) -> None:
|
||||
"""에이전트를 기본값으로 재설정하거나 다른 에이전트로부터 복사합니다."""
|
||||
agents_dir = settings.user_deepagents_dir
|
||||
agent_dir = agents_dir / agent_name
|
||||
|
||||
if source_agent:
|
||||
source_dir = agents_dir / source_agent
|
||||
source_md = source_dir / "agent.md"
|
||||
|
||||
if not source_md.exists():
|
||||
console.print(
|
||||
f"[bold red]오류:[/bold red] 소스 에이전트 '{source_agent}'를 찾을 수 없거나 agent.md가 없습니다"
|
||||
)
|
||||
return
|
||||
|
||||
source_content = source_md.read_text()
|
||||
action_desc = f"contents of agent '{source_agent}'"
|
||||
else:
|
||||
source_content = get_default_coding_instructions()
|
||||
action_desc = "default"
|
||||
|
||||
if agent_dir.exists():
|
||||
shutil.rmtree(agent_dir)
|
||||
console.print(f"기존 에이전트 디렉터리를 제거했습니다: {agent_dir}", style=COLORS["tool"])
|
||||
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
agent_md = agent_dir / "agent.md"
|
||||
agent_md.write_text(source_content)
|
||||
|
||||
console.print(f"✓ 에이전트 '{agent_name}'가 {action_desc}(으)로 재설정되었습니다", style=COLORS["primary"])
|
||||
console.print(f"Location: {agent_dir}\n", style=COLORS["dim"])
|
||||
|
||||
|
||||
def get_system_prompt(assistant_id: str, sandbox_type: str | None = None) -> str:
|
||||
"""에이전트에 대한 기본 시스템 프롬프트를 가져옵니다.
|
||||
|
||||
Args:
|
||||
assistant_id: 경로 참조를 위한 에이전트 식별자
|
||||
sandbox_type: 샌드박스 공급자 유형("modal", "runloop", "daytona").
|
||||
None인 경우 에이전트는 로컬 모드에서 작동합니다.
|
||||
|
||||
Returns:
|
||||
시스템 프롬프트 문자열 (agent.md 내용 제외)
|
||||
"""
|
||||
agent_dir_path = f"~/.deepagents/{assistant_id}"
|
||||
|
||||
if sandbox_type:
|
||||
# Get provider-specific working directory
|
||||
|
||||
working_dir = get_default_working_dir(sandbox_type)
|
||||
|
||||
working_dir_section = f"""### Current Working Directory
|
||||
|
||||
You are working in a **remote Linux sandbox** at `{working_dir}`.
|
||||
|
||||
All code execution and file operations happen in this sandbox environment.
|
||||
|
||||
**IMPORTANT:**
|
||||
- The CLI runs locally on the user's machine, but executes code remotely.
|
||||
- Use `{working_dir}` as your working directory for all operations.
|
||||
|
||||
"""
|
||||
else:
|
||||
cwd = Path.cwd()
|
||||
working_dir_section = f"""<env>
|
||||
WORKING_DIRECTORY: {cwd}
|
||||
</env>
|
||||
|
||||
### Current Working Directory
|
||||
|
||||
The filesystem backend is currently operating at: `{cwd}`
|
||||
|
||||
### File System and Paths
|
||||
|
||||
**IMPORTANT - Path Handling:**
|
||||
- All file paths MUST be absolute (e.g. `{cwd}/file.txt`).
|
||||
- Use the WORKING_DIRECTORY from <env> to construct absolute paths.
|
||||
- Example: To create a file in the working directory, use `{cwd}/research_project/file.md`
|
||||
- Do NOT use relative paths - always construct the full absolute path.
|
||||
|
||||
"""
|
||||
|
||||
return (
|
||||
working_dir_section
|
||||
+ f"""### Skills Directory
|
||||
|
||||
Your skills are stored at: `{agent_dir_path}/skills/`
|
||||
Skills may contain scripts or support files. Use the physical filesystem path when running skill scripts with bash:
|
||||
Example: `bash python {agent_dir_path}/skills/web-research/script.py`
|
||||
|
||||
### Human-in-the-Loop Tool Approvals
|
||||
|
||||
Some tool calls require user approval before execution. If a tool call is rejected by the user:
|
||||
1. Accept the decision immediately - do NOT try the same command again.
|
||||
2. Explain that you understand the user rejected the operation.
|
||||
3. Propose an alternative or ask for clarification.
|
||||
4. NEVER try to bypass a rejection by retrying the exact same command.
|
||||
|
||||
Respect user decisions and work collaboratively.
|
||||
|
||||
### Web Search Tool Usage
|
||||
|
||||
When using the web_search tool:
|
||||
1. The tool returns search results with titles, URLs, and content snippets.
|
||||
2. You MUST read and process these results, then respond to the user naturally.
|
||||
3. Do NOT show raw JSON or tool results directly to the user.
|
||||
4. Synthesize information from multiple sources into a coherent answer.
|
||||
5. Cite sources by mentioning page titles or URLs when relevant.
|
||||
6. If you don't find what you need in the search, explain what you found and ask clarifying questions.
|
||||
|
||||
The user ONLY sees your text response, not the tool results. Always provide a complete, natural language answer after using web_search.
|
||||
|
||||
### Todo List Management
|
||||
|
||||
When using the write_todos tool:
|
||||
1. Keep the todo list minimal - aim for 3-6 items max.
|
||||
2. Only create todos for complex, multi-step tasks that really need tracking.
|
||||
3. Break down tasks into clear, actionable items without being overly granular.
|
||||
4. For simple tasks (1-2 steps), just do them - don't create a todo.
|
||||
5. When first creating a todo list for a task, ALWAYS ask the user if the plan looks good before starting work.
|
||||
- Create the todos so they render, then ask "Does this plan look good?" or similar.
|
||||
- Wait for the user's response before marking the first todo in_progress.
|
||||
- Adjust the plan if they want changes.
|
||||
6. Update todo status promptly as you complete each item.
|
||||
|
||||
The todo list is a planning tool - use it judiciously to avoid overwhelming the user with excessive task tracking."""
|
||||
)
|
||||
|
||||
|
||||
def _format_write_file_description(tool_call: ToolCall, _state: AgentState, _runtime: Runtime) -> str:
|
||||
"""승인 프롬프트를 위한 write_file 도구 호출 포맷."""
|
||||
args = tool_call["args"]
|
||||
file_path = args.get("file_path", "unknown")
|
||||
content = args.get("content", "")
|
||||
|
||||
action = "덮어쓰기(Overwrite)" if Path(file_path).exists() else "생성(Create)"
|
||||
line_count = len(content.splitlines())
|
||||
|
||||
return f"파일: {file_path}\n작업: 파일 {action}\n줄 수: {line_count}"
|
||||
|
||||
|
||||
def _format_edit_file_description(tool_call: ToolCall, _state: AgentState, _runtime: Runtime) -> str:
|
||||
"""승인 프롬프트를 위한 edit_file 도구 호출 포맷."""
|
||||
args = tool_call["args"]
|
||||
file_path = args.get("file_path", "unknown")
|
||||
replace_all = bool(args.get("replace_all", False))
|
||||
|
||||
return f"파일: {file_path}\n작업: 텍스트 교체 ({'모든 항목' if replace_all else '단일 항목'})"
|
||||
|
||||
|
||||
def _format_web_search_description(tool_call: ToolCall, _state: AgentState, _runtime: Runtime) -> str:
|
||||
"""Format web_search tool call for approval prompt."""
|
||||
args = tool_call["args"]
|
||||
query = args.get("query", "unknown")
|
||||
max_results = args.get("max_results", 5)
|
||||
|
||||
return f"쿼리: {query}\n최대 결과: {max_results}\n\n⚠️ 이 작업은 Tavily API 크레딧을 사용합니다"
|
||||
|
||||
|
||||
def _format_fetch_url_description(tool_call: ToolCall, _state: AgentState, _runtime: Runtime) -> str:
|
||||
"""Format fetch_url tool call for approval prompt."""
|
||||
args = tool_call["args"]
|
||||
url = args.get("url", "unknown")
|
||||
timeout = args.get("timeout", 30)
|
||||
|
||||
return f"URL: {url}\n시간 제한: {timeout}초\n\n⚠️ 웹 콘텐츠를 가져와 마크다운으로 변환합니다"
|
||||
|
||||
|
||||
def _format_task_description(tool_call: ToolCall, _state: AgentState, _runtime: Runtime) -> str:
|
||||
"""승인 프롬프트를 위한 task(서브 에이전트) 도구 호출 포맷.
|
||||
|
||||
task 도구 서명은: task(description: str, subagent_type: str)
|
||||
description에는 서브 에이전트에게 전송될 모든 지침이 포함됩니다.
|
||||
"""
|
||||
args = tool_call["args"]
|
||||
description = args.get("description", "unknown")
|
||||
subagent_type = args.get("subagent_type", "unknown")
|
||||
|
||||
# Truncate description if too long for display
|
||||
description_preview = description
|
||||
if len(description) > 500:
|
||||
description_preview = description[:500] + "..."
|
||||
|
||||
return (
|
||||
f"서브 에이전트 유형: {subagent_type}\n\n"
|
||||
f"작업 지침:\n"
|
||||
f"{'─' * 40}\n"
|
||||
f"{description_preview}\n"
|
||||
f"{'─' * 40}\n\n"
|
||||
f"⚠️ 서브 에이전트는 파일 작업 및 셸 명령에 접근할 수 있습니다"
|
||||
)
|
||||
|
||||
|
||||
def _format_shell_description(tool_call: ToolCall, _state: AgentState, _runtime: Runtime) -> str:
|
||||
"""Format shell tool call for approval prompt."""
|
||||
args = tool_call["args"]
|
||||
command = args.get("command", "없음")
|
||||
return f"셸 명령: {command}\n작업 디렉터리: {Path.cwd()}"
|
||||
|
||||
|
||||
def _format_execute_description(tool_call: ToolCall, _state: AgentState, _runtime: Runtime) -> str:
|
||||
"""Format execute tool call for approval prompt."""
|
||||
args = tool_call["args"]
|
||||
command = args.get("command", "없음")
|
||||
return f"명령 실행: {command}\n위치: 원격 샌드박스"
|
||||
|
||||
|
||||
def _add_interrupt_on() -> dict[str, InterruptOnConfig]:
|
||||
"""파괴적인 도구에 대해 히먼-인-더-루프(human-in-the-loop) interrupt_on 설정을 구성합니다."""
|
||||
shell_interrupt_config: InterruptOnConfig = {
|
||||
"allowed_decisions": ["approve", "reject"],
|
||||
"description": _format_shell_description,
|
||||
}
|
||||
|
||||
execute_interrupt_config: InterruptOnConfig = {
|
||||
"allowed_decisions": ["approve", "reject"],
|
||||
"description": _format_execute_description,
|
||||
}
|
||||
|
||||
write_file_interrupt_config: InterruptOnConfig = {
|
||||
"allowed_decisions": ["approve", "reject"],
|
||||
"description": _format_write_file_description,
|
||||
}
|
||||
|
||||
edit_file_interrupt_config: InterruptOnConfig = {
|
||||
"allowed_decisions": ["approve", "reject"],
|
||||
"description": _format_edit_file_description,
|
||||
}
|
||||
|
||||
web_search_interrupt_config: InterruptOnConfig = {
|
||||
"allowed_decisions": ["approve", "reject"],
|
||||
"description": _format_web_search_description,
|
||||
}
|
||||
|
||||
fetch_url_interrupt_config: InterruptOnConfig = {
|
||||
"allowed_decisions": ["approve", "reject"],
|
||||
"description": _format_fetch_url_description,
|
||||
}
|
||||
|
||||
task_interrupt_config: InterruptOnConfig = {
|
||||
"allowed_decisions": ["approve", "reject"],
|
||||
"description": _format_task_description,
|
||||
}
|
||||
return {
|
||||
"shell": shell_interrupt_config,
|
||||
"execute": execute_interrupt_config,
|
||||
"write_file": write_file_interrupt_config,
|
||||
"edit_file": edit_file_interrupt_config,
|
||||
"web_search": web_search_interrupt_config,
|
||||
"fetch_url": fetch_url_interrupt_config,
|
||||
"task": task_interrupt_config,
|
||||
}
|
||||
|
||||
|
||||
def create_cli_agent(
|
||||
model: str | BaseChatModel,
|
||||
assistant_id: str,
|
||||
*,
|
||||
tools: list[BaseTool] | None = None,
|
||||
sandbox: SandboxBackendProtocol | None = None,
|
||||
sandbox_type: str | None = None,
|
||||
system_prompt: str | None = None,
|
||||
auto_approve: bool = False,
|
||||
enable_memory: bool = True,
|
||||
enable_skills: bool = True,
|
||||
enable_shell: bool = True,
|
||||
) -> tuple[Pregel, CompositeBackend]:
|
||||
"""유연한 옵션으로 CLI 구성 에이전트를 생성합니다.
|
||||
|
||||
이것은 deepagents CLI 에이전트 생성을 위한 주요 진입점이며,
|
||||
내부적으로 사용되거나 외부 코드(예: 벤치마킹 프레임워크, Harbor)에서 사용할 수 있습니다.
|
||||
|
||||
Args:
|
||||
model: 사용할 LLM 모델 (예: "anthropic:claude-sonnet-4-5-20250929")
|
||||
assistant_id: 메모리/상태 저장을 위한 에이전트 식별자
|
||||
tools: 에이전트에 제공할 추가 도구 (기본값: 빈 목록)
|
||||
sandbox: 원격 실행을 위한 선택적 샌드박스 백엔드 (예: ModalBackend).
|
||||
None인 경우 로컬 파일시스템 + 셸을 사용합니다.
|
||||
sandbox_type: 샌드박스 공급자 유형("modal", "runloop", "daytona").
|
||||
시스템 프롬프트 생성에 사용됩니다.
|
||||
system_prompt: 기본 시스템 프롬프트를 재정의합니다. None인 경우
|
||||
sandbox_type 및 assistant_id를 기반으로 생성합니다.
|
||||
auto_approve: True인 경우 사람의 확인 없이 모든 도구 호출을 자동으로 승인합니다.
|
||||
자동화된 워크플로에 유용합니다.
|
||||
enable_memory: 영구 메모리를 위한 AgentMemoryMiddleware 활성화
|
||||
enable_skills: 사용자 정의 에이전트 스킬을 위한 SkillsMiddleware 활성화
|
||||
enable_shell: 로컬 셸 실행을 위한 ShellMiddleware 활성화 (로컬 모드에서만)
|
||||
|
||||
Returns:
|
||||
(agent_graph, composite_backend)의 2-튜플
|
||||
- agent_graph: 실행 준비된 구성된 LangGraph Pregel 인스턴스
|
||||
- composite_backend: 파일 작업을 위한 CompositeBackend
|
||||
"""
|
||||
if tools is None:
|
||||
tools = []
|
||||
|
||||
# Setup agent directory for persistent memory (if enabled)
|
||||
if enable_memory or enable_skills:
|
||||
agent_dir = settings.ensure_agent_dir(assistant_id)
|
||||
agent_md = agent_dir / "agent.md"
|
||||
if not agent_md.exists():
|
||||
source_content = get_default_coding_instructions()
|
||||
agent_md.write_text(source_content)
|
||||
|
||||
# Skills directories (if enabled)
|
||||
skills_dir = None
|
||||
project_skills_dir = None
|
||||
if enable_skills:
|
||||
skills_dir = settings.ensure_user_skills_dir(assistant_id)
|
||||
project_skills_dir = settings.get_project_skills_dir()
|
||||
|
||||
# Build middleware stack based on enabled features
|
||||
agent_middleware = []
|
||||
|
||||
# CONDITIONAL SETUP: Local vs Remote Sandbox
|
||||
if sandbox is None:
|
||||
# ========== LOCAL MODE ==========
|
||||
composite_backend = CompositeBackend(
|
||||
default=FilesystemBackend(), # Current working directory
|
||||
routes={}, # No virtualization - use real paths
|
||||
)
|
||||
|
||||
# Add memory middleware
|
||||
if enable_memory:
|
||||
agent_middleware.append(AgentMemoryMiddleware(settings=settings, assistant_id=assistant_id))
|
||||
|
||||
# Add skills middleware
|
||||
if enable_skills:
|
||||
agent_middleware.append(
|
||||
SkillsMiddleware(
|
||||
skills_dir=skills_dir,
|
||||
assistant_id=assistant_id,
|
||||
project_skills_dir=project_skills_dir,
|
||||
)
|
||||
)
|
||||
|
||||
# Add shell middleware (only in local mode)
|
||||
if enable_shell:
|
||||
# Create environment for shell commands
|
||||
# Restore user's original LANGSMITH_PROJECT so their code traces separately
|
||||
shell_env = os.environ.copy()
|
||||
if settings.user_langchain_project:
|
||||
shell_env["LANGSMITH_PROJECT"] = settings.user_langchain_project
|
||||
|
||||
agent_middleware.append(
|
||||
ShellMiddleware(
|
||||
workspace_root=str(Path.cwd()),
|
||||
env=shell_env,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# ========== REMOTE SANDBOX MODE ==========
|
||||
composite_backend = CompositeBackend(
|
||||
default=sandbox, # Remote sandbox (ModalBackend, etc.)
|
||||
routes={}, # No virtualization
|
||||
)
|
||||
|
||||
# Add memory middleware
|
||||
if enable_memory:
|
||||
agent_middleware.append(AgentMemoryMiddleware(settings=settings, assistant_id=assistant_id))
|
||||
|
||||
# Add skills middleware
|
||||
if enable_skills:
|
||||
agent_middleware.append(
|
||||
SkillsMiddleware(
|
||||
skills_dir=skills_dir,
|
||||
assistant_id=assistant_id,
|
||||
project_skills_dir=project_skills_dir,
|
||||
)
|
||||
)
|
||||
|
||||
# Note: Shell middleware not used in sandbox mode
|
||||
# File operations and execute tool are provided by the sandbox backend
|
||||
|
||||
# Get or use custom system prompt
|
||||
if system_prompt is None:
|
||||
system_prompt = get_system_prompt(assistant_id=assistant_id, sandbox_type=sandbox_type)
|
||||
|
||||
# Configure interrupt_on based on auto_approve setting
|
||||
if auto_approve:
|
||||
# No interrupts - all tools run automatically
|
||||
interrupt_on = {}
|
||||
else:
|
||||
# Full HITL for destructive operations
|
||||
interrupt_on = _add_interrupt_on()
|
||||
|
||||
# Create the agent
|
||||
agent = create_deep_agent(
|
||||
model=model,
|
||||
system_prompt=system_prompt,
|
||||
tools=tools,
|
||||
backend=composite_backend,
|
||||
middleware=agent_middleware,
|
||||
interrupt_on=interrupt_on,
|
||||
checkpointer=InMemorySaver(),
|
||||
).with_config(config)
|
||||
return agent, composite_backend
|
||||
@@ -0,0 +1,328 @@
|
||||
"""에이전트별 장기 메모리를 시스템 프롬프트에 로드하기 위한 미들웨어."""
|
||||
|
||||
import contextlib
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import NotRequired, TypedDict, cast
|
||||
|
||||
from langchain.agents.middleware.types import (
|
||||
AgentMiddleware,
|
||||
AgentState,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
)
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from deepagents_cli.config import Settings
|
||||
|
||||
|
||||
class AgentMemoryState(AgentState):
|
||||
"""에이전트 메모리 미들웨어를 위한 상태."""
|
||||
|
||||
user_memory: NotRequired[str]
|
||||
"""~/.deepagents/{agent}/의 개인 설정 (모든 곳에 적용됨)."""
|
||||
|
||||
project_memory: NotRequired[str]
|
||||
"""프로젝트별 컨텍스트 (프로젝트 루트에서 로드됨)."""
|
||||
|
||||
|
||||
class AgentMemoryStateUpdate(TypedDict):
|
||||
"""에이전트 메모리 미들웨어에 대한 상태 업데이트."""
|
||||
|
||||
user_memory: NotRequired[str]
|
||||
"""~/.deepagents/{agent}/의 개인 설정 (모든 곳에 적용됨)."""
|
||||
|
||||
project_memory: NotRequired[str]
|
||||
"""프로젝트별 컨텍스트 (프로젝트 루트에서 로드됨)."""
|
||||
|
||||
|
||||
# Long-term Memory Documentation
|
||||
# Note: Claude Code loads CLAUDE.md files hierarchically and combines them (not precedence-based):
|
||||
# - Loads recursively from cwd up to (but not including) root directory
|
||||
# - Multiple files are combined hierarchically: enterprise → project → user
|
||||
# - Both [project-root]/CLAUDE.md and [project-root]/.claude/CLAUDE.md are loaded if both exist
|
||||
# - Files higher in hierarchy load first, providing foundation for more specific memories
|
||||
# We will follow that pattern for deepagents-cli
|
||||
LONGTERM_MEMORY_SYSTEM_PROMPT = """
|
||||
|
||||
## Long-term Memory
|
||||
|
||||
Long-term memory is stored in files on the filesystem and persists across sessions.
|
||||
|
||||
**User Memory Location**: `{agent_dir_absolute}` (display: `{agent_dir_display}`)
|
||||
**Project Memory Location**: {project_memory_info}
|
||||
|
||||
The system prompt is loaded from two sources at startup:
|
||||
1. **User agent.md**: `{agent_dir_absolute}/agent.md` - personal settings that apply everywhere
|
||||
2. **Project agent.md**: loaded from the project root if available - project-specific instructions
|
||||
|
||||
Project-specific agent.md files are loaded from the following locations (combined if both exist):
|
||||
- `[project-root]/.deepagents/agent.md` (preferred)
|
||||
- `[project-root]/agent.md` (fallback, included if both exist)
|
||||
|
||||
**When you should check/read memory (IMPORTANT - do this first):**
|
||||
- **At the start of every new session**: Check both user and project memory
|
||||
- User: `ls {agent_dir_absolute}`
|
||||
- Project: `ls {project_deepagents_dir}` (if inside a project)
|
||||
- **Before answering a question**: If asked "What do you know about X?" or "How do I do Y?", check project memory first, then user.
|
||||
- **When the user asks you to do a task**: Check for project-specific guides or examples.
|
||||
- **When the user refers to past work**: Search project memory files for relevant context.
|
||||
|
||||
**Memory-First Response Pattern:**
|
||||
1. User asks question -> Check project directory first: `ls {project_deepagents_dir}`
|
||||
2. If relevant files exist -> Read them: `read_file '{project_deepagents_dir}/[filename]'`
|
||||
3. If needed, check user memory -> `ls {agent_dir_absolute}`
|
||||
4. Answer by supplementing general knowledge with stored knowledge.
|
||||
|
||||
**When you should update memory:**
|
||||
- **Immediately when the user describes your role or how you should behave**
|
||||
- **Immediately when the user gives you feedback** - record what went wrong and how to do better in memory.
|
||||
- When the user explicitly asks you to remember something.
|
||||
- When patterns or preferences emerge (coding style, conventions, workflow).
|
||||
- After a significant task where the context would be helpful for future sessions.
|
||||
|
||||
**Learning from Feedback:**
|
||||
- When the user tells you something is better or worse, figure out why and encode it as a pattern.
|
||||
- Every correction is an opportunity to improve permanently - don't just fix the immediate issue, update your instructions.
|
||||
- If the user says "You should remember X" or "Pay attention to Y", treat this as highest priority and update memory immediately.
|
||||
- Look for the underlying principles behind corrections, not just the specific mistakes.
|
||||
|
||||
## Deciding Where to Store Memory
|
||||
|
||||
When writing or updating agent memory, decide where each fact, configuration, or behavior belongs:
|
||||
|
||||
### User Agent File: `{agent_dir_absolute}/agent.md`
|
||||
-> Describes the agent's **personality, style, and universal behaviors** across all projects.
|
||||
|
||||
**Store here:**
|
||||
- General tone and communication style
|
||||
- Universal coding preferences (formatting, commenting style, etc.)
|
||||
- General workflows and methodologies to follow
|
||||
- Tool usage patterns that apply everywhere
|
||||
- Personal preferences that don't change between projects
|
||||
|
||||
**Examples:**
|
||||
- "Be concise and direct in your answers"
|
||||
- "Always use type hints in Python"
|
||||
- "Prefer functional programming patterns"
|
||||
|
||||
### Project Agent File: `{project_deepagents_dir}/agent.md`
|
||||
-> Describes **how this specific project works** and **how the agent should behave here only**.
|
||||
|
||||
**Store here:**
|
||||
- Project-specific architecture and design patterns
|
||||
- Coding conventions specific to this codebase
|
||||
- Project structure and organization
|
||||
- Testing strategies for this project
|
||||
- Deployment processes and workflows
|
||||
- Team conventions and guidelines
|
||||
|
||||
**Examples:**
|
||||
- "This project uses FastAPI with SQLAlchemy"
|
||||
- "Tests are located in tests/ directory mirroring src structure"
|
||||
- "All API changes require updating OpenAPI specs"
|
||||
|
||||
### Project Memory Files: `{project_deepagents_dir}/*.md`
|
||||
-> Use for **project-specific reference information** and structured notes.
|
||||
|
||||
**Store here:**
|
||||
- API design documentation
|
||||
- Architecture decisions and reasoning
|
||||
- Deployment procedures
|
||||
- Common debugging patterns
|
||||
- Onboarding information
|
||||
|
||||
**Examples:**
|
||||
- `{project_deepagents_dir}/api-design.md` - REST API patterns used
|
||||
- `{project_deepagents_dir}/architecture.md` - System architecture overview
|
||||
- `{project_deepagents_dir}/deployment.md` - How to deploy this project
|
||||
|
||||
### File Operations:
|
||||
|
||||
**User Memory:**
|
||||
```
|
||||
ls {agent_dir_absolute} # List user memory files
|
||||
read_file '{agent_dir_absolute}/agent.md' # Read user preferences
|
||||
edit_file '{agent_dir_absolute}/agent.md' ... # Update user preferences
|
||||
```
|
||||
|
||||
**Project Memory (Preferred for project-specific info):**
|
||||
```
|
||||
ls {project_deepagents_dir} # List project memory files
|
||||
read_file '{project_deepagents_dir}/agent.md' # Read project guidelines
|
||||
edit_file '{project_deepagents_dir}/agent.md' ... # Update project guidelines
|
||||
write_file '{project_deepagents_dir}/agent.md' ... # Create project memory file
|
||||
```
|
||||
|
||||
**IMPORTANT**:
|
||||
- Project memory files are stored in `.deepagents/` inside the project root.
|
||||
- Always use absolute paths for file operations.
|
||||
- Determine if info is project-specific (check user vs project memory) before answering."""
|
||||
|
||||
|
||||
DEFAULT_MEMORY_SNIPPET = """<user_memory>
|
||||
{user_memory}
|
||||
</user_memory>
|
||||
|
||||
<project_memory>
|
||||
{project_memory}
|
||||
</project_memory>"""
|
||||
|
||||
|
||||
class AgentMemoryMiddleware(AgentMiddleware):
|
||||
"""에이전트별 장기 메모리를 로드하기 위한 미들웨어.
|
||||
|
||||
이 미들웨어는 파일(agent.md)에서 에이전트의 장기 메모리를 로드하고
|
||||
시스템 프롬프트에 주입합니다. 메모리는 대화 시작 시 한 번 로드되어
|
||||
상태에 저장됩니다.
|
||||
"""
|
||||
|
||||
state_schema = AgentMemoryState
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings: Settings,
|
||||
assistant_id: str,
|
||||
system_prompt_template: str | None = None,
|
||||
) -> None:
|
||||
"""에이전트 메모리 미들웨어를 초기화합니다.
|
||||
|
||||
Args:
|
||||
settings: 프로젝트 감지 및 경로가 포함된 전역 설정 인스턴스.
|
||||
assistant_id: 에이전트 식별자.
|
||||
system_prompt_template: 시스템 프롬프트에 에이전트 메모리를 주입하기 위한
|
||||
선택적 사용자 정의 템플릿.
|
||||
"""
|
||||
self.settings = settings
|
||||
self.assistant_id = assistant_id
|
||||
|
||||
# User paths
|
||||
self.agent_dir = settings.get_agent_dir(assistant_id)
|
||||
# Store both display path (with ~) and absolute path for file operations
|
||||
self.agent_dir_display = f"~/.deepagents/{assistant_id}"
|
||||
self.agent_dir_absolute = str(self.agent_dir)
|
||||
|
||||
# Project paths (from settings)
|
||||
self.project_root = settings.project_root
|
||||
|
||||
self.system_prompt_template = system_prompt_template or DEFAULT_MEMORY_SNIPPET
|
||||
|
||||
def before_agent(
|
||||
self,
|
||||
state: AgentMemoryState,
|
||||
runtime: Runtime,
|
||||
) -> AgentMemoryStateUpdate:
|
||||
"""에이전트 실행 전에 파일에서 에이전트 메모리를 로드합니다.
|
||||
|
||||
사용자 agent.md와 프로젝트별 agent.md가 있으면 로드합니다.
|
||||
상태에 아직 없는 경우에만 로드합니다.
|
||||
|
||||
사용자 업데이트를 포착하기 위해 매 호출마다 파일 존재 여부를 동적으로 확인합니다.
|
||||
|
||||
Args:
|
||||
state: 현재 에이전트 상태.
|
||||
runtime: 런타임 컨텍스트.
|
||||
|
||||
Returns:
|
||||
user_memory 및 project_memory가 채워진 업데이트된 상태.
|
||||
"""
|
||||
result: AgentMemoryStateUpdate = {}
|
||||
|
||||
# Load user memory if not already in state
|
||||
if "user_memory" not in state:
|
||||
user_path = self.settings.get_user_agent_md_path(self.assistant_id)
|
||||
if user_path.exists():
|
||||
with contextlib.suppress(OSError, UnicodeDecodeError):
|
||||
result["user_memory"] = user_path.read_text()
|
||||
|
||||
# Load project memory if not already in state
|
||||
if "project_memory" not in state:
|
||||
project_path = self.settings.get_project_agent_md_path()
|
||||
if project_path and project_path.exists():
|
||||
with contextlib.suppress(OSError, UnicodeDecodeError):
|
||||
result["project_memory"] = project_path.read_text()
|
||||
|
||||
return result
|
||||
|
||||
def _build_system_prompt(self, request: ModelRequest) -> str:
|
||||
"""메모리 섹션이 포함된 전체 시스템 프롬프트를 작성합니다.
|
||||
|
||||
Args:
|
||||
request: 상태 및 기본 시스템 프롬프트가 포함된 모델 요청.
|
||||
|
||||
Returns:
|
||||
메모리 섹션이 주입된 전체 시스템 프롬프트.
|
||||
"""
|
||||
# Extract memory from state
|
||||
state = cast("AgentMemoryState", request.state)
|
||||
user_memory = state.get("user_memory")
|
||||
project_memory = state.get("project_memory")
|
||||
base_system_prompt = request.system_prompt
|
||||
|
||||
# Build project memory info for documentation
|
||||
if self.project_root and project_memory:
|
||||
project_memory_info = f"`{self.project_root}` (detected)"
|
||||
elif self.project_root:
|
||||
project_memory_info = f"`{self.project_root}` (no agent.md found)"
|
||||
else:
|
||||
project_memory_info = "None (not in a git project)"
|
||||
|
||||
# Build project deepagents directory path
|
||||
if self.project_root:
|
||||
project_deepagents_dir = str(self.project_root / ".deepagents")
|
||||
else:
|
||||
project_deepagents_dir = "[project-root]/.deepagents (not in a project)"
|
||||
|
||||
# Format memory section with both memories
|
||||
memory_section = self.system_prompt_template.format(
|
||||
user_memory=user_memory if user_memory else "(No user agent.md)",
|
||||
project_memory=project_memory if project_memory else "(No project agent.md)",
|
||||
)
|
||||
|
||||
system_prompt = memory_section
|
||||
|
||||
if base_system_prompt:
|
||||
system_prompt += "\n\n" + base_system_prompt
|
||||
|
||||
system_prompt += "\n\n" + LONGTERM_MEMORY_SYSTEM_PROMPT.format(
|
||||
agent_dir_absolute=self.agent_dir_absolute,
|
||||
agent_dir_display=self.agent_dir_display,
|
||||
project_memory_info=project_memory_info,
|
||||
project_deepagents_dir=project_deepagents_dir,
|
||||
)
|
||||
|
||||
return system_prompt
|
||||
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelResponse:
|
||||
"""시스템 프롬프트에 에이전트 메모리를 주입합니다.
|
||||
|
||||
Args:
|
||||
request: 처리 중인 모델 요청.
|
||||
handler: 수정된 요청으로 호출할 핸들러 함수.
|
||||
|
||||
Returns:
|
||||
핸들러의 모델 응답.
|
||||
"""
|
||||
system_prompt = self._build_system_prompt(request)
|
||||
return handler(request.override(system_prompt=system_prompt))
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelResponse:
|
||||
"""(비동기) 시스템 프롬프트에 에이전트 메모리를 주입합니다.
|
||||
|
||||
Args:
|
||||
request: 처리 중인 모델 요청.
|
||||
handler: 수정된 요청으로 호출할 핸들러 함수.
|
||||
|
||||
Returns:
|
||||
핸들러의 모델 응답.
|
||||
"""
|
||||
system_prompt = self._build_system_prompt(request)
|
||||
return await handler(request.override(system_prompt=system_prompt))
|
||||
@@ -0,0 +1,87 @@
|
||||
"""슬래시 명령 및 bash 실행을 위한 명령 처리기."""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
from .config import COLORS, DEEP_AGENTS_ASCII, console
|
||||
from .ui import TokenTracker, show_interactive_help
|
||||
|
||||
|
||||
def handle_command(command: str, agent, token_tracker: TokenTracker) -> str | bool:
|
||||
"""슬래시 명령을 처리합니다. 종료하려면 'exit', 처리된 경우 True, 에이전트에게 전달하려면 False를 반환합니다."""
|
||||
cmd = command.lower().strip().lstrip("/")
|
||||
|
||||
if cmd in ["quit", "exit", "q"]:
|
||||
return "exit"
|
||||
|
||||
if cmd == "clear":
|
||||
# Reset agent conversation state
|
||||
agent.checkpointer = InMemorySaver()
|
||||
|
||||
# Reset token tracking to baseline
|
||||
token_tracker.reset()
|
||||
|
||||
# Clear screen and show fresh UI
|
||||
console.clear()
|
||||
console.print(DEEP_AGENTS_ASCII, style=f"bold {COLORS['primary']}")
|
||||
console.print()
|
||||
console.print("... 새로 시작! 화면이 지워지고 대화가 초기화되었습니다.", style=COLORS["agent"])
|
||||
console.print()
|
||||
return True
|
||||
|
||||
if cmd == "help":
|
||||
show_interactive_help()
|
||||
return True
|
||||
|
||||
if cmd == "tokens":
|
||||
token_tracker.display_session()
|
||||
return True
|
||||
|
||||
console.print()
|
||||
console.print(f"[yellow]알 수 없는 명령: /{cmd}[/yellow]")
|
||||
console.print("[dim]사용 가능한 명령을 보려면 /help를 입력하세요.[/dim]")
|
||||
console.print()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def execute_bash_command(command: str) -> bool:
|
||||
"""bash 명령을 실행하고 출력을 표시합니다. 처리된 경우 True를 반환합니다."""
|
||||
cmd = command.strip().lstrip("!")
|
||||
|
||||
if not cmd:
|
||||
return True
|
||||
|
||||
try:
|
||||
console.print()
|
||||
console.print(f"[dim]$ {cmd}[/dim]")
|
||||
|
||||
# Execute the command
|
||||
result = subprocess.run(
|
||||
cmd, check=False, shell=True, capture_output=True, text=True, timeout=30, cwd=Path.cwd()
|
||||
)
|
||||
|
||||
# Display output
|
||||
if result.stdout:
|
||||
console.print(result.stdout, style=COLORS["dim"], markup=False)
|
||||
if result.stderr:
|
||||
console.print(result.stderr, style="red", markup=False)
|
||||
|
||||
# Show return code if non-zero
|
||||
if result.returncode != 0:
|
||||
console.print(f"[dim]Exit code: {result.returncode}[/dim]")
|
||||
|
||||
console.print()
|
||||
return True
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
console.print("[red]30초 후 명령 시간 초과[/red]")
|
||||
console.print()
|
||||
return True
|
||||
except Exception as e:
|
||||
console.print(f"[red]명령 실행 오류: {e}[/red]")
|
||||
console.print()
|
||||
return True
|
||||
@@ -0,0 +1,509 @@
|
||||
"""CLI를 위한 구성, 상수 밎 모델 생성."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import dotenv
|
||||
from rich.console import Console
|
||||
|
||||
from deepagents_cli._version import __version__
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
# CRITICAL: Override LANGSMITH_PROJECT to route agent traces to separate project
|
||||
# LangSmith reads LANGSMITH_PROJECT at invocation time, so we override it here
|
||||
# and preserve the user's original value for shell commands
|
||||
_deepagents_project = os.environ.get("DEEPAGENTS_LANGSMITH_PROJECT")
|
||||
_original_langsmith_project = os.environ.get("LANGSMITH_PROJECT")
|
||||
if _deepagents_project:
|
||||
# Override LANGSMITH_PROJECT for agent traces
|
||||
os.environ["LANGSMITH_PROJECT"] = _deepagents_project
|
||||
|
||||
# Now safe to import LangChain modules
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
|
||||
# Color scheme
|
||||
COLORS = {
|
||||
"primary": "#10b981",
|
||||
"dim": "#6b7280",
|
||||
"user": "#ffffff",
|
||||
"agent": "#10b981",
|
||||
"thinking": "#34d399",
|
||||
"tool": "#fbbf24",
|
||||
}
|
||||
|
||||
# ASCII art banner
|
||||
|
||||
DEEP_AGENTS_ASCII = f"""
|
||||
██████╗ ███████╗ ███████╗ ██████╗
|
||||
██╔══██╗ ██╔════╝ ██╔════╝ ██╔══██╗
|
||||
██║ ██║ █████╗ █████╗ ██████╔╝
|
||||
██║ ██║ ██╔══╝ ██╔══╝ ██╔═══╝
|
||||
██████╔╝ ███████╗ ███████╗ ██║
|
||||
╚═════╝ ╚══════╝ ╚══════╝ ╚═╝
|
||||
|
||||
█████╗ ██████╗ ███████╗ ███╗ ██╗ ████████╗ ███████╗
|
||||
██╔══██╗ ██╔════╝ ██╔════╝ ████╗ ██║ ╚══██╔══╝ ██╔════╝
|
||||
███████║ ██║ ███╗ █████╗ ██╔██╗ ██║ ██║ ███████╗
|
||||
██╔══██║ ██║ ██║ ██╔══╝ ██║╚██╗██║ ██║ ╚════██║
|
||||
██║ ██║ ╚██████╔╝ ███████╗ ██║ ╚████║ ██║ ███████║
|
||||
╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═══╝ ╚═╝ ╚══════╝
|
||||
v{__version__}
|
||||
"""
|
||||
|
||||
# Interactive commands
|
||||
# Interactive commands
|
||||
COMMANDS = {
|
||||
"clear": "화면을 지우고 대화를 재설정합니다",
|
||||
"help": "도움말 정보를 표시합니다",
|
||||
"tokens": "현재 세션의 토큰 사용량을 표시합니다",
|
||||
"quit": "CLI를 종료합니다",
|
||||
"exit": "CLI를 종료합니다",
|
||||
}
|
||||
|
||||
|
||||
# Maximum argument length for display
|
||||
MAX_ARG_LENGTH = 150
|
||||
|
||||
# Agent configuration
|
||||
config = {"recursion_limit": 1000}
|
||||
|
||||
# Rich console instance
|
||||
console = Console(highlight=False)
|
||||
|
||||
|
||||
def _find_project_root(start_path: Path | None = None) -> Path | None:
|
||||
"""git 디렉터리를 찾아 프로젝트 루트를 찾습니다.
|
||||
|
||||
start_path(또는 cwd)에서 디렉터리 트리를 따라 올라가며 프로젝트 루트를 나타내는
|
||||
.git 디렉터리를 찾습니다.
|
||||
|
||||
Args:
|
||||
start_path: 검색을 시작할 디렉터리. 기본값은 현재 작업 디렉터리입니다.
|
||||
|
||||
Returns:
|
||||
찾은 경우 프로젝트 루트의 경로, 그렇지 않으면 None입니다.
|
||||
"""
|
||||
current = Path(start_path or Path.cwd()).resolve()
|
||||
|
||||
# Walk up the directory tree
|
||||
for parent in [current, *list(current.parents)]:
|
||||
git_dir = parent / ".git"
|
||||
if git_dir.exists():
|
||||
return parent
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _find_project_agent_md(project_root: Path) -> list[Path]:
|
||||
"""프로젝트별 agent.md 파일(들)을 찾습니다.
|
||||
|
||||
두 위치를 확인하고 존재하는 모든 위치를 반환합니다:
|
||||
1. project_root/.deepagents/agent.md
|
||||
2. project_root/agent.md
|
||||
|
||||
두 파일이 모두 존재하면 둘 다 로드되어 결합됩니다.
|
||||
|
||||
Args:
|
||||
project_root: 프로젝트 루트 디렉터리 경로.
|
||||
|
||||
Returns:
|
||||
프로젝트 agent.md 파일 경로 목록 (0, 1 또는 2개의 경로를 포함할 수 있음).
|
||||
"""
|
||||
paths = []
|
||||
|
||||
# Check .deepagents/agent.md (preferred)
|
||||
deepagents_md = project_root / ".deepagents" / "agent.md"
|
||||
if deepagents_md.exists():
|
||||
paths.append(deepagents_md)
|
||||
|
||||
# Check root agent.md (fallback, but also include if both exist)
|
||||
root_md = project_root / "agent.md"
|
||||
if root_md.exists():
|
||||
paths.append(root_md)
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
"""DeepAgents-cli를 위한 전역 설정 및 환경 감지.
|
||||
|
||||
이 클래스는 시작 시 한 번 초기화되며 다음 정보에 대한 액세스를 제공합니다:
|
||||
- 사용 가능한 모델 및 API 키
|
||||
- 현재 프로젝트 정보
|
||||
- 도구 가용성 (예: Tavily)
|
||||
- 파일 시스템 경로
|
||||
|
||||
Attributes:
|
||||
project_root: 현재 프로젝트 루트 디렉터리 (git 프로젝트 내인 경우)
|
||||
|
||||
openai_api_key: OpenAI API 키 (사용 가능한 경우)
|
||||
anthropic_api_key: Anthropic API 키 (사용 가능한 경우)
|
||||
tavily_api_key: Tavily API 키 (사용 가능한 경우)
|
||||
deepagents_langchain_project: DeepAgents 에이전트 추적을 위한 LangSmith 프로젝트 이름
|
||||
user_langchain_project: 환경의 원래 LANGSMITH_PROJECT (사용자 코드용)
|
||||
"""
|
||||
|
||||
# API keys
|
||||
openai_api_key: str | None
|
||||
anthropic_api_key: str | None
|
||||
google_api_key: str | None
|
||||
tavily_api_key: str | None
|
||||
|
||||
# LangSmith configuration
|
||||
deepagents_langchain_project: str | None # For deepagents agent tracing
|
||||
user_langchain_project: str | None # Original LANGSMITH_PROJECT for user code
|
||||
|
||||
# Model configuration
|
||||
model_name: str | None = None # Currently active model name
|
||||
model_provider: str | None = None # Provider (openai, anthropic, google)
|
||||
|
||||
# Project information
|
||||
project_root: Path | None = None
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls, *, start_path: Path | None = None) -> "Settings":
|
||||
"""현재 환경을 감지하여 설정을 생성합니다.
|
||||
|
||||
Args:
|
||||
start_path: 프로젝트 감지를 시작할 디렉터리(기본값은 cwd)
|
||||
|
||||
Returns:
|
||||
감지된 구성이 포함된 Settings 인스턴스
|
||||
"""
|
||||
# Detect API keys
|
||||
openai_key = os.environ.get("OPENAI_API_KEY")
|
||||
anthropic_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
google_key = os.environ.get("GOOGLE_API_KEY")
|
||||
tavily_key = os.environ.get("TAVILY_API_KEY")
|
||||
|
||||
# Detect LangSmith configuration
|
||||
# DEEPAGENTS_LANGSMITH_PROJECT: Project for deepagents agent tracing
|
||||
# user_langchain_project: User's ORIGINAL LANGSMITH_PROJECT (before override)
|
||||
# Note: LANGSMITH_PROJECT was already overridden at module import time (above)
|
||||
# so we use the saved original value, not the current os.environ value
|
||||
deepagents_langchain_project = os.environ.get("DEEPAGENTS_LANGSMITH_PROJECT")
|
||||
user_langchain_project = _original_langsmith_project # Use saved original!
|
||||
|
||||
# Detect project
|
||||
project_root = _find_project_root(start_path)
|
||||
|
||||
return cls(
|
||||
openai_api_key=openai_key,
|
||||
anthropic_api_key=anthropic_key,
|
||||
google_api_key=google_key,
|
||||
tavily_api_key=tavily_key,
|
||||
deepagents_langchain_project=deepagents_langchain_project,
|
||||
user_langchain_project=user_langchain_project,
|
||||
project_root=project_root,
|
||||
)
|
||||
|
||||
@property
|
||||
def has_openai(self) -> bool:
|
||||
"""OpenAI API 키가 구성되어 있는지 확인합니다."""
|
||||
return self.openai_api_key is not None
|
||||
|
||||
@property
|
||||
def has_anthropic(self) -> bool:
|
||||
"""Anthropic API 키가 구성되어 있는지 확인합니다."""
|
||||
return self.anthropic_api_key is not None
|
||||
|
||||
@property
|
||||
def has_google(self) -> bool:
|
||||
"""Google API 키가 구성되어 있는지 확인합니다."""
|
||||
return self.google_api_key is not None
|
||||
|
||||
@property
|
||||
def has_tavily(self) -> bool:
|
||||
"""Tavily API 키가 구성되어 있는지 확인합니다."""
|
||||
return self.tavily_api_key is not None
|
||||
|
||||
@property
|
||||
def has_deepagents_langchain_project(self) -> bool:
|
||||
"""DeepAgents LangChain 프로젝트 이름이 구성되어 있는지 확인합니다."""
|
||||
return self.deepagents_langchain_project is not None
|
||||
|
||||
@property
|
||||
def has_project(self) -> bool:
|
||||
"""현재 git 프로젝트 내에 있는지 확인합니다."""
|
||||
return self.project_root is not None
|
||||
|
||||
@property
|
||||
def user_deepagents_dir(self) -> Path:
|
||||
"""기본 사용자 수준 .deepagents 디렉터리를 가져옵니다.
|
||||
|
||||
Returns:
|
||||
~/.deepagents 경로
|
||||
"""
|
||||
return Path.home() / ".deepagents"
|
||||
|
||||
def get_user_agent_md_path(self, agent_name: str) -> Path:
|
||||
"""특정 에이전트에 대한 사용자 수준 agent.md 경로를 가져옵니다.
|
||||
|
||||
파일 존재 여부와 상관없이 경로를 반환합니다.
|
||||
|
||||
Args:
|
||||
agent_name: 에이전트 이름
|
||||
|
||||
Returns:
|
||||
~/.deepagents/{agent_name}/agent.md 경로
|
||||
"""
|
||||
return Path.home() / ".deepagents" / agent_name / "agent.md"
|
||||
|
||||
def get_project_agent_md_path(self) -> Path | None:
|
||||
"""프로젝트 수준 agent.md 경로를 가져옵니다.
|
||||
|
||||
파일 존재 여부와 상관없이 경로를 반환합니다.
|
||||
|
||||
Returns:
|
||||
{project_root}/.deepagents/agent.md 경로, 프로젝트 내에 없는 경우 None
|
||||
"""
|
||||
if not self.project_root:
|
||||
return None
|
||||
return self.project_root / ".deepagents" / "agent.md"
|
||||
|
||||
@staticmethod
|
||||
def _is_valid_agent_name(agent_name: str) -> bool:
|
||||
"""유효하지 않은 파일시스템 경로 및 보안 문제를 방지하기 위해 검증합니다."""
|
||||
if not agent_name or not agent_name.strip():
|
||||
return False
|
||||
# Allow only alphanumeric, hyphens, underscores, and whitespace
|
||||
return bool(re.match(r"^[a-zA-Z0-9_\-\s]+$", agent_name))
|
||||
|
||||
def get_agent_dir(self, agent_name: str) -> Path:
|
||||
"""전역 에이전트 디렉터리 경로를 가져옵니다.
|
||||
|
||||
Args:
|
||||
agent_name: 에이전트 이름
|
||||
|
||||
Returns:
|
||||
~/.deepagents/{agent_name} 경로
|
||||
"""
|
||||
if not self._is_valid_agent_name(agent_name):
|
||||
msg = (
|
||||
f"Invalid agent name: {agent_name!r}. "
|
||||
"Agent names can only contain letters, numbers, hyphens, underscores, and spaces."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return Path.home() / ".deepagents" / agent_name
|
||||
|
||||
def ensure_agent_dir(self, agent_name: str) -> Path:
|
||||
"""전역 에이전트 디렉터리가 존재하는지 확인하고 경로를 반환합니다.
|
||||
|
||||
Args:
|
||||
agent_name: 에이전트 이름
|
||||
|
||||
Returns:
|
||||
~/.deepagents/{agent_name} 경로
|
||||
"""
|
||||
if not self._is_valid_agent_name(agent_name):
|
||||
msg = (
|
||||
f"Invalid agent name: {agent_name!r}. "
|
||||
"Agent names can only contain letters, numbers, hyphens, underscores, and spaces."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
agent_dir = self.get_agent_dir(agent_name)
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
return agent_dir
|
||||
|
||||
def ensure_project_deepagents_dir(self) -> Path | None:
|
||||
"""프로젝트 .deepagents 디렉터리가 존재하는지 확인하고 경로를 반환합니다.
|
||||
|
||||
Returns:
|
||||
프로젝트 .deepagents 디렉터리 경로, 프로젝트 내에 없는 경우 None
|
||||
"""
|
||||
if not self.project_root:
|
||||
return None
|
||||
|
||||
project_deepagents_dir = self.project_root / ".deepagents"
|
||||
project_deepagents_dir.mkdir(parents=True, exist_ok=True)
|
||||
return project_deepagents_dir
|
||||
|
||||
def get_user_skills_dir(self, agent_name: str) -> Path:
|
||||
"""특정 에이전트에 대한 사용자 수준 기술(skills) 디렉터리 경로를 가져옵니다.
|
||||
|
||||
Args:
|
||||
agent_name: 에이전트 이름
|
||||
|
||||
Returns:
|
||||
~/.deepagents/{agent_name}/skills/ 경로
|
||||
"""
|
||||
return self.get_agent_dir(agent_name) / "skills"
|
||||
|
||||
def ensure_user_skills_dir(self, agent_name: str) -> Path:
|
||||
"""사용자 수준 기술(skills) 디렉터리가 존재하는지 확인하고 경로를 반환합니다.
|
||||
|
||||
Args:
|
||||
agent_name: 에이전트 이름
|
||||
|
||||
Returns:
|
||||
~/.deepagents/{agent_name}/skills/ 경로
|
||||
"""
|
||||
skills_dir = self.get_user_skills_dir(agent_name)
|
||||
skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
return skills_dir
|
||||
|
||||
def get_project_skills_dir(self) -> Path | None:
|
||||
"""프로젝트 수준 기술(skills) 디렉터리 경로를 가져옵니다.
|
||||
|
||||
Returns:
|
||||
{project_root}/.deepagents/skills/ 경로, 프로젝트 내에 없는 경우 None
|
||||
"""
|
||||
if not self.project_root:
|
||||
return None
|
||||
return self.project_root / ".deepagents" / "skills"
|
||||
|
||||
def ensure_project_skills_dir(self) -> Path | None:
|
||||
"""프로젝트 수준 기술(skills) 디렉터리가 존재하는지 확인하고 경로를 반환합니다.
|
||||
|
||||
Returns:
|
||||
{project_root}/.deepagents/skills/ 경로, 프로젝트 내에 없는 경우 None
|
||||
"""
|
||||
if not self.project_root:
|
||||
return None
|
||||
skills_dir = self.get_project_skills_dir()
|
||||
skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
return skills_dir
|
||||
|
||||
|
||||
# Global settings instance (initialized once)
|
||||
settings = Settings.from_environment()
|
||||
|
||||
|
||||
class SessionState:
|
||||
"""변경 가능한 세션 상태를 유지합니다 (자동 승인 모드 등)."""
|
||||
|
||||
def __init__(self, auto_approve: bool = False, no_splash: bool = False) -> None:
|
||||
self.auto_approve = auto_approve
|
||||
self.no_splash = no_splash
|
||||
self.exit_hint_until: float | None = None
|
||||
self.exit_hint_handle = None
|
||||
self.thread_id = str(uuid.uuid4())
|
||||
|
||||
def toggle_auto_approve(self) -> bool:
|
||||
"""자동 승인을 토글하고 새로운 상태를 반환합니다."""
|
||||
self.auto_approve = not self.auto_approve
|
||||
return self.auto_approve
|
||||
|
||||
|
||||
def get_default_coding_instructions() -> str:
|
||||
"""기본 코딩 에이전트 지침을 가져옵니다.
|
||||
|
||||
이는 에이전트가 수정할 수 없는 불변의 기본 지침입니다.
|
||||
장기 메모리(agent.md)는 미들웨어에서 별도로 처리합니다.
|
||||
"""
|
||||
default_prompt_path = Path(__file__).parent / "default_agent_prompt.md"
|
||||
return default_prompt_path.read_text()
|
||||
|
||||
|
||||
def _detect_provider(model_name: str) -> str | None:
|
||||
"""모델 이름에서 공급자를 자동 감지합니다.
|
||||
|
||||
Args:
|
||||
model_name: 공급자를 감지할 모델 이름
|
||||
|
||||
Returns:
|
||||
공급자 이름(openai, anthropic, google) 또는 감지할 수 없는 경우 None
|
||||
"""
|
||||
model_lower = model_name.lower()
|
||||
if any(x in model_lower for x in ["gpt", "o1", "o3"]):
|
||||
return "openai"
|
||||
if "claude" in model_lower:
|
||||
return "anthropic"
|
||||
if "gemini" in model_lower:
|
||||
return "google"
|
||||
return None
|
||||
|
||||
|
||||
def create_model(model_name_override: str | None = None) -> BaseChatModel:
|
||||
"""사용 가능한 API 키를 기반으로 적절한 모델을 생성합니다.
|
||||
|
||||
전역 설정 인스턴스를 사용하여 생성할 모델을 결정합니다.
|
||||
|
||||
Args:
|
||||
model_name_override: 환경 변수 대신 사용할 선택적 모델 이름
|
||||
|
||||
Returns:
|
||||
ChatModel 인스턴스 (OpenAI, Anthropic, 또는 Google)
|
||||
|
||||
Raises:
|
||||
API 키가 구성되지 않았거나 모델 공급자를 결정할 수 없는 경우 SystemExit
|
||||
"""
|
||||
# Determine provider and model
|
||||
if model_name_override:
|
||||
# Use provided model, auto-detect provider
|
||||
provider = _detect_provider(model_name_override)
|
||||
if not provider:
|
||||
console.print(
|
||||
f"[bold red]오류:[/bold red] 모델 이름에서 공급자를 감지할 수 없습니다: {model_name_override}"
|
||||
)
|
||||
console.print("\n지원되는 모델 이름 패턴:")
|
||||
console.print(" - OpenAI: gpt-*, o1-*, o3-*")
|
||||
console.print(" - Anthropic: claude-*")
|
||||
console.print(" - Google: gemini-*")
|
||||
sys.exit(1)
|
||||
|
||||
# Check if API key for detected provider is available
|
||||
if provider == "openai" and not settings.has_openai:
|
||||
console.print(f"[bold red]오류:[/bold red] 모델 '{model_name_override}'은(는) OPENAI_API_KEY가 필요합니다")
|
||||
sys.exit(1)
|
||||
elif provider == "anthropic" and not settings.has_anthropic:
|
||||
console.print(
|
||||
f"[bold red]오류:[/bold red] 모델 '{model_name_override}'은(는) ANTHROPIC_API_KEY가 필요합니다"
|
||||
)
|
||||
sys.exit(1)
|
||||
elif provider == "google" and not settings.has_google:
|
||||
console.print(f"[bold red]오류:[/bold red] 모델 '{model_name_override}'은(는) GOOGLE_API_KEY가 필요합니다")
|
||||
sys.exit(1)
|
||||
|
||||
model_name = model_name_override
|
||||
# Use environment variable defaults, detect provider by API key priority
|
||||
elif settings.has_openai:
|
||||
provider = "openai"
|
||||
model_name = os.environ.get("OPENAI_MODEL", "gpt-5-mini")
|
||||
elif settings.has_anthropic:
|
||||
provider = "anthropic"
|
||||
model_name = os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-5-20250929")
|
||||
elif settings.has_google:
|
||||
provider = "google"
|
||||
model_name = os.environ.get("GOOGLE_MODEL", "gemini-3-pro-preview")
|
||||
else:
|
||||
console.print("[bold red]오류:[/bold red] API 키가 구성되지 않았습니다.")
|
||||
console.print("\n다음 환경 변수 중 하나를 설정하십시오:")
|
||||
console.print(" - OPENAI_API_KEY (OpenAI 모델용, 예: gpt-5-mini)")
|
||||
console.print(" - ANTHROPIC_API_KEY (Claude 모델용)")
|
||||
console.print(" - GOOGLE_API_KEY (Google Gemini 모델용)")
|
||||
console.print("\n예시:")
|
||||
console.print(" export OPENAI_API_KEY=your_api_key_here")
|
||||
console.print("\n또는 .env 파일에 추가하십시오.")
|
||||
sys.exit(1)
|
||||
|
||||
# Store model info in settings for display
|
||||
settings.model_name = model_name
|
||||
settings.model_provider = provider
|
||||
|
||||
# Create and return the model
|
||||
if provider == "openai":
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
return ChatOpenAI(model=model_name)
|
||||
if provider == "anthropic":
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
|
||||
return ChatAnthropic(
|
||||
model_name=model_name,
|
||||
max_tokens=20_000, # type: ignore[arg-type]
|
||||
)
|
||||
if provider == "google":
|
||||
from langchain_google_genai import ChatGoogleGenerativeAI
|
||||
|
||||
return ChatGoogleGenerativeAI(
|
||||
model=model_name,
|
||||
temperature=0,
|
||||
max_tokens=None,
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
You are an AI assistant that helps users with various tasks such as coding, research, and analysis.
|
||||
|
||||
# Core Role
|
||||
Your core role and behavior can be updated based on user feedback and instructions. If the user instructs you on how to behave or about your role, immediately update this memory file to reflect those instructions.
|
||||
|
||||
## Memory-First Protocol
|
||||
You have access to a persistent memory system. Always follow this protocol:
|
||||
|
||||
**At the start of a session:**
|
||||
- Check `ls /memories/` to see what knowledge is stored.
|
||||
- If a specific topic is mentioned in the role description, check related guides in `/memories/`.
|
||||
|
||||
**Before answering a question:**
|
||||
- When asked "What do you know about X?" or "How do I do Y?" → Check `ls /memories/` first.
|
||||
- If a relevant memory file exists → Read it and answer based on the saved knowledge.
|
||||
- Prioritize stored knowledge over general knowledge.
|
||||
|
||||
**When learning new information:**
|
||||
- If the user teaches you something or asks you to remember something → Save it to `/memories/[topic].md`.
|
||||
- Use descriptive filenames: Use `/memories/deep-agents-guide.md` instead of `/memories/notes.md`.
|
||||
- After saving, read specific content again to verify.
|
||||
|
||||
**Important:** Your memory persists between sessions. Information stored in `/memories/` is more reliable than general knowledge for topics you have specifically learned.
|
||||
|
||||
# Tone and Style
|
||||
Be concise and direct. Answer within 4 lines unless the user asks for details.
|
||||
Stop after finishing file operations - Do not explain what you did unless asked.
|
||||
Avoid unnecessary introductions or conclusions.
|
||||
|
||||
When executing unimportant bash commands, briefly explain what you are doing.
|
||||
|
||||
## Proactiveness
|
||||
Take action when requested, but do not surprise the user with unrequested actions.
|
||||
If asked about an approach, answer first before taking action.
|
||||
|
||||
## Following Conventions
|
||||
- Check existing code before assuming the availability of libraries and frameworks.
|
||||
- Mimic existing code style, naming conventions, and patterns.
|
||||
- Do not add comments unless requested.
|
||||
|
||||
## Task Management
|
||||
Use `write_todos` for complex multi-step tasks (3 or more steps). Mark tasks as `in_progress` before starting, and `completed` immediately after finishing.
|
||||
Perform simple 1-2 step tasks immediately without todos.
|
||||
|
||||
## File Reading Best Practices
|
||||
|
||||
**Important**: When navigating the codebase or reading multiple files, always use pagination to prevent context overflow.
|
||||
|
||||
**Codebase Navigation Patterns:**
|
||||
1. First Scan: `read_file(path, limit=100)` - Check file structure and key sections
|
||||
2. Targeted Reading: `read_file(path, offset=100, limit=200)` - Read specific sections if needed
|
||||
3. Full Reading: Use `read_file(path)` without limits only when needed for editing
|
||||
|
||||
**When to use pagination:**
|
||||
- Reading any file exceeding 500 lines
|
||||
- Exploring unfamiliar codebases (Always start with limit=100)
|
||||
- Reading multiple files in succession
|
||||
- All research or investigation tasks
|
||||
|
||||
**When full reading is allowed:**
|
||||
- Small files (under 500 lines)
|
||||
- Files required to be edited immediately after reading
|
||||
- After verifying file size with a first scan
|
||||
|
||||
**Workflow Example:**
|
||||
```
|
||||
Bad: read_file(/src/large_module.py) # Fills context with 2000+ lines of code
|
||||
Good: read_file(/src/large_module.py, limit=100) # Scan structure first
|
||||
read_file(/src/large_module.py, offset=100, limit=100) # Read relevant section
|
||||
```
|
||||
|
||||
## Working with Subagents (Task Tools)
|
||||
When delegating to subagents:
|
||||
- **Use Filesystem for Large I/O**: If input instructions are large (500+ words) or expected output is large, communicate via files.
|
||||
- Write input context/instructions to a file, and instruct the subagent to read it.
|
||||
- Ask the subagent to write output to a file, and read it after the subagent returns.
|
||||
- This prevents token bloat in both directions and keeps context manageable.
|
||||
- **Parallelize Independent Tasks**: When tasks are independent, create parallel subagents to work simultaneously.
|
||||
- **Clear Specifications**: Precisely inform the subagent of the required format/structure in their response or output file.
|
||||
- **Main Agent Synthesis**: Once subagents collect/execute, the main agent integrates results into the final output.
|
||||
|
||||
## Tools
|
||||
|
||||
### execute_bash
|
||||
Executes shell commands. Always allow path with spaces to be quoted.
|
||||
bash commands are executed in the current working directory.
|
||||
Example: `pytest /foo/bar/tests` (Good), `cd /foo/bar && pytest tests` (Bad)
|
||||
|
||||
### File Tools
|
||||
- read_file: Read file content (use absolute path)
|
||||
- edit_file: Exact string replacement in file (must read first, provide unique old_string)
|
||||
- write_file: Create or overwrite file
|
||||
- ls: List directory contents
|
||||
- glob: Find files by pattern (e.g., "**/*.py")
|
||||
- grep: Search file content
|
||||
|
||||
Always use absolute paths starting with /.
|
||||
|
||||
### web_search
|
||||
Search for documentation, error solutions, and code examples.
|
||||
|
||||
### http_request
|
||||
Sends HTTP requests to an API (GET, POST, etc.).
|
||||
|
||||
## Code References
|
||||
When referencing code, use the following format: `file_path:line_number`
|
||||
|
||||
## Documentation
|
||||
- Do not create excessive markdown summary/documentation files after completing tasks.
|
||||
- Focus on the task itself, not documenting what you did.
|
||||
- Write documentation only when explicitly requested.
|
||||
@@ -0,0 +1,672 @@
|
||||
"""CLI를 위한 작업 실행 및 스트리밍 로직."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import termios
|
||||
import tty
|
||||
|
||||
from langchain.agents.middleware.human_in_the_loop import (
|
||||
ActionRequest,
|
||||
ApproveDecision,
|
||||
Decision,
|
||||
HITLRequest,
|
||||
HITLResponse,
|
||||
RejectDecision,
|
||||
)
|
||||
from langchain_core.messages import HumanMessage, ToolMessage
|
||||
from langgraph.types import Command, Interrupt
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from rich import box
|
||||
from rich.markdown import Markdown
|
||||
from rich.panel import Panel
|
||||
|
||||
from deepagents_cli.config import COLORS, console
|
||||
from deepagents_cli.file_ops import FileOpTracker, build_approval_preview
|
||||
from deepagents_cli.image_utils import create_multimodal_content
|
||||
from deepagents_cli.input import ImageTracker, parse_file_mentions
|
||||
from deepagents_cli.ui import (
|
||||
TokenTracker,
|
||||
format_tool_display,
|
||||
format_tool_message_content,
|
||||
render_diff_block,
|
||||
render_file_operation,
|
||||
render_todo_list,
|
||||
)
|
||||
|
||||
_HITL_REQUEST_ADAPTER = TypeAdapter(HITLRequest)
|
||||
|
||||
|
||||
def prompt_for_tool_approval(
|
||||
action_request: ActionRequest,
|
||||
assistant_id: str | None,
|
||||
) -> Decision | dict:
|
||||
"""방향키 탐색을 사용하여 도구 작업을 승인/거부하도록 사용자에게 묻습니다.
|
||||
|
||||
Returns:
|
||||
Decision (ApproveDecision 또는 RejectDecision) 또는
|
||||
자동 승인 모드로 전환하기 위한 {"type": "auto_approve_all"} dict
|
||||
"""
|
||||
description = action_request.get("description", "No description available")
|
||||
name = action_request["name"]
|
||||
args = action_request["args"]
|
||||
preview = build_approval_preview(name, args, assistant_id) if name else None
|
||||
|
||||
body_lines = []
|
||||
if preview:
|
||||
body_lines.append(f"[bold]{preview.title}[/bold]")
|
||||
body_lines.extend(preview.details)
|
||||
if preview.error:
|
||||
body_lines.append(f"[red]{preview.error}[/red]")
|
||||
else:
|
||||
body_lines.append(description)
|
||||
|
||||
# Display action info first
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold yellow]⚠️ 도구 작업 승인 필요[/bold yellow]\n\n" + "\n".join(body_lines),
|
||||
border_style="yellow",
|
||||
box=box.ROUNDED,
|
||||
padding=(0, 1),
|
||||
)
|
||||
)
|
||||
if preview and preview.diff and not preview.error:
|
||||
console.print()
|
||||
render_diff_block(preview.diff, preview.diff_title or preview.title)
|
||||
|
||||
options = ["approve", "reject", "auto-accept all going forward"]
|
||||
selected = 0 # Start with approve selected
|
||||
|
||||
try:
|
||||
fd = sys.stdin.fileno()
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
|
||||
try:
|
||||
tty.setraw(fd)
|
||||
# Hide cursor during menu interaction
|
||||
sys.stdout.write("\033[?25l")
|
||||
sys.stdout.flush()
|
||||
|
||||
# Initial render flag
|
||||
first_render = True
|
||||
|
||||
while True:
|
||||
if not first_render:
|
||||
# Move cursor back to start of menu (up 3 lines, then to start of line)
|
||||
sys.stdout.write("\033[3A\r")
|
||||
|
||||
first_render = False
|
||||
|
||||
# Display options vertically with ANSI color codes
|
||||
for i, option in enumerate(options):
|
||||
sys.stdout.write("\r\033[K") # Clear line from cursor to end
|
||||
|
||||
if i == selected:
|
||||
if option == "approve":
|
||||
# Green bold with filled checkbox
|
||||
sys.stdout.write("\033[1;32m☑ 승인 (Approve)\033[0m\n")
|
||||
elif option == "reject":
|
||||
# Red bold with filled checkbox
|
||||
sys.stdout.write("\033[1;31m☑ 거부 (Reject)\033[0m\n")
|
||||
else:
|
||||
# Blue bold with filled checkbox for auto-accept
|
||||
sys.stdout.write("\033[1;34m☑ 이후 모두 자동 승인 (Auto-accept all)\033[0m\n")
|
||||
elif option == "approve":
|
||||
# Dim with empty checkbox
|
||||
sys.stdout.write("\033[2m☐ 승인 (Approve)\033[0m\n")
|
||||
elif option == "reject":
|
||||
# Dim with empty checkbox
|
||||
sys.stdout.write("\033[2m☐ 거부 (Reject)\033[0m\n")
|
||||
else:
|
||||
# Dim with empty checkbox
|
||||
sys.stdout.write("\033[2m☐ 이후 모두 자동 승인 (Auto-accept all)\033[0m\n")
|
||||
|
||||
sys.stdout.flush()
|
||||
|
||||
# Read key
|
||||
char = sys.stdin.read(1)
|
||||
|
||||
if char == "\x1b": # ESC sequence (arrow keys)
|
||||
next1 = sys.stdin.read(1)
|
||||
next2 = sys.stdin.read(1)
|
||||
if next1 == "[":
|
||||
if next2 == "B": # Down arrow
|
||||
selected = (selected + 1) % len(options)
|
||||
elif next2 == "A": # Up arrow
|
||||
selected = (selected - 1) % len(options)
|
||||
elif char in {"\r", "\n"}: # Enter
|
||||
sys.stdout.write("\r\n") # Move to start of line and add newline
|
||||
break
|
||||
elif char == "\x03": # Ctrl+C
|
||||
sys.stdout.write("\r\n") # Move to start of line and add newline
|
||||
raise KeyboardInterrupt
|
||||
elif char.lower() == "a":
|
||||
selected = 0
|
||||
sys.stdout.write("\r\n") # Move to start of line and add newline
|
||||
break
|
||||
elif char.lower() == "r":
|
||||
selected = 1
|
||||
sys.stdout.write("\r\n") # Move to start of line and add newline
|
||||
break
|
||||
|
||||
finally:
|
||||
# Show cursor again
|
||||
sys.stdout.write("\033[?25h")
|
||||
sys.stdout.flush()
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
|
||||
except (termios.error, AttributeError):
|
||||
# Fallback for non-Unix systems
|
||||
console.print(" ☐ (A)승인 (기본값)")
|
||||
console.print(" ☐ (R)거부")
|
||||
console.print(" ☐ (Auto)이후 모두 자동 승인")
|
||||
choice = input("\n선택 (A/R/Auto, 기본값=Approve): ").strip().lower()
|
||||
if choice in {"r", "reject"}:
|
||||
selected = 1
|
||||
elif choice in {"auto", "auto-accept"}:
|
||||
selected = 2
|
||||
else:
|
||||
selected = 0
|
||||
|
||||
# Return decision based on selection
|
||||
if selected == 0:
|
||||
return ApproveDecision(type="approve")
|
||||
if selected == 1:
|
||||
return RejectDecision(type="reject", message="User rejected the command")
|
||||
# Return special marker for auto-approve mode
|
||||
return {"type": "auto_approve_all"}
|
||||
|
||||
|
||||
async def execute_task(
|
||||
user_input: str,
|
||||
agent,
|
||||
assistant_id: str | None,
|
||||
session_state,
|
||||
token_tracker: TokenTracker | None = None,
|
||||
backend=None,
|
||||
image_tracker: ImageTracker | None = None,
|
||||
) -> None:
|
||||
"""모든 작업을 AI 에이전트에게 직접 전달하여 실행합니다."""
|
||||
# Parse file mentions and inject content if any
|
||||
prompt_text, mentioned_files = parse_file_mentions(user_input)
|
||||
|
||||
if mentioned_files:
|
||||
context_parts = [prompt_text, "\n\n## 참조된 파일 (Referenced Files)\n"]
|
||||
for file_path in mentioned_files:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
# Limit file content to reasonable size
|
||||
if len(content) > 50000:
|
||||
content = content[:50000] + "\n... (파일 잘림)"
|
||||
context_parts.append(f"\n### {file_path.name}\nPath: `{file_path}`\n```\n{content}\n```")
|
||||
except Exception as e:
|
||||
context_parts.append(f"\n### {file_path.name}\n[파일 읽기 오류: {e}]")
|
||||
|
||||
final_input = "\n".join(context_parts)
|
||||
else:
|
||||
final_input = prompt_text
|
||||
|
||||
# Include images in the message content
|
||||
images_to_send = []
|
||||
if image_tracker:
|
||||
images_to_send = image_tracker.get_images()
|
||||
if images_to_send:
|
||||
message_content = create_multimodal_content(final_input, images_to_send)
|
||||
else:
|
||||
message_content = final_input
|
||||
|
||||
config = {
|
||||
"configurable": {"thread_id": session_state.thread_id},
|
||||
"metadata": {"assistant_id": assistant_id} if assistant_id else {},
|
||||
}
|
||||
|
||||
has_responded = False
|
||||
captured_input_tokens = 0
|
||||
captured_output_tokens = 0
|
||||
current_todos = None # Track current todo list state
|
||||
|
||||
status = console.status(f"[bold {COLORS['thinking']}]에이전트가 생각 중...", spinner="dots")
|
||||
status.start()
|
||||
spinner_active = True
|
||||
|
||||
tool_icons = {
|
||||
"read_file": "📖",
|
||||
"write_file": "✏️",
|
||||
"edit_file": "✂️",
|
||||
"ls": "📁",
|
||||
"glob": "🔍",
|
||||
"grep": "🔎",
|
||||
"shell": "⚡",
|
||||
"execute": "🔧",
|
||||
"web_search": "🌐",
|
||||
"http_request": "🌍",
|
||||
"task": "🤖",
|
||||
"write_todos": "📋",
|
||||
}
|
||||
|
||||
file_op_tracker = FileOpTracker(assistant_id=assistant_id, backend=backend)
|
||||
|
||||
# Track which tool calls we've displayed to avoid duplicates
|
||||
displayed_tool_ids = set()
|
||||
# Buffer partial tool-call chunks keyed by streaming index
|
||||
tool_call_buffers: dict[str | int, dict] = {}
|
||||
# Buffer assistant text so we can render complete markdown segments
|
||||
pending_text = ""
|
||||
|
||||
def flush_text_buffer(*, final: bool = False) -> None:
|
||||
"""Flush accumulated assistant text as rendered markdown when appropriate."""
|
||||
nonlocal pending_text, spinner_active, has_responded
|
||||
if not final or not pending_text.strip():
|
||||
return
|
||||
if spinner_active:
|
||||
status.stop()
|
||||
spinner_active = False
|
||||
if not has_responded:
|
||||
console.print("●", style=COLORS["agent"], markup=False, end=" ")
|
||||
has_responded = True
|
||||
markdown = Markdown(pending_text.rstrip())
|
||||
console.print(markdown, style=COLORS["agent"])
|
||||
pending_text = ""
|
||||
|
||||
# Clear images from tracker after creating the message
|
||||
# (they've been encoded into the message content)
|
||||
if image_tracker:
|
||||
image_tracker.clear()
|
||||
|
||||
# Stream input - may need to loop if there are interrupts
|
||||
stream_input = {"messages": [{"role": "user", "content": message_content}]}
|
||||
|
||||
try:
|
||||
while True:
|
||||
interrupt_occurred = False
|
||||
hitl_response: dict[str, HITLResponse] = {}
|
||||
suppress_resumed_output = False
|
||||
# Track all pending interrupts: {interrupt_id: request_data}
|
||||
pending_interrupts: dict[str, HITLRequest] = {}
|
||||
|
||||
async for chunk in agent.astream(
|
||||
stream_input,
|
||||
stream_mode=["messages", "updates"], # Dual-mode for HITL support
|
||||
subgraphs=True,
|
||||
config=config,
|
||||
durability="exit",
|
||||
):
|
||||
# Unpack chunk - with subgraphs=True and dual-mode, it's (namespace, stream_mode, data)
|
||||
if not isinstance(chunk, tuple) or len(chunk) != 3:
|
||||
continue
|
||||
|
||||
_namespace, current_stream_mode, data = chunk
|
||||
|
||||
# Handle UPDATES stream - for interrupts and todos
|
||||
if current_stream_mode == "updates":
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
|
||||
# Check for interrupts - collect ALL pending interrupts
|
||||
if "__interrupt__" in data:
|
||||
interrupts: list[Interrupt] = data["__interrupt__"]
|
||||
if interrupts:
|
||||
for interrupt_obj in interrupts:
|
||||
# Interrupt has required fields: value (HITLRequest) and id (str)
|
||||
# Validate the HITLRequest using TypeAdapter
|
||||
try:
|
||||
validated_request = _HITL_REQUEST_ADAPTER.validate_python(interrupt_obj.value)
|
||||
pending_interrupts[interrupt_obj.id] = validated_request
|
||||
interrupt_occurred = True
|
||||
except ValidationError as e:
|
||||
console.print(
|
||||
f"[yellow]경고: 유효하지 않은 HITL 요청 데이터: {e}[/yellow]",
|
||||
style="dim",
|
||||
)
|
||||
raise
|
||||
|
||||
# Extract chunk_data from updates for todo checking
|
||||
chunk_data = next(iter(data.values())) if data else None
|
||||
if chunk_data and isinstance(chunk_data, dict):
|
||||
# Check for todo updates
|
||||
if "todos" in chunk_data:
|
||||
new_todos = chunk_data["todos"]
|
||||
if new_todos != current_todos:
|
||||
current_todos = new_todos
|
||||
# Stop spinner before rendering todos
|
||||
if spinner_active:
|
||||
status.stop()
|
||||
spinner_active = False
|
||||
console.print()
|
||||
render_todo_list(new_todos)
|
||||
console.print()
|
||||
|
||||
# Handle MESSAGES stream - for content and tool calls
|
||||
elif current_stream_mode == "messages":
|
||||
# Messages stream returns (message, metadata) tuples
|
||||
if not isinstance(data, tuple) or len(data) != 2:
|
||||
continue
|
||||
|
||||
message, _metadata = data
|
||||
|
||||
if isinstance(message, HumanMessage):
|
||||
content = message.text
|
||||
if content:
|
||||
flush_text_buffer(final=True)
|
||||
if spinner_active:
|
||||
status.stop()
|
||||
spinner_active = False
|
||||
if not has_responded:
|
||||
console.print("●", style=COLORS["agent"], markup=False, end=" ")
|
||||
has_responded = True
|
||||
markdown = Markdown(content)
|
||||
console.print(markdown, style=COLORS["agent"])
|
||||
console.print()
|
||||
continue
|
||||
|
||||
if isinstance(message, ToolMessage):
|
||||
# Tool results are sent to the agent, not displayed to users
|
||||
# Exception: show shell command errors to help with debugging
|
||||
tool_name = getattr(message, "name", "")
|
||||
tool_status = getattr(message, "status", "success")
|
||||
tool_content = format_tool_message_content(message.content)
|
||||
record = file_op_tracker.complete_with_message(message)
|
||||
|
||||
# Reset spinner message after tool completes
|
||||
if spinner_active:
|
||||
status.update(f"[bold {COLORS['thinking']}]에이전트가 생각 중...")
|
||||
|
||||
if tool_name == "shell" and tool_status != "success":
|
||||
flush_text_buffer(final=True)
|
||||
if tool_content:
|
||||
if spinner_active:
|
||||
status.stop()
|
||||
spinner_active = False
|
||||
console.print()
|
||||
console.print(tool_content, style="red", markup=False)
|
||||
console.print()
|
||||
elif tool_content and isinstance(tool_content, str):
|
||||
stripped = tool_content.lstrip()
|
||||
if stripped.lower().startswith("error"):
|
||||
flush_text_buffer(final=True)
|
||||
if spinner_active:
|
||||
status.stop()
|
||||
spinner_active = False
|
||||
console.print()
|
||||
console.print(tool_content, style="red", markup=False)
|
||||
console.print()
|
||||
|
||||
if record:
|
||||
flush_text_buffer(final=True)
|
||||
if spinner_active:
|
||||
status.stop()
|
||||
spinner_active = False
|
||||
console.print()
|
||||
render_file_operation(record)
|
||||
console.print()
|
||||
if not spinner_active:
|
||||
status.start()
|
||||
spinner_active = True
|
||||
|
||||
# For all other tools (web_search, http_request, etc.),
|
||||
# results are hidden from user - agent will process and respond
|
||||
continue
|
||||
|
||||
# Check if this is an AIMessageChunk
|
||||
if not hasattr(message, "content_blocks"):
|
||||
# Fallback for messages without content_blocks
|
||||
continue
|
||||
|
||||
# Extract token usage if available
|
||||
if token_tracker and hasattr(message, "usage_metadata"):
|
||||
usage = message.usage_metadata
|
||||
if usage:
|
||||
input_toks = usage.get("input_tokens", 0)
|
||||
output_toks = usage.get("output_tokens", 0)
|
||||
if input_toks or output_toks:
|
||||
captured_input_tokens = max(captured_input_tokens, input_toks)
|
||||
captured_output_tokens = max(captured_output_tokens, output_toks)
|
||||
|
||||
# Process content blocks (this is the key fix!)
|
||||
for block in message.content_blocks:
|
||||
block_type = block.get("type")
|
||||
|
||||
# Handle text blocks
|
||||
if block_type == "text":
|
||||
text = block.get("text", "")
|
||||
if text:
|
||||
pending_text += text
|
||||
|
||||
# Handle reasoning blocks
|
||||
elif block_type == "reasoning":
|
||||
flush_text_buffer(final=True)
|
||||
reasoning = block.get("reasoning", "")
|
||||
if reasoning and spinner_active:
|
||||
status.stop()
|
||||
spinner_active = False
|
||||
# Could display reasoning differently if desired
|
||||
# For now, skip it or handle minimally
|
||||
|
||||
# Handle tool call chunks
|
||||
# Some models (OpenAI, Anthropic) stream tool_call_chunks
|
||||
# Others (Gemini) don't stream them and just return the full tool_call
|
||||
elif block_type in ("tool_call_chunk", "tool_call"):
|
||||
chunk_name = block.get("name")
|
||||
chunk_args = block.get("args")
|
||||
chunk_id = block.get("id")
|
||||
chunk_index = block.get("index")
|
||||
|
||||
# Use index as stable buffer key; fall back to id if needed
|
||||
buffer_key: str | int
|
||||
if chunk_index is not None:
|
||||
buffer_key = chunk_index
|
||||
elif chunk_id is not None:
|
||||
buffer_key = chunk_id
|
||||
else:
|
||||
buffer_key = f"unknown-{len(tool_call_buffers)}"
|
||||
|
||||
buffer = tool_call_buffers.setdefault(
|
||||
buffer_key,
|
||||
{"name": None, "id": None, "args": None, "args_parts": []},
|
||||
)
|
||||
|
||||
if chunk_name:
|
||||
buffer["name"] = chunk_name
|
||||
if chunk_id:
|
||||
buffer["id"] = chunk_id
|
||||
|
||||
if isinstance(chunk_args, dict):
|
||||
buffer["args"] = chunk_args
|
||||
buffer["args_parts"] = []
|
||||
elif isinstance(chunk_args, str):
|
||||
if chunk_args:
|
||||
parts: list[str] = buffer.setdefault("args_parts", [])
|
||||
if not parts or chunk_args != parts[-1]:
|
||||
parts.append(chunk_args)
|
||||
buffer["args"] = "".join(parts)
|
||||
elif chunk_args is not None:
|
||||
buffer["args"] = chunk_args
|
||||
|
||||
buffer_name = buffer.get("name")
|
||||
buffer_id = buffer.get("id")
|
||||
if buffer_name is None:
|
||||
continue
|
||||
|
||||
parsed_args = buffer.get("args")
|
||||
if isinstance(parsed_args, str):
|
||||
if not parsed_args:
|
||||
continue
|
||||
try:
|
||||
parsed_args = json.loads(parsed_args)
|
||||
except json.JSONDecodeError:
|
||||
# Wait for more chunks to form valid JSON
|
||||
continue
|
||||
elif parsed_args is None:
|
||||
continue
|
||||
|
||||
# Ensure args are in dict form for formatter
|
||||
if not isinstance(parsed_args, dict):
|
||||
parsed_args = {"value": parsed_args}
|
||||
|
||||
flush_text_buffer(final=True)
|
||||
if buffer_id is not None:
|
||||
if buffer_id not in displayed_tool_ids:
|
||||
displayed_tool_ids.add(buffer_id)
|
||||
file_op_tracker.start_operation(buffer_name, parsed_args, buffer_id)
|
||||
else:
|
||||
file_op_tracker.update_args(buffer_id, parsed_args)
|
||||
tool_call_buffers.pop(buffer_key, None)
|
||||
icon = tool_icons.get(buffer_name, "🔧")
|
||||
|
||||
if spinner_active:
|
||||
status.stop()
|
||||
|
||||
if has_responded:
|
||||
console.print()
|
||||
|
||||
display_str = format_tool_display(buffer_name, parsed_args)
|
||||
console.print(
|
||||
f" {icon} {display_str}",
|
||||
style=f"dim {COLORS['tool']}",
|
||||
markup=False,
|
||||
)
|
||||
|
||||
# Restart spinner with context about which tool is executing
|
||||
status.update(f"[bold {COLORS['thinking']}]{display_str} 실행 중...")
|
||||
status.start()
|
||||
spinner_active = True
|
||||
|
||||
if getattr(message, "chunk_position", None) == "last":
|
||||
flush_text_buffer(final=True)
|
||||
|
||||
# After streaming loop - handle interrupt if it occurred
|
||||
flush_text_buffer(final=True)
|
||||
|
||||
# Handle human-in-the-loop after stream completes
|
||||
if interrupt_occurred:
|
||||
any_rejected = False
|
||||
|
||||
for interrupt_id, hitl_request in pending_interrupts.items():
|
||||
# Check if auto-approve is enabled
|
||||
if session_state.auto_approve:
|
||||
# Auto-approve all commands without prompting
|
||||
decisions = []
|
||||
for action_request in hitl_request["action_requests"]:
|
||||
# Show what's being auto-approved (brief, dim message)
|
||||
if spinner_active:
|
||||
status.stop()
|
||||
spinner_active = False
|
||||
|
||||
description = action_request.get("description", "tool action")
|
||||
console.print()
|
||||
console.print(f" [dim]⚡ {description}[/dim]")
|
||||
|
||||
decisions.append({"type": "approve"})
|
||||
|
||||
hitl_response[interrupt_id] = {"decisions": decisions}
|
||||
|
||||
# Restart spinner for continuation
|
||||
if not spinner_active:
|
||||
status.start()
|
||||
spinner_active = True
|
||||
else:
|
||||
# Normal HITL flow - stop spinner and prompt user
|
||||
if spinner_active:
|
||||
status.stop()
|
||||
spinner_active = False
|
||||
|
||||
# Handle human-in-the-loop approval
|
||||
decisions = []
|
||||
for action_index, action_request in enumerate(hitl_request["action_requests"]):
|
||||
decision = prompt_for_tool_approval(
|
||||
action_request,
|
||||
assistant_id,
|
||||
)
|
||||
|
||||
# Check if user wants to switch to auto-approve mode
|
||||
if isinstance(decision, dict) and decision.get("type") == "auto_approve_all":
|
||||
# Switch to auto-approve mode
|
||||
session_state.auto_approve = True
|
||||
console.print()
|
||||
console.print("[bold blue]✓ 자동 승인 모드 활성화됨[/bold blue]")
|
||||
console.print("[dim]향후 모든 도구 작업이 자동으로 승인됩니다.[/dim]")
|
||||
console.print()
|
||||
|
||||
# Approve this action and all remaining actions in the batch
|
||||
decisions.append({"type": "approve"})
|
||||
for _remaining_action in hitl_request["action_requests"][action_index + 1 :]:
|
||||
decisions.append({"type": "approve"})
|
||||
break
|
||||
decisions.append(decision)
|
||||
|
||||
# Mark file operations as HIL-approved if user approved
|
||||
if decision.get("type") == "approve":
|
||||
tool_name = action_request.get("name")
|
||||
if tool_name in {"write_file", "edit_file"}:
|
||||
file_op_tracker.mark_hitl_approved(tool_name, action_request.get("args", {}))
|
||||
|
||||
if any(decision.get("type") == "reject" for decision in decisions):
|
||||
any_rejected = True
|
||||
|
||||
hitl_response[interrupt_id] = {"decisions": decisions}
|
||||
|
||||
suppress_resumed_output = any_rejected
|
||||
|
||||
if interrupt_occurred and hitl_response:
|
||||
if suppress_resumed_output:
|
||||
if spinner_active:
|
||||
status.stop()
|
||||
spinner_active = False
|
||||
|
||||
console.print("[yellow]명령이 거부되었습니다.[/yellow]", style="bold")
|
||||
console.print("에이전트에게 다르게 수행할 작업을 알려주세요.")
|
||||
console.print()
|
||||
return
|
||||
|
||||
# Resume the agent with the human decision
|
||||
stream_input = Command(resume=hitl_response)
|
||||
# Continue the while loop to restream
|
||||
else:
|
||||
# No interrupt, break out of while loop
|
||||
break
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Event loop cancelled the task (e.g. Ctrl+C during streaming) - clean up and return
|
||||
if spinner_active:
|
||||
status.stop()
|
||||
console.print("\n[yellow]사용자에 의해 중단됨[/yellow]")
|
||||
console.print("에이전트 상태 업데이트 중...", style="dim")
|
||||
|
||||
try:
|
||||
await agent.aupdate_state(
|
||||
config=config,
|
||||
values={"messages": [HumanMessage(content="[이전 요청이 시스템에 의해 취소되었습니다]")]},
|
||||
)
|
||||
console.print("다음 명령 준비 완료.\n", style="dim")
|
||||
except Exception as e:
|
||||
console.print(f"[red]경고: 에이전트 상태 업데이트 실패: {e}[/red]\n")
|
||||
|
||||
return
|
||||
|
||||
except KeyboardInterrupt:
|
||||
# User pressed Ctrl+C - clean up and exit gracefully
|
||||
if spinner_active:
|
||||
status.stop()
|
||||
console.print("\n[yellow]사용자에 의해 중단됨[/yellow]")
|
||||
console.print("에이전트 상태 업데이트 중...", style="dim")
|
||||
|
||||
# Inform the agent synchronously (in async context)
|
||||
try:
|
||||
await agent.aupdate_state(
|
||||
config=config,
|
||||
values={"messages": [HumanMessage(content="[사용자가 Ctrl+C로 이전 요청을 중단했습니다]")]},
|
||||
)
|
||||
console.print("다음 명령 준비 완료.\n", style="dim")
|
||||
except Exception as e:
|
||||
console.print(f"[red]경고: 에이전트 상태 업데이트 실패: {e}[/red]\n")
|
||||
|
||||
return
|
||||
|
||||
if spinner_active:
|
||||
status.stop()
|
||||
|
||||
if has_responded:
|
||||
console.print()
|
||||
# Track token usage (display only via /tokens command)
|
||||
if token_tracker and (captured_input_tokens or captured_output_tokens):
|
||||
token_tracker.add(captured_input_tokens, captured_output_tokens)
|
||||
@@ -0,0 +1,408 @@
|
||||
"""CLI 표시를 위한 파일 작업 추적 및 diff 계산 도움말."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from deepagents.backends.utils import perform_string_replacement
|
||||
|
||||
from deepagents_cli.config import settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from deepagents.backends.protocol import BACKEND_TYPES
|
||||
|
||||
FileOpStatus = Literal["pending", "success", "error"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApprovalPreview:
|
||||
"""HITL 미리보기를 렌더링하는 데 사용되는 데이터."""
|
||||
|
||||
title: str
|
||||
details: list[str]
|
||||
diff: str | None = None
|
||||
diff_title: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def _safe_read(path: Path) -> str | None:
|
||||
"""파일 내용을 읽고, 실패 시 None을 반환합니다."""
|
||||
try:
|
||||
return path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _count_lines(text: str) -> int:
|
||||
"""빈 문자열을 0줄로 취급하여 텍스트의 줄 수를 셉니다."""
|
||||
if not text:
|
||||
return 0
|
||||
return len(text.splitlines())
|
||||
|
||||
|
||||
def compute_unified_diff(
|
||||
before: str,
|
||||
after: str,
|
||||
display_path: str,
|
||||
*,
|
||||
max_lines: int | None = 800,
|
||||
context_lines: int = 3,
|
||||
) -> str | None:
|
||||
"""이전 내용과 이후 내용 간의 통합 diff를 계산합니다.
|
||||
|
||||
Args:
|
||||
before: 원본 내용
|
||||
after: 새로운 내용
|
||||
display_path: diff 헤더에 표시할 경로
|
||||
max_lines: 최대 diff 줄 수 (제한 없으면 None)
|
||||
context_lines: 변경 사항 주변의 컨텍스트 줄 수 (기본값 3)
|
||||
|
||||
Returns:
|
||||
통합 diff 문자열 또는 변경 사항이 없는 경우 None
|
||||
"""
|
||||
before_lines = before.splitlines()
|
||||
after_lines = after.splitlines()
|
||||
diff_lines = list(
|
||||
difflib.unified_diff(
|
||||
before_lines,
|
||||
after_lines,
|
||||
fromfile=f"{display_path} (before)",
|
||||
tofile=f"{display_path} (after)",
|
||||
lineterm="",
|
||||
n=context_lines,
|
||||
)
|
||||
)
|
||||
if not diff_lines:
|
||||
return None
|
||||
if max_lines is not None and len(diff_lines) > max_lines:
|
||||
truncated = diff_lines[: max_lines - 1]
|
||||
truncated.append("...")
|
||||
return "\n".join(truncated)
|
||||
return "\n".join(diff_lines)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileOpMetrics:
|
||||
"""파일 작업에 대한 줄 및 바이트 수준 메트릭."""
|
||||
|
||||
lines_read: int = 0
|
||||
start_line: int | None = None
|
||||
end_line: int | None = None
|
||||
lines_written: int = 0
|
||||
lines_added: int = 0
|
||||
lines_removed: int = 0
|
||||
bytes_written: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileOperationRecord:
|
||||
"""단일 파일시스템 도구 호출을 추적합니다."""
|
||||
|
||||
tool_name: str
|
||||
display_path: str
|
||||
physical_path: Path | None
|
||||
tool_call_id: str | None
|
||||
args: dict[str, Any] = field(default_factory=dict)
|
||||
status: FileOpStatus = "pending"
|
||||
error: str | None = None
|
||||
metrics: FileOpMetrics = field(default_factory=FileOpMetrics)
|
||||
diff: str | None = None
|
||||
before_content: str | None = None
|
||||
after_content: str | None = None
|
||||
read_output: str | None = None
|
||||
hitl_approved: bool = False
|
||||
|
||||
|
||||
def resolve_physical_path(path_str: str | None, assistant_id: str | None) -> Path | None:
|
||||
"""가상/상대 경로를 실제 파일시스템 경로로 변환합니다."""
|
||||
if not path_str:
|
||||
return None
|
||||
try:
|
||||
if assistant_id and path_str.startswith("/memories/"):
|
||||
agent_dir = settings.get_agent_dir(assistant_id)
|
||||
suffix = path_str.removeprefix("/memories/").lstrip("/")
|
||||
return (agent_dir / suffix).resolve()
|
||||
path = Path(path_str)
|
||||
if path.is_absolute():
|
||||
return path
|
||||
return (Path.cwd() / path).resolve()
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def format_display_path(path_str: str | None) -> str:
|
||||
"""표시용으로 경로를 포맷합니다."""
|
||||
if not path_str:
|
||||
return "(알 수 없음)"
|
||||
try:
|
||||
path = Path(path_str)
|
||||
if path.is_absolute():
|
||||
return path.name or str(path)
|
||||
return str(path)
|
||||
except (OSError, ValueError):
|
||||
return str(path_str)
|
||||
|
||||
|
||||
def build_approval_preview(
|
||||
tool_name: str,
|
||||
args: dict[str, Any],
|
||||
assistant_id: str | None,
|
||||
) -> ApprovalPreview | None:
|
||||
"""HITL 승인을 위한 요약 정보 및 diff를 수집합니다."""
|
||||
path_str = str(args.get("file_path") or args.get("path") or "")
|
||||
display_path = format_display_path(path_str)
|
||||
physical_path = resolve_physical_path(path_str, assistant_id)
|
||||
|
||||
if tool_name == "write_file":
|
||||
content = str(args.get("content", ""))
|
||||
before = _safe_read(physical_path) if physical_path and physical_path.exists() else ""
|
||||
after = content
|
||||
diff = compute_unified_diff(before or "", after, display_path, max_lines=100)
|
||||
additions = 0
|
||||
if diff:
|
||||
additions = sum(1 for line in diff.splitlines() if line.startswith("+") and not line.startswith("+++"))
|
||||
total_lines = _count_lines(after)
|
||||
details = [
|
||||
f"파일: {path_str}",
|
||||
"작업: 새 파일 생성" + (" (기존 내용 덮어씀)" if before else ""),
|
||||
f"작성할 줄 수: {additions or total_lines}",
|
||||
]
|
||||
return ApprovalPreview(
|
||||
title=f"{display_path} 쓰기",
|
||||
details=details,
|
||||
diff=diff,
|
||||
diff_title=f"{display_path} 차이(Diff)",
|
||||
)
|
||||
|
||||
if tool_name == "edit_file":
|
||||
if physical_path is None:
|
||||
return ApprovalPreview(
|
||||
title=f"{display_path} 업데이트",
|
||||
details=[f"파일: {path_str}", "작업: 텍스트 교체"],
|
||||
error="파일 경로를 확인할 수 없습니다.",
|
||||
)
|
||||
before = _safe_read(physical_path)
|
||||
if before is None:
|
||||
return ApprovalPreview(
|
||||
title=f"{display_path} 업데이트",
|
||||
details=[f"파일: {path_str}", "작업: 텍스트 교체"],
|
||||
error="현재 파일 내용을 읽을 수 없습니다.",
|
||||
)
|
||||
old_string = str(args.get("old_string", ""))
|
||||
new_string = str(args.get("new_string", ""))
|
||||
replace_all = bool(args.get("replace_all", False))
|
||||
replacement = perform_string_replacement(before, old_string, new_string, replace_all)
|
||||
if isinstance(replacement, str):
|
||||
return ApprovalPreview(
|
||||
title=f"{display_path} 업데이트",
|
||||
details=[f"파일: {path_str}", "작업: 텍스트 교체"],
|
||||
error=replacement,
|
||||
)
|
||||
after, occurrences = replacement
|
||||
diff = compute_unified_diff(before, after, display_path, max_lines=None)
|
||||
additions = 0
|
||||
deletions = 0
|
||||
if diff:
|
||||
additions = sum(1 for line in diff.splitlines() if line.startswith("+") and not line.startswith("+++"))
|
||||
deletions = sum(1 for line in diff.splitlines() if line.startswith("-") and not line.startswith("---"))
|
||||
details = [
|
||||
f"파일: {path_str}",
|
||||
f"작업: 텍스트 교체 ({'모든 발생' if replace_all else '단일 발생'})",
|
||||
f"일치하는 발생: {occurrences}",
|
||||
f"변경된 줄: +{additions} / -{deletions}",
|
||||
]
|
||||
return ApprovalPreview(
|
||||
title=f"{display_path} 업데이트",
|
||||
details=details,
|
||||
diff=diff,
|
||||
diff_title=f"{display_path} 차이(Diff)",
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class FileOpTracker:
|
||||
"""CLI 상호작용 중 파일 작업 메트릭을 수집합니다."""
|
||||
|
||||
def __init__(self, *, assistant_id: str | None, backend: BACKEND_TYPES | None = None) -> None:
|
||||
"""추적기를 초기화합니다."""
|
||||
self.assistant_id = assistant_id
|
||||
self.backend = backend
|
||||
self.active: dict[str | None, FileOperationRecord] = {}
|
||||
self.completed: list[FileOperationRecord] = []
|
||||
|
||||
def start_operation(self, tool_name: str, args: dict[str, Any], tool_call_id: str | None) -> None:
|
||||
if tool_name not in {"read_file", "write_file", "edit_file"}:
|
||||
return
|
||||
path_str = str(args.get("file_path") or args.get("path") or "")
|
||||
display_path = format_display_path(path_str)
|
||||
record = FileOperationRecord(
|
||||
tool_name=tool_name,
|
||||
display_path=display_path,
|
||||
physical_path=resolve_physical_path(path_str, self.assistant_id),
|
||||
tool_call_id=tool_call_id,
|
||||
args=args,
|
||||
)
|
||||
if tool_name in {"write_file", "edit_file"}:
|
||||
if self.backend and path_str:
|
||||
try:
|
||||
responses = self.backend.download_files([path_str])
|
||||
if responses and responses[0].content is not None and responses[0].error is None:
|
||||
record.before_content = responses[0].content.decode("utf-8")
|
||||
else:
|
||||
record.before_content = ""
|
||||
except Exception:
|
||||
record.before_content = ""
|
||||
elif record.physical_path:
|
||||
record.before_content = _safe_read(record.physical_path) or ""
|
||||
self.active[tool_call_id] = record
|
||||
|
||||
def update_args(self, tool_call_id: str, args: dict[str, Any]) -> None:
|
||||
"""활성 작업의 인수를 업데이트하고 before_content 캡처를 다시 시도합니다."""
|
||||
record = self.active.get(tool_call_id)
|
||||
if not record:
|
||||
return
|
||||
|
||||
record.args.update(args)
|
||||
|
||||
# If we haven't captured before_content yet, try again now that we might have the path
|
||||
if record.before_content is None and record.tool_name in {"write_file", "edit_file"}:
|
||||
path_str = str(record.args.get("file_path") or record.args.get("path") or "")
|
||||
if path_str:
|
||||
record.display_path = format_display_path(path_str)
|
||||
record.physical_path = resolve_physical_path(path_str, self.assistant_id)
|
||||
if self.backend:
|
||||
try:
|
||||
responses = self.backend.download_files([path_str])
|
||||
if responses and responses[0].content is not None and responses[0].error is None:
|
||||
record.before_content = responses[0].content.decode("utf-8")
|
||||
else:
|
||||
record.before_content = ""
|
||||
except Exception:
|
||||
record.before_content = ""
|
||||
elif record.physical_path:
|
||||
record.before_content = _safe_read(record.physical_path) or ""
|
||||
|
||||
def complete_with_message(self, tool_message: Any) -> FileOperationRecord | None:
|
||||
tool_call_id = getattr(tool_message, "tool_call_id", None)
|
||||
record = self.active.get(tool_call_id)
|
||||
if record is None:
|
||||
return None
|
||||
|
||||
content = tool_message.content
|
||||
if isinstance(content, list):
|
||||
# Some tool messages may return list segments; join them for analysis.
|
||||
joined = []
|
||||
for item in content:
|
||||
if isinstance(item, str):
|
||||
joined.append(item)
|
||||
else:
|
||||
joined.append(str(item))
|
||||
content_text = "\n".join(joined)
|
||||
else:
|
||||
content_text = str(content) if content is not None else ""
|
||||
|
||||
if getattr(tool_message, "status", "success") != "success" or content_text.lower().startswith("error"):
|
||||
record.status = "error"
|
||||
record.error = content_text
|
||||
self._finalize(record)
|
||||
return record
|
||||
|
||||
record.status = "success"
|
||||
|
||||
if record.tool_name == "read_file":
|
||||
record.read_output = content_text
|
||||
lines = _count_lines(content_text)
|
||||
record.metrics.lines_read = lines
|
||||
offset = record.args.get("offset")
|
||||
limit = record.args.get("limit")
|
||||
if isinstance(offset, int):
|
||||
if offset > lines:
|
||||
offset = 0
|
||||
record.metrics.start_line = offset + 1
|
||||
if lines:
|
||||
record.metrics.end_line = offset + lines
|
||||
elif lines:
|
||||
record.metrics.start_line = 1
|
||||
record.metrics.end_line = lines
|
||||
if isinstance(limit, int) and lines > limit:
|
||||
record.metrics.end_line = (record.metrics.start_line or 1) + limit - 1
|
||||
else:
|
||||
# For write/edit operations, read back from backend (or local filesystem)
|
||||
self._populate_after_content(record)
|
||||
if record.after_content is None:
|
||||
record.status = "error"
|
||||
record.error = "업데이트된 파일 내용을 읽을 수 없습니다."
|
||||
self._finalize(record)
|
||||
return record
|
||||
record.metrics.lines_written = _count_lines(record.after_content)
|
||||
before_lines = _count_lines(record.before_content or "")
|
||||
diff = compute_unified_diff(
|
||||
record.before_content or "",
|
||||
record.after_content,
|
||||
record.display_path,
|
||||
max_lines=100,
|
||||
)
|
||||
record.diff = diff
|
||||
if diff:
|
||||
additions = sum(1 for line in diff.splitlines() if line.startswith("+") and not line.startswith("+++"))
|
||||
deletions = sum(1 for line in diff.splitlines() if line.startswith("-") and not line.startswith("---"))
|
||||
record.metrics.lines_added = additions
|
||||
record.metrics.lines_removed = deletions
|
||||
elif record.tool_name == "write_file" and (record.before_content or "") == "":
|
||||
record.metrics.lines_added = record.metrics.lines_written
|
||||
record.metrics.bytes_written = len(record.after_content.encode("utf-8"))
|
||||
if record.diff is None and (record.before_content or "") != record.after_content:
|
||||
record.diff = compute_unified_diff(
|
||||
record.before_content or "",
|
||||
record.after_content,
|
||||
record.display_path,
|
||||
max_lines=100,
|
||||
)
|
||||
if record.diff is None and before_lines != record.metrics.lines_written:
|
||||
record.metrics.lines_added = max(record.metrics.lines_written - before_lines, 0)
|
||||
|
||||
self._finalize(record)
|
||||
return record
|
||||
|
||||
def mark_hitl_approved(self, tool_name: str, args: dict[str, Any]) -> None:
|
||||
"""tool_name 및 file_path와 일치하는 작업을 HIL 승인됨으로 표시합니다."""
|
||||
file_path = args.get("file_path") or args.get("path")
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
# Mark all active records that match
|
||||
for record in self.active.values():
|
||||
if record.tool_name == tool_name:
|
||||
record_path = record.args.get("file_path") or record.args.get("path")
|
||||
if record_path == file_path:
|
||||
record.hitl_approved = True
|
||||
|
||||
def _populate_after_content(self, record: FileOperationRecord) -> None:
|
||||
# Use backend if available (works for any BackendProtocol implementation)
|
||||
if self.backend:
|
||||
try:
|
||||
file_path = record.args.get("file_path") or record.args.get("path")
|
||||
if file_path:
|
||||
responses = self.backend.download_files([file_path])
|
||||
if responses and responses[0].content is not None and responses[0].error is None:
|
||||
record.after_content = responses[0].content.decode("utf-8")
|
||||
else:
|
||||
record.after_content = None
|
||||
else:
|
||||
record.after_content = None
|
||||
except Exception:
|
||||
record.after_content = None
|
||||
else:
|
||||
# Fallback: direct filesystem read when no backend provided
|
||||
if record.physical_path is None:
|
||||
record.after_content = None
|
||||
return
|
||||
record.after_content = _safe_read(record.physical_path)
|
||||
|
||||
def _finalize(self, record: FileOperationRecord) -> None:
|
||||
self.completed.append(record)
|
||||
self.active.pop(record.tool_call_id, None)
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Utilities for handling image paste from clipboard."""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageData:
|
||||
"""Represents a pasted image with its base64 encoding."""
|
||||
|
||||
base64_data: str
|
||||
format: str # "png", "jpeg", etc.
|
||||
placeholder: str # Display text like "[image 1]"
|
||||
|
||||
def to_message_content(self) -> dict:
|
||||
"""Convert to LangChain message content format.
|
||||
|
||||
Returns:
|
||||
Dict with type and image_url for multimodal messages
|
||||
"""
|
||||
return {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/{self.format};base64,{self.base64_data}"},
|
||||
}
|
||||
|
||||
|
||||
def get_clipboard_image() -> ImageData | None:
|
||||
"""Attempt to read an image from the system clipboard.
|
||||
|
||||
Supports macOS via `pngpaste` or `osascript`.
|
||||
|
||||
Returns:
|
||||
ImageData if an image is found, None otherwise
|
||||
"""
|
||||
if sys.platform == "darwin":
|
||||
return _get_macos_clipboard_image()
|
||||
# Linux/Windows support could be added here
|
||||
return None
|
||||
|
||||
|
||||
def _get_macos_clipboard_image() -> ImageData | None:
|
||||
"""Get clipboard image on macOS using pngpaste or osascript.
|
||||
|
||||
First tries pngpaste (faster if installed), then falls back to osascript.
|
||||
|
||||
Returns:
|
||||
ImageData if an image is found, None otherwise
|
||||
"""
|
||||
# Try pngpaste first (fast if installed)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pngpaste", "-"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=2,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout:
|
||||
# Successfully got PNG data
|
||||
try:
|
||||
Image.open(io.BytesIO(result.stdout)) # Validate it's a real image
|
||||
base64_data = base64.b64encode(result.stdout).decode("utf-8")
|
||||
return ImageData(
|
||||
base64_data=base64_data,
|
||||
format="png", # 'pngpaste -' always outputs PNG
|
||||
placeholder="[image]",
|
||||
)
|
||||
except Exception:
|
||||
pass # Invalid image data
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass # pngpaste not installed or timed out
|
||||
|
||||
# Fallback to osascript with temp file (built-in but slower)
|
||||
return _get_clipboard_via_osascript()
|
||||
|
||||
|
||||
def _get_clipboard_via_osascript() -> ImageData | None:
|
||||
"""Get clipboard image via osascript using a temp file.
|
||||
|
||||
osascript outputs data in a special format that can't be captured as raw binary,
|
||||
so we write to a temp file instead.
|
||||
|
||||
Returns:
|
||||
ImageData if an image is found, None otherwise
|
||||
"""
|
||||
# Create a temp file for the image
|
||||
fd, temp_path = tempfile.mkstemp(suffix=".png")
|
||||
os.close(fd)
|
||||
|
||||
try:
|
||||
# First check if clipboard has PNG data
|
||||
check_result = subprocess.run(
|
||||
["osascript", "-e", "clipboard info"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=2,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if check_result.returncode != 0:
|
||||
return None
|
||||
|
||||
# Check for PNG or TIFF in clipboard info
|
||||
clipboard_info = check_result.stdout.lower()
|
||||
if "pngf" not in clipboard_info and "tiff" not in clipboard_info:
|
||||
return None
|
||||
|
||||
# Try to get PNG first, fall back to TIFF
|
||||
if "pngf" in clipboard_info:
|
||||
get_script = f"""
|
||||
set pngData to the clipboard as «class PNGf»
|
||||
set theFile to open for access POSIX file "{temp_path}" with write permission
|
||||
write pngData to theFile
|
||||
close access theFile
|
||||
return "success"
|
||||
"""
|
||||
else:
|
||||
get_script = f"""
|
||||
set tiffData to the clipboard as TIFF picture
|
||||
set theFile to open for access POSIX file "{temp_path}" with write permission
|
||||
write tiffData to theFile
|
||||
close access theFile
|
||||
return "success"
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
["osascript", "-e", get_script],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=3,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0 or "success" not in result.stdout:
|
||||
return None
|
||||
|
||||
# Check if file was created and has content
|
||||
if not os.path.exists(temp_path) or os.path.getsize(temp_path) == 0:
|
||||
return None
|
||||
|
||||
# Read and validate the image
|
||||
with open(temp_path, "rb") as f:
|
||||
image_data = f.read()
|
||||
|
||||
try:
|
||||
image = Image.open(io.BytesIO(image_data))
|
||||
# Convert to PNG if it's not already (e.g., if we got TIFF)
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
base64_data = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
||||
|
||||
return ImageData(
|
||||
base64_data=base64_data,
|
||||
format="png",
|
||||
placeholder="[image]",
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return None
|
||||
finally:
|
||||
# Clean up temp file
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def encode_image_to_base64(image_bytes: bytes) -> str:
|
||||
"""Encode image bytes to base64 string.
|
||||
|
||||
Args:
|
||||
image_bytes: Raw image bytes
|
||||
|
||||
Returns:
|
||||
Base64-encoded string
|
||||
"""
|
||||
return base64.b64encode(image_bytes).decode("utf-8")
|
||||
|
||||
|
||||
def create_multimodal_content(text: str, images: list[ImageData]) -> list[dict]:
|
||||
"""Create multimodal message content with text and images.
|
||||
|
||||
Args:
|
||||
text: Text content of the message
|
||||
images: List of ImageData objects
|
||||
|
||||
Returns:
|
||||
List of content blocks in LangChain format
|
||||
"""
|
||||
content_blocks = []
|
||||
|
||||
# Add text block
|
||||
if text.strip():
|
||||
content_blocks.append({"type": "text", "text": text})
|
||||
|
||||
# Add image blocks
|
||||
for image in images:
|
||||
content_blocks.append(image.to_message_content())
|
||||
|
||||
return content_blocks
|
||||
@@ -0,0 +1,420 @@
|
||||
"""CLI를 위한 입력 처리, 완성 및 프롬프트 세션."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.completion import (
|
||||
Completer,
|
||||
Completion,
|
||||
PathCompleter,
|
||||
merge_completers,
|
||||
)
|
||||
from prompt_toolkit.document import Document
|
||||
from prompt_toolkit.enums import EditingMode
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
|
||||
from .config import COLORS, COMMANDS, SessionState, console
|
||||
from .image_utils import ImageData, get_clipboard_image
|
||||
|
||||
# Regex patterns for context-aware completion
|
||||
AT_MENTION_RE = re.compile(r"@(?P<path>(?:[^\s@]|(?<=\\)\s)*)$")
|
||||
SLASH_COMMAND_RE = re.compile(r"^/(?P<command>[a-z]*)$")
|
||||
|
||||
EXIT_CONFIRM_WINDOW = 3.0
|
||||
|
||||
|
||||
class ImageTracker:
|
||||
"""현재 대화에서 붙여넣은 이미지를 추적합니다."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.images: list[ImageData] = []
|
||||
self.next_id = 1
|
||||
|
||||
def add_image(self, image_data: ImageData) -> str:
|
||||
"""이미지를 추가하고 해당 자리 표시자 텍스트를 반환합니다.
|
||||
|
||||
Args:
|
||||
image_data: 추적할 이미지 데이터
|
||||
|
||||
Returns:
|
||||
"[image 1]"과 같은 자리 표시자 문자열
|
||||
"""
|
||||
placeholder = f"[image {self.next_id}]"
|
||||
image_data.placeholder = placeholder
|
||||
self.images.append(image_data)
|
||||
self.next_id += 1
|
||||
return placeholder
|
||||
|
||||
def get_images(self) -> list[ImageData]:
|
||||
"""추적된 모든 이미지를 가져옵니다."""
|
||||
return self.images.copy()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""추적된 모든 이미지를 지우고 카운터를 재설정합니다."""
|
||||
self.images.clear()
|
||||
self.next_id = 1
|
||||
|
||||
|
||||
class FilePathCompleter(Completer):
|
||||
"""커서가 '@' 뒤에 있을 때만 파일시스템 완성을 활성화합니다."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.path_completer = PathCompleter(
|
||||
expanduser=True,
|
||||
min_input_len=0,
|
||||
only_directories=False,
|
||||
)
|
||||
|
||||
def get_completions(self, document, complete_event):
|
||||
"""@가 감지되면 파일 경로 완성을 가져옵니다."""
|
||||
text = document.text_before_cursor
|
||||
|
||||
# Use regex to detect @path pattern at end of line
|
||||
m = AT_MENTION_RE.search(text)
|
||||
if not m:
|
||||
return # Not in an @path context
|
||||
|
||||
path_fragment = m.group("path")
|
||||
|
||||
# Unescape the path for PathCompleter (it doesn't understand escape sequences)
|
||||
unescaped_fragment = path_fragment.replace("\\ ", " ")
|
||||
|
||||
# Strip trailing backslash if present (user is in the process of typing an escape)
|
||||
unescaped_fragment = unescaped_fragment.removesuffix("\\")
|
||||
|
||||
# Create temporary document for the unescaped path fragment
|
||||
temp_doc = Document(text=unescaped_fragment, cursor_position=len(unescaped_fragment))
|
||||
|
||||
# Get completions from PathCompleter and use its start_position
|
||||
# PathCompleter returns suffix text with start_position=0 (insert at cursor)
|
||||
for comp in self.path_completer.get_completions(temp_doc, complete_event):
|
||||
# Add trailing / for directories so users can continue navigating
|
||||
completed_path = Path(unescaped_fragment + comp.text).expanduser()
|
||||
# Re-escape spaces in the completion text for the command line
|
||||
completion_text = comp.text.replace(" ", "\\ ")
|
||||
if completed_path.is_dir() and not completion_text.endswith("/"):
|
||||
completion_text += "/"
|
||||
|
||||
yield Completion(
|
||||
text=completion_text,
|
||||
start_position=comp.start_position, # Use PathCompleter's position (usually 0)
|
||||
display=comp.display,
|
||||
display_meta=comp.display_meta,
|
||||
)
|
||||
|
||||
|
||||
class CommandCompleter(Completer):
|
||||
"""줄이 '/'로 시작할 때만 명령 완성을 활성화합니다."""
|
||||
|
||||
def get_completions(self, document, _complete_event):
|
||||
"""/가 시작 부분에 있을 때 명령 완성을 가져옵니다."""
|
||||
text = document.text_before_cursor
|
||||
|
||||
# Use regex to detect /command pattern at start of line
|
||||
m = SLASH_COMMAND_RE.match(text)
|
||||
if not m:
|
||||
return # Not in a /command context
|
||||
|
||||
command_fragment = m.group("command")
|
||||
|
||||
# Match commands that start with the fragment (case-insensitive)
|
||||
for cmd_name, cmd_desc in COMMANDS.items():
|
||||
if cmd_name.startswith(command_fragment.lower()):
|
||||
yield Completion(
|
||||
text=cmd_name,
|
||||
start_position=-len(command_fragment), # Fixed position for original document
|
||||
display=cmd_name,
|
||||
display_meta=cmd_desc,
|
||||
)
|
||||
|
||||
|
||||
def parse_file_mentions(text: str) -> tuple[str, list[Path]]:
|
||||
"""@file 멘션을 추출하고 해결된 파일 경로가 포함된 정리된 텍스트를 반환합니다."""
|
||||
pattern = r"@((?:[^\s@]|(?<=\\)\s)+)" # Match @filename, allowing escaped spaces
|
||||
matches = re.findall(pattern, text)
|
||||
|
||||
files = []
|
||||
for match in matches:
|
||||
# Remove escape characters
|
||||
clean_path = match.replace("\\ ", " ")
|
||||
path = Path(clean_path).expanduser()
|
||||
|
||||
# Try to resolve relative to cwd
|
||||
if not path.is_absolute():
|
||||
path = Path.cwd() / path
|
||||
|
||||
try:
|
||||
path = path.resolve()
|
||||
if path.exists() and path.is_file():
|
||||
files.append(path)
|
||||
else:
|
||||
console.print(f"[yellow]경고: 파일을 찾을 수 없습니다: {match}[/yellow]")
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]경고: 유효하지 않은 경로 {match}: {e}[/yellow]")
|
||||
|
||||
return text, files
|
||||
|
||||
|
||||
def parse_image_placeholders(text: str) -> tuple[str, int]:
|
||||
"""텍스트 내 이미지 자리 표시자 수를 셉니다.
|
||||
|
||||
Args:
|
||||
text: [image] 또는 [image N] 자리 표시자가 포함될 수 있는 입력 텍스트
|
||||
|
||||
Returns:
|
||||
이미지 자리 표시자 수가 포함된 (텍스트, 개수) 튜플
|
||||
"""
|
||||
# Match [image] or [image N] patterns
|
||||
pattern = r"\[image(?:\s+\d+)?\]"
|
||||
matches = re.findall(pattern, text, re.IGNORECASE)
|
||||
return text, len(matches)
|
||||
|
||||
|
||||
def get_bottom_toolbar(session_state: SessionState, session_ref: dict) -> Callable[[], list[tuple[str, str]]]:
|
||||
"""자동 승인 상태와 BASH 모드를 표시하는 툴바 함수를 반환합니다."""
|
||||
|
||||
def toolbar() -> list[tuple[str, str]]:
|
||||
parts = []
|
||||
|
||||
# Check if we're in BASH mode (input starts with !)
|
||||
try:
|
||||
session = session_ref.get("session")
|
||||
if session:
|
||||
current_text = session.default_buffer.text
|
||||
if current_text.startswith("!"):
|
||||
parts.append(("bg:#ff1493 fg:#ffffff bold", " BASH MODE "))
|
||||
parts.append(("", " | "))
|
||||
except (AttributeError, TypeError):
|
||||
# Silently ignore - toolbar is non-critical and called frequently
|
||||
pass
|
||||
|
||||
# Base status message
|
||||
if session_state.auto_approve:
|
||||
base_msg = "자동 승인 켜짐 (CTRL+T로 전환)"
|
||||
base_class = "class:toolbar-green"
|
||||
else:
|
||||
base_msg = "수동 승인 (CTRL+T로 전환)"
|
||||
base_class = "class:toolbar-orange"
|
||||
|
||||
parts.append((base_class, base_msg))
|
||||
|
||||
# Show exit confirmation hint if active
|
||||
hint_until = session_state.exit_hint_until
|
||||
if hint_until is not None:
|
||||
now = time.monotonic()
|
||||
if now < hint_until:
|
||||
parts.append(("", " | "))
|
||||
parts.append(("class:toolbar-exit", " 종료하려면 Ctrl+C를 한번 더 누르세요 "))
|
||||
else:
|
||||
session_state.exit_hint_until = None
|
||||
|
||||
return parts
|
||||
|
||||
return toolbar
|
||||
|
||||
|
||||
def create_prompt_session(
|
||||
_assistant_id: str, session_state: SessionState, image_tracker: ImageTracker | None = None
|
||||
) -> PromptSession:
|
||||
"""모든 기능이 구성된 PromptSession을 생성합니다."""
|
||||
# Set default editor if not already set
|
||||
if "EDITOR" not in os.environ:
|
||||
os.environ["EDITOR"] = "nano"
|
||||
|
||||
# Create key bindings
|
||||
kb = KeyBindings()
|
||||
|
||||
@kb.add("c-c")
|
||||
def _(event) -> None:
|
||||
"""종료하려면 짧은 시간 내에 Ctrl+C를 두 번 눌러야 합니다."""
|
||||
app = event.app
|
||||
now = time.monotonic()
|
||||
|
||||
if session_state.exit_hint_until is not None and now < session_state.exit_hint_until:
|
||||
handle = session_state.exit_hint_handle
|
||||
if handle:
|
||||
handle.cancel()
|
||||
session_state.exit_hint_handle = None
|
||||
session_state.exit_hint_until = None
|
||||
app.invalidate()
|
||||
app.exit(exception=KeyboardInterrupt())
|
||||
return
|
||||
|
||||
session_state.exit_hint_until = now + EXIT_CONFIRM_WINDOW
|
||||
|
||||
handle = session_state.exit_hint_handle
|
||||
if handle:
|
||||
handle.cancel()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
app_ref = app
|
||||
|
||||
def clear_hint() -> None:
|
||||
if session_state.exit_hint_until is not None and time.monotonic() >= session_state.exit_hint_until:
|
||||
session_state.exit_hint_until = None
|
||||
session_state.exit_hint_handle = None
|
||||
app_ref.invalidate()
|
||||
|
||||
session_state.exit_hint_handle = loop.call_later(EXIT_CONFIRM_WINDOW, clear_hint)
|
||||
|
||||
app.invalidate()
|
||||
|
||||
# Bind Ctrl+T to toggle auto-approve
|
||||
@kb.add("c-t")
|
||||
def _(event) -> None:
|
||||
"""자동 승인 모드를 토글합니다."""
|
||||
session_state.toggle_auto_approve()
|
||||
# Force UI refresh to update toolbar
|
||||
event.app.invalidate()
|
||||
|
||||
# Custom paste handler to detect images
|
||||
if image_tracker:
|
||||
from prompt_toolkit.keys import Keys
|
||||
|
||||
def _handle_paste_with_image_check(event, pasted_text: str = "") -> None:
|
||||
"""클립보드에서 이미지를 확인하고, 그렇지 않으면 붙여넣은 텍스트를 삽입합니다."""
|
||||
# Try to get an image from clipboard
|
||||
clipboard_image = get_clipboard_image()
|
||||
|
||||
if clipboard_image:
|
||||
# Found an image! Add it to tracker and insert placeholder
|
||||
placeholder = image_tracker.add_image(clipboard_image)
|
||||
# Insert placeholder (no confirmation message)
|
||||
event.current_buffer.insert_text(placeholder)
|
||||
elif pasted_text:
|
||||
# No image, insert the pasted text
|
||||
event.current_buffer.insert_text(pasted_text)
|
||||
else:
|
||||
# Fallback: try to get text from prompt_toolkit clipboard
|
||||
clipboard_data = event.app.clipboard.get_data()
|
||||
if clipboard_data and clipboard_data.text:
|
||||
event.current_buffer.insert_text(clipboard_data.text)
|
||||
|
||||
@kb.add(Keys.BracketedPaste)
|
||||
def _(event) -> None:
|
||||
"""브래킷 붙여넣기(macOS의 Cmd+V)를 처리합니다 - 이미지를 먼저 확인합니다."""
|
||||
# Bracketed paste provides the pasted text in event.data
|
||||
pasted_text = event.data if hasattr(event, "data") else ""
|
||||
_handle_paste_with_image_check(event, pasted_text)
|
||||
|
||||
@kb.add("c-v")
|
||||
def _(event) -> None:
|
||||
"""Ctrl+V 붙여넣기를 처리합니다 - 이미지를 먼저 확인합니다."""
|
||||
_handle_paste_with_image_check(event)
|
||||
|
||||
# Bind regular Enter to submit (intuitive behavior)
|
||||
@kb.add("enter")
|
||||
def _(event) -> None:
|
||||
"""완성 메뉴가 활성화되지 않은 경우 Enter는 입력을 제출합니다."""
|
||||
buffer = event.current_buffer
|
||||
|
||||
# If completion menu is showing, apply the current completion
|
||||
if buffer.complete_state:
|
||||
# Get the current completion (the highlighted one)
|
||||
current_completion = buffer.complete_state.current_completion
|
||||
|
||||
# If no completion is selected (user hasn't navigated), select and apply the first one
|
||||
if not current_completion and buffer.complete_state.completions:
|
||||
# Move to the first completion
|
||||
buffer.complete_next()
|
||||
# Now apply it
|
||||
buffer.apply_completion(buffer.complete_state.current_completion)
|
||||
elif current_completion:
|
||||
# Apply the already-selected completion
|
||||
buffer.apply_completion(current_completion)
|
||||
else:
|
||||
# No completions available, close menu
|
||||
buffer.complete_state = None
|
||||
# Don't submit if buffer is empty or only whitespace
|
||||
elif buffer.text.strip():
|
||||
# Normal submit
|
||||
buffer.validate_and_handle()
|
||||
# If empty, do nothing (don't submit)
|
||||
|
||||
# Alt+Enter for newlines (press ESC then Enter, or Option+Enter on Mac)
|
||||
@kb.add("escape", "enter")
|
||||
def _(event) -> None:
|
||||
"""Alt+Enter는 여러 줄 입력을 위해 줄바꿈을 삽입합니다."""
|
||||
event.current_buffer.insert_text("\n")
|
||||
|
||||
# Ctrl+E to open in external editor
|
||||
@kb.add("c-e")
|
||||
def _(event) -> None:
|
||||
"""현재 입력을 외부 편집기(기본값 nano)에서 엽니다."""
|
||||
event.current_buffer.open_in_editor()
|
||||
|
||||
# Backspace handler to retrigger completions and delete image tags as units
|
||||
@kb.add("backspace")
|
||||
def _(event) -> None:
|
||||
"""백스페이스 처리: 이미지 태그를 단일 단위로 삭제하고 완성을 다시 트리거합니다."""
|
||||
buffer = event.current_buffer
|
||||
text_before = buffer.document.text_before_cursor
|
||||
|
||||
# Check if cursor is right after an image tag like [image 1] or [image 12]
|
||||
image_tag_pattern = r"\[image \d+\]$"
|
||||
match = re.search(image_tag_pattern, text_before)
|
||||
|
||||
if match and image_tracker:
|
||||
# Delete the entire tag
|
||||
tag_length = len(match.group(0))
|
||||
buffer.delete_before_cursor(count=tag_length)
|
||||
|
||||
# Remove the image from tracker and reset counter
|
||||
tag_text = match.group(0)
|
||||
image_num_match = re.search(r"\d+", tag_text)
|
||||
if image_num_match:
|
||||
image_num = int(image_num_match.group(0))
|
||||
# Remove image at index (1-based to 0-based)
|
||||
if 0 < image_num <= len(image_tracker.images):
|
||||
image_tracker.images.pop(image_num - 1)
|
||||
# Reset counter to next available number
|
||||
image_tracker.next_id = len(image_tracker.images) + 1
|
||||
else:
|
||||
# Normal backspace
|
||||
buffer.delete_before_cursor(count=1)
|
||||
|
||||
# Check if we're in a completion context (@ or /)
|
||||
text = buffer.document.text_before_cursor
|
||||
if AT_MENTION_RE.search(text) or SLASH_COMMAND_RE.match(text):
|
||||
# Retrigger completion
|
||||
buffer.start_completion(select_first=False)
|
||||
|
||||
from prompt_toolkit.styles import Style
|
||||
|
||||
# Define styles for the toolbar with full-width background colors
|
||||
toolbar_style = Style.from_dict({
|
||||
"bottom-toolbar": "noreverse", # Disable default reverse video
|
||||
"toolbar-green": "bg:#10b981 #000000", # Green for auto-accept ON
|
||||
"toolbar-orange": "bg:#f59e0b #000000", # Orange for manual accept
|
||||
"toolbar-exit": "bg:#2563eb #ffffff", # Blue for exit hint
|
||||
})
|
||||
|
||||
# Create session reference dict for toolbar to access session
|
||||
session_ref = {}
|
||||
|
||||
# Create the session
|
||||
session = PromptSession(
|
||||
message=HTML(f'<style fg="{COLORS["user"]}">></style> '),
|
||||
multiline=True, # Keep multiline support but Enter submits
|
||||
key_bindings=kb,
|
||||
completer=merge_completers([CommandCompleter(), FilePathCompleter()]),
|
||||
editing_mode=EditingMode.EMACS,
|
||||
complete_while_typing=True, # Show completions as you type
|
||||
complete_in_thread=True, # Async completion prevents menu freezing
|
||||
mouse_support=False,
|
||||
enable_open_in_editor=True, # Allow Ctrl+X Ctrl+E to open external editor
|
||||
bottom_toolbar=get_bottom_toolbar(session_state, session_ref), # Persistent status bar at bottom
|
||||
style=toolbar_style, # Apply toolbar styling
|
||||
reserve_space_for_menu=7, # Reserve space for completion menu to show 5-6 results
|
||||
)
|
||||
|
||||
# Store session reference for toolbar to access
|
||||
session_ref["session"] = session
|
||||
|
||||
return session
|
||||
@@ -0,0 +1 @@
|
||||
"""DeepAgents CLI를 위한 샌드박스 연동."""
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Daytona 샌드박스 백엔드 구현."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from deepagents.backends.protocol import (
|
||||
ExecuteResponse,
|
||||
FileDownloadResponse,
|
||||
FileUploadResponse,
|
||||
)
|
||||
from deepagents.backends.sandbox import BaseSandbox
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from daytona import Sandbox
|
||||
|
||||
|
||||
class DaytonaBackend(BaseSandbox):
|
||||
"""SandboxBackendProtocol을 준수하는 Daytona 백엔드 구현.
|
||||
|
||||
이 구현은 BaseSandbox로부터 모든 파일 작업 메서드를 상속받으며,
|
||||
Daytona의 API를 사용하여 execute() 메서드만 구현합니다.
|
||||
"""
|
||||
|
||||
def __init__(self, sandbox: Sandbox) -> None:
|
||||
"""Daytona 샌드박스 클라이언트로 DaytonaBackend를 초기화합니다.
|
||||
|
||||
Args:
|
||||
sandbox: Daytona 샌드박스 인스턴스
|
||||
"""
|
||||
self._sandbox = sandbox
|
||||
self._timeout: int = 30 * 60 # 30분
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""샌드박스 백엔드의 고유 식별자."""
|
||||
return self._sandbox.id
|
||||
|
||||
def execute(
|
||||
self,
|
||||
command: str,
|
||||
) -> ExecuteResponse:
|
||||
"""샌드박스에서 명령을 실행하고 ExecuteResponse를 반환합니다.
|
||||
|
||||
Args:
|
||||
command: 실행할 전체 셸 명령 문자열.
|
||||
|
||||
Returns:
|
||||
결합된 출력, 종료 코드, 선택적 시그널 및 잘림 플래그가 포함된 ExecuteResponse.
|
||||
"""
|
||||
result = self._sandbox.process.exec(command, timeout=self._timeout)
|
||||
|
||||
return ExecuteResponse(
|
||||
output=result.result, # Daytona는 stdout/stderr를 결합함
|
||||
exit_code=result.exit_code,
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
||||
"""Daytona 샌드박스에서 여러 파일을 다운로드합니다.
|
||||
|
||||
효율성을 위해 Daytona의 네이티브 일괄 다운로드 API를 활용합니다.
|
||||
부분적인 성공을 지원하므로 개별 다운로드가 다른 다운로드에 영향을 주지 않고 실패할 수 있습니다.
|
||||
|
||||
Args:
|
||||
paths: 다운로드할 파일 경로 목록.
|
||||
|
||||
Returns:
|
||||
입력 경로당 하나씩 FileDownloadResponse 객체 목록.
|
||||
응답 순서는 입력 순서와 일치합니다.
|
||||
|
||||
TODO: Daytona API 오류 문자열을 표준화된 FileOperationError 코드로 매핑해야 합니다.
|
||||
현재는 정상적인 동작(happy path)만 구현되어 있습니다.
|
||||
"""
|
||||
from daytona import FileDownloadRequest
|
||||
|
||||
# Daytona의 네이티브 일괄 API를 사용하여 일괄 다운로드 요청 생성
|
||||
download_requests = [FileDownloadRequest(source=path) for path in paths]
|
||||
daytona_responses = self._sandbox.fs.download_files(download_requests)
|
||||
|
||||
# Daytona 결과를 당사의 응답 형식으로 변환
|
||||
# TODO: 사용 가능한 경우 resp.error를 표준화된 오류 코드로 매핑
|
||||
return [
|
||||
FileDownloadResponse(
|
||||
path=resp.source,
|
||||
content=resp.result,
|
||||
error=None, # TODO: resp.error를 FileOperationError로 매핑
|
||||
)
|
||||
for resp in daytona_responses
|
||||
]
|
||||
|
||||
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
|
||||
"""Daytona 샌드박스에 여러 파일을 업로드합니다.
|
||||
|
||||
효율성을 위해 Daytona의 네이티브 일괄 업로드 API를 활용합니다.
|
||||
부분적인 성공을 지원하므로 개별 업로드가 다른 업로드에 영향을 주지 않고 실패할 수 있습니다.
|
||||
|
||||
Args:
|
||||
files: 업로드할 (경로, 내용) 튜플 목록.
|
||||
|
||||
Returns:
|
||||
입력 파일당 하나씩 FileUploadResponse 객체 목록.
|
||||
응답 순서는 입력 순서와 일치합니다.
|
||||
|
||||
TODO: Daytona API 오류 문자열을 표준화된 FileOperationError 코드로 매핑해야 합니다.
|
||||
현재는 정상적인 동작(happy path)만 구현되어 있습니다.
|
||||
"""
|
||||
from daytona import FileUpload
|
||||
|
||||
# Daytona의 네이티브 일괄 API를 사용하여 일괄 업로드 요청 생성
|
||||
upload_requests = [FileUpload(source=content, destination=path) for path, content in files]
|
||||
self._sandbox.fs.upload_files(upload_requests)
|
||||
|
||||
# TODO: Daytona가 오류 정보를 반환하는지 확인하고 FileOperationError 코드로 매핑
|
||||
return [FileUploadResponse(path=path, error=None) for path, _ in files]
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Modal 샌드박스 백엔드 구현."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from deepagents.backends.protocol import (
|
||||
ExecuteResponse,
|
||||
FileDownloadResponse,
|
||||
FileUploadResponse,
|
||||
)
|
||||
from deepagents.backends.sandbox import BaseSandbox
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import modal
|
||||
|
||||
|
||||
class ModalBackend(BaseSandbox):
|
||||
"""SandboxBackendProtocol을 준수하는 Modal 백엔드 구현.
|
||||
|
||||
이 구현은 BaseSandbox로부터 모든 파일 작업 메서드를 상속받으며,
|
||||
Modal의 API를 사용하여 execute() 메서드만 구현합니다.
|
||||
"""
|
||||
|
||||
def __init__(self, sandbox: modal.Sandbox) -> None:
|
||||
"""Modal 샌드박스 인스턴스로 ModalBackend를 초기화합니다.
|
||||
|
||||
Args:
|
||||
sandbox: 활성 Modal 샌드박스 인스턴스
|
||||
"""
|
||||
self._sandbox = sandbox
|
||||
self._timeout = 30 * 60
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""샌드박스 백엔드의 고유 식별자."""
|
||||
return self._sandbox.object_id
|
||||
|
||||
def execute(
|
||||
self,
|
||||
command: str,
|
||||
) -> ExecuteResponse:
|
||||
"""샌드박스에서 명령을 실행하고 ExecuteResponse를 반환합니다.
|
||||
|
||||
Args:
|
||||
command: 실행할 전체 셸 명령 문자열.
|
||||
|
||||
Returns:
|
||||
결합된 출력, 종료 코드 및 잘림 플래그가 포함된 ExecuteResponse.
|
||||
"""
|
||||
# Modal의 exec API를 사용하여 명령 실행
|
||||
process = self._sandbox.exec("bash", "-c", command, timeout=self._timeout)
|
||||
|
||||
# 프로세스가 완료될 때까지 대기
|
||||
process.wait()
|
||||
|
||||
# stdout 및 stderr 읽기
|
||||
stdout = process.stdout.read()
|
||||
stderr = process.stderr.read()
|
||||
|
||||
# stdout과 stderr 결합 (Runloop의 방식과 일치)
|
||||
output = stdout or ""
|
||||
if stderr:
|
||||
output += "\n" + stderr if output else stderr
|
||||
|
||||
return ExecuteResponse(
|
||||
output=output,
|
||||
exit_code=process.returncode,
|
||||
truncated=False, # Modal은 잘림 정보를 제공하지 않음
|
||||
)
|
||||
|
||||
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
||||
"""Modal 샌드박스에서 여러 파일을 다운로드합니다.
|
||||
|
||||
부분적인 성공을 지원하므로 개별 다운로드가 다른 다운로드에 영향을 주지 않고 실패할 수 있습니다.
|
||||
|
||||
Args:
|
||||
paths: 다운로드할 파일 경로 목록.
|
||||
|
||||
Returns:
|
||||
입력 경로당 하나씩 FileDownloadResponse 객체 목록.
|
||||
응답 순서는 입력 순서와 일치합니다.
|
||||
|
||||
TODO: 표준화된 FileOperationError 코드를 사용하여 적절한 오류 처리를 구현해야 합니다.
|
||||
Modal의 sandbox.open()이 실제로 어떤 예외를 발생시키는지 확인이 필요합니다.
|
||||
현재는 정상적인 동작(happy path)만 구현되어 있습니다.
|
||||
"""
|
||||
# 이 구현은 Modal 샌드박스 파일 API에 의존합니다.
|
||||
# https://modal.com/doc/guide/sandbox-files
|
||||
# 이 API는 현재 알파 단계이며 프로덕션 용도로는 권장되지 않습니다.
|
||||
# CLI 애플리케이션을 대상으로 하므로 여기에서 사용하는 것은 괜찮습니다.
|
||||
responses = []
|
||||
for path in paths:
|
||||
with self._sandbox.open(path, "rb") as f:
|
||||
content = f.read()
|
||||
responses.append(FileDownloadResponse(path=path, content=content, error=None))
|
||||
return responses
|
||||
|
||||
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
|
||||
"""Modal 샌드박스에 여러 파일을 업로드합니다.
|
||||
|
||||
부분적인 성공을 지원하므로 개별 업로드가 다른 업로드에 영향을 주지 않고 실패할 수 있습니다.
|
||||
|
||||
Args:
|
||||
files: 업로드할 (경로, 내용) 튜플 목록.
|
||||
|
||||
Returns:
|
||||
입력 파일당 하나씩 FileUploadResponse 객체 목록.
|
||||
응답 순서는 입력 순서와 일치합니다.
|
||||
|
||||
TODO: 표준화된 FileOperationError 코드를 사용하여 적절한 오류 처리를 구현해야 합니다.
|
||||
Modal의 sandbox.open()이 실제로 어떤 예외를 발생시키는지 확인이 필요합니다.
|
||||
현재는 정상적인 동작(happy path)만 구현되어 있습니다.
|
||||
"""
|
||||
# 이 구현은 Modal 샌드박스 파일 API에 의존합니다.
|
||||
# https://modal.com/doc/guide/sandbox-files
|
||||
# 이 API는 현재 알파 단계이며 프로덕션 용도로는 권장되지 않습니다.
|
||||
# CLI 애플리케이션을 대상으로 하므로 여기에서 사용하는 것은 괜찮습니다.
|
||||
responses = []
|
||||
for path, content in files:
|
||||
with self._sandbox.open(path, "wb") as f:
|
||||
f.write(content)
|
||||
responses.append(FileUploadResponse(path=path, error=None))
|
||||
return responses
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Runloop을 위한 BackendProtocol 구현."""
|
||||
|
||||
try:
|
||||
import runloop_api_client
|
||||
except ImportError:
|
||||
msg = (
|
||||
"RunloopBackend를 위해서는 runloop_api_client 패키지가 필요합니다. "
|
||||
"`pip install runloop_api_client`로 설치하십시오."
|
||||
)
|
||||
raise ImportError(msg)
|
||||
|
||||
import os
|
||||
|
||||
from deepagents.backends.protocol import ExecuteResponse, FileDownloadResponse, FileUploadResponse
|
||||
from deepagents.backends.sandbox import BaseSandbox
|
||||
from runloop_api_client import Runloop
|
||||
|
||||
|
||||
class RunloopBackend(BaseSandbox):
|
||||
"""Runloop devbox의 파일에서 작동하는 백엔드.
|
||||
|
||||
이 구현은 Runloop API 클라이언트를 사용하여 명령을 실행하고
|
||||
원격 devbox 환경 내에서 파일을 조작합니다.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
devbox_id: str,
|
||||
client: Runloop | None = None,
|
||||
api_key: str | None = None,
|
||||
) -> None:
|
||||
"""Runloop 프로토콜을 초기화합니다.
|
||||
|
||||
Args:
|
||||
devbox_id: 작업할 Runloop devbox의 ID.
|
||||
client: 선택적인 기존 Runloop 클라이언트 인스턴스
|
||||
api_key: 새 클라이언트를 생성하기 위한 선택적 API 키
|
||||
(기본값은 RUNLOOP_API_KEY 환경 변수)
|
||||
"""
|
||||
if client and api_key:
|
||||
msg = "client 또는 bearer_token 중 하나만 제공해야 하며, 둘 다 제공할 수는 없습니다."
|
||||
raise ValueError(msg)
|
||||
|
||||
if client is None:
|
||||
api_key = api_key or os.environ.get("RUNLOOP_API_KEY", None)
|
||||
if api_key is None:
|
||||
msg = "client 또는 bearer_token 중 하나는 제공되어야 합니다."
|
||||
raise ValueError(msg)
|
||||
client = Runloop(bearer_token=api_key)
|
||||
|
||||
self._client = client
|
||||
self._devbox_id = devbox_id
|
||||
self._timeout = 30 * 60
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""샌드박스 백엔드의 고유 식별자."""
|
||||
return self._devbox_id
|
||||
|
||||
def execute(
|
||||
self,
|
||||
command: str,
|
||||
) -> ExecuteResponse:
|
||||
"""devbox에서 명령을 실행하고 ExecuteResponse를 반환합니다.
|
||||
|
||||
Args:
|
||||
command: 실행할 전체 셸 명령 문자열.
|
||||
|
||||
Returns:
|
||||
결합된 출력, 종료 코드, 선택적 시그널 및 잘림 플래그가 포함된 ExecuteResponse.
|
||||
"""
|
||||
result = self._client.devboxes.execute_and_await_completion(
|
||||
devbox_id=self._devbox_id,
|
||||
command=command,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
# stdout과 stderr 결합
|
||||
output = result.stdout or ""
|
||||
if result.stderr:
|
||||
output += "\n" + result.stderr if output else result.stderr
|
||||
|
||||
return ExecuteResponse(
|
||||
output=output,
|
||||
exit_code=result.exit_status,
|
||||
truncated=False, # Runloop는 잘림 정보를 제공하지 않음
|
||||
)
|
||||
|
||||
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
||||
"""Runloop devbox에서 여러 파일을 다운로드합니다.
|
||||
|
||||
Runloop API를 사용하여 파일을 개별적으로 다운로드합니다. 순서를 유지하고
|
||||
예외를 발생시키는 대신 파일별 오류를 보고하는 FileDownloadResponse 객체 목록을 반환합니다.
|
||||
|
||||
TODO: 표준화된 FileOperationError 코드를 사용하여 적절한 오류 처리를 구현해야 합니다.
|
||||
현재는 정상적인 동작(happy path)만 구현되어 있습니다.
|
||||
"""
|
||||
responses: list[FileDownloadResponse] = []
|
||||
for path in paths:
|
||||
# devboxes.download_file은 .read()를 노출하는 BinaryAPIResponse를 반환함
|
||||
resp = self._client.devboxes.download_file(self._devbox_id, path=path)
|
||||
content = resp.read()
|
||||
responses.append(FileDownloadResponse(path=path, content=content, error=None))
|
||||
|
||||
return responses
|
||||
|
||||
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
|
||||
"""Runloop devbox에 여러 파일을 업로드합니다.
|
||||
|
||||
Runloop API를 사용하여 파일을 개별적으로 업로드합니다. 순서를 유지하고
|
||||
예외를 발생시키는 대신 파일별 오류를 보고하는 FileUploadResponse 객체 목록을 반환합니다.
|
||||
|
||||
TODO: 표준화된 FileOperationError 코드를 사용하여 적절한 오류 처리를 구현해야 합니다.
|
||||
현재는 정상적인 동작(happy path)만 구현되어 있습니다.
|
||||
"""
|
||||
responses: list[FileUploadResponse] = []
|
||||
for path, content in files:
|
||||
# Runloop 클라이언트는 'file'을 바이트 또는 파일류 객체로 기대함
|
||||
self._client.devboxes.upload_file(self._devbox_id, path=path, file=content)
|
||||
responses.append(FileUploadResponse(path=path, error=None))
|
||||
|
||||
return responses
|
||||
@@ -0,0 +1,345 @@
|
||||
"""컨텍스트 매니저를 통한 샌드박스 수명 주기 관리."""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import string
|
||||
import time
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from deepagents.backends.protocol import SandboxBackendProtocol
|
||||
|
||||
from deepagents_cli.config import console
|
||||
|
||||
|
||||
def _run_sandbox_setup(backend: SandboxBackendProtocol, setup_script_path: str) -> None:
|
||||
"""환경 변수 확장을 포함하여 샌드박스에서 사용자 설정 스크립트를 실행합니다.
|
||||
|
||||
Args:
|
||||
backend: 샌드박스 백엔드 인스턴스
|
||||
setup_script_path: 설정 스크립트 파일 경로
|
||||
"""
|
||||
script_path = Path(setup_script_path)
|
||||
if not script_path.exists():
|
||||
msg = f"설정 스크립트를 찾을 수 없습니다: {setup_script_path}"
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
console.print(f"[dim]설정 스크립트 실행 중: {setup_script_path}...[/dim]")
|
||||
|
||||
# 스크립트 내용 읽기
|
||||
script_content = script_path.read_text()
|
||||
|
||||
# 로컬 환경을 사용하여 ${VAR} 구문 확장
|
||||
template = string.Template(script_content)
|
||||
expanded_script = template.safe_substitute(os.environ)
|
||||
|
||||
# 5분 타임아웃으로 샌드박스에서 실행
|
||||
result = backend.execute(f"bash -c {shlex.quote(expanded_script)}")
|
||||
|
||||
if result.exit_code != 0:
|
||||
console.print(f"[red]❌ 설정 스크립트 실패 (종료 코드 {result.exit_code}):[/red]")
|
||||
console.print(f"[dim]{result.output}[/dim]")
|
||||
msg = "설정 실패 - 중단됨"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
console.print("[green]✓ 설정 완료[/green]")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def create_modal_sandbox(
|
||||
*, sandbox_id: str | None = None, setup_script_path: str | None = None
|
||||
) -> Generator[SandboxBackendProtocol, None, None]:
|
||||
"""Modal 샌드박스를 생성하거나 연결합니다.
|
||||
|
||||
Args:
|
||||
sandbox_id: 재사용할 기존 샌드박스 ID (선택 사항)
|
||||
setup_script_path: 샌드박스 시작 후 실행할 설정 스크립트 경로 (선택 사항)
|
||||
|
||||
Yields:
|
||||
(ModalBackend, sandbox_id)
|
||||
|
||||
Raises:
|
||||
ImportError: Modal SDK가 설치되지 않음
|
||||
Exception: 샌드박스 생성/연결 실패
|
||||
FileNotFoundError: 설정 스크립트를 찾을 수 없음
|
||||
RuntimeError: 설정 스크립트 실패
|
||||
"""
|
||||
import modal
|
||||
|
||||
from deepagents_cli.integrations.modal import ModalBackend
|
||||
|
||||
console.print("[yellow]Modal 샌드박스 시작 중...[/yellow]")
|
||||
|
||||
# 임시 앱 생성 (종료 시 자동 정리)
|
||||
app = modal.App("deepagents-sandbox")
|
||||
|
||||
with app.run():
|
||||
if sandbox_id:
|
||||
sandbox = modal.Sandbox.from_id(sandbox_id=sandbox_id, app=app)
|
||||
should_cleanup = False
|
||||
else:
|
||||
sandbox = modal.Sandbox.create(app=app, workdir="/workspace")
|
||||
should_cleanup = True
|
||||
|
||||
# 실행될 때까지 폴링 (Modal에서 필요)
|
||||
for _ in range(90): # 180초 타임아웃 (90 * 2초)
|
||||
if sandbox.poll() is not None: # 샌드박스가 예기치 않게 종료됨
|
||||
msg = "시작 중 Modal 샌드박스가 예기치 않게 종료되었습니다"
|
||||
raise RuntimeError(msg)
|
||||
# 간단한 명령을 시도하여 샌드박스가 준비되었는지 확인
|
||||
try:
|
||||
process = sandbox.exec("echo", "ready", timeout=5)
|
||||
process.wait()
|
||||
if process.returncode == 0:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(2)
|
||||
else:
|
||||
# 타임아웃 - 정리 및 실패 처리
|
||||
sandbox.terminate()
|
||||
msg = "180초 이내에 Modal 샌드박스를 시작하지 못했습니다"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
backend = ModalBackend(sandbox)
|
||||
console.print(f"[green]✓ Modal 샌드박스 준비 완료: {backend.id}[/green]")
|
||||
|
||||
# 설정 스크립트가 제공된 경우 실행
|
||||
if setup_script_path:
|
||||
_run_sandbox_setup(backend, setup_script_path)
|
||||
try:
|
||||
yield backend
|
||||
finally:
|
||||
if should_cleanup:
|
||||
try:
|
||||
console.print(f"[dim]Modal 샌드박스 {sandbox_id} 종료 중...[/dim]")
|
||||
sandbox.terminate()
|
||||
console.print(f"[dim]✓ Modal 샌드박스 {sandbox_id} 종료됨[/dim]")
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]⚠ 정리 실패: {e}[/yellow]")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def create_runloop_sandbox(
|
||||
*, sandbox_id: str | None = None, setup_script_path: str | None = None
|
||||
) -> Generator[SandboxBackendProtocol, None, None]:
|
||||
"""Runloop devbox를 생성하거나 연결합니다.
|
||||
|
||||
Args:
|
||||
sandbox_id: 재사용할 기존 devbox ID (선택 사항)
|
||||
setup_script_path: 샌드박스 시작 후 실행할 설정 스크립트 경로 (선택 사항)
|
||||
|
||||
Yields:
|
||||
(RunloopBackend, devbox_id)
|
||||
|
||||
Raises:
|
||||
ImportError: Runloop SDK가 설치되지 않음
|
||||
ValueError: RUNLOOP_API_KEY가 설정되지 않음
|
||||
RuntimeError: 타임아웃 내에 devbox를 시작하지 못함
|
||||
FileNotFoundError: 설정 스크립트를 찾을 수 없음
|
||||
RuntimeError: 설정 스크립트 실패
|
||||
"""
|
||||
from runloop_api_client import Runloop
|
||||
|
||||
from deepagents_cli.integrations.runloop import RunloopBackend
|
||||
|
||||
bearer_token = os.environ.get("RUNLOOP_API_KEY")
|
||||
if not bearer_token:
|
||||
msg = "RUNLOOP_API_KEY 환경 변수가 설정되지 않았습니다"
|
||||
raise ValueError(msg)
|
||||
|
||||
client = Runloop(bearer_token=bearer_token)
|
||||
|
||||
console.print("[yellow]Runloop devbox 시작 중...[/yellow]")
|
||||
|
||||
if sandbox_id:
|
||||
devbox = client.devboxes.retrieve(id=sandbox_id)
|
||||
should_cleanup = False
|
||||
else:
|
||||
devbox = client.devboxes.create()
|
||||
sandbox_id = devbox.id
|
||||
should_cleanup = True
|
||||
|
||||
# 실행될 때까지 폴링 (Runloop에서 필요)
|
||||
for _ in range(90): # 180초 타임아웃 (90 * 2초)
|
||||
status = client.devboxes.retrieve(id=devbox.id)
|
||||
if status.status == "running":
|
||||
break
|
||||
time.sleep(2)
|
||||
else:
|
||||
# 타임아웃 - 정리 및 실패 처리
|
||||
client.devboxes.shutdown(id=devbox.id)
|
||||
msg = "180초 이내에 devbox를 시작하지 못했습니다"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
console.print(f"[green]✓ Runloop devbox 준비 완료: {sandbox_id}[/green]")
|
||||
|
||||
backend = RunloopBackend(devbox_id=devbox.id, client=client)
|
||||
|
||||
# 설정 스크립트가 제공된 경우 실행
|
||||
if setup_script_path:
|
||||
_run_sandbox_setup(backend, setup_script_path)
|
||||
try:
|
||||
yield backend
|
||||
finally:
|
||||
if should_cleanup:
|
||||
try:
|
||||
console.print(f"[dim]Runloop devbox {sandbox_id} 종료 중...[/dim]")
|
||||
client.devboxes.shutdown(id=devbox.id)
|
||||
console.print(f"[dim]✓ Runloop devbox {sandbox_id} 종료됨[/dim]")
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]⚠ 정리 실패: {e}[/yellow]")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def create_daytona_sandbox(
|
||||
*, sandbox_id: str | None = None, setup_script_path: str | None = None
|
||||
) -> Generator[SandboxBackendProtocol, None, None]:
|
||||
"""Daytona 샌드박스를 생성합니다.
|
||||
|
||||
Args:
|
||||
sandbox_id: 재사용할 기존 샌드박스 ID (선택 사항)
|
||||
setup_script_path: 샌드박스 시작 후 실행할 설정 스크립트 경로 (선택 사항)
|
||||
|
||||
Yields:
|
||||
(DaytonaBackend, sandbox_id)
|
||||
|
||||
Note:
|
||||
ID로 기존 Daytona 샌드박스에 연결하는 기능은 아직 지원되지 않을 수 있습니다.
|
||||
sandbox_id가 제공되면 NotImplementedError가 발생합니다.
|
||||
"""
|
||||
from daytona import Daytona, DaytonaConfig
|
||||
|
||||
from deepagents_cli.integrations.daytona import DaytonaBackend
|
||||
|
||||
api_key = os.environ.get("DAYTONA_API_KEY")
|
||||
if not api_key:
|
||||
msg = "DAYTONA_API_KEY 환경 변수가 설정되지 않았습니다"
|
||||
raise ValueError(msg)
|
||||
|
||||
if sandbox_id:
|
||||
msg = (
|
||||
"ID로 기존 Daytona 샌드박스에 연결하는 기능은 아직 지원되지 않습니다. "
|
||||
"--sandbox-id를 생략하여 새 샌드박스를 생성하십시오."
|
||||
)
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
console.print("[yellow]Daytona 샌드박스 시작 중...[/yellow]")
|
||||
|
||||
daytona = Daytona(DaytonaConfig(api_key=api_key))
|
||||
sandbox = daytona.create()
|
||||
sandbox_id = sandbox.id
|
||||
|
||||
# 실행될 때까지 폴링 (Daytona에서 필요)
|
||||
for _ in range(90): # 180초 타임아웃 (90 * 2초)
|
||||
# 간단한 명령을 시도하여 샌드박스가 준비되었는지 확인
|
||||
try:
|
||||
result = sandbox.process.exec("echo ready", timeout=5)
|
||||
if result.exit_code == 0:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(2)
|
||||
else:
|
||||
try:
|
||||
# 가능한 경우 정리
|
||||
sandbox.delete()
|
||||
finally:
|
||||
msg = "180초 이내에 Daytona 샌드박스를 시작하지 못했습니다"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
backend = DaytonaBackend(sandbox)
|
||||
console.print(f"[green]✓ Daytona 샌드박스 준비 완료: {backend.id}[/green]")
|
||||
|
||||
# 설정 스크립트가 제공된 경우 실행
|
||||
if setup_script_path:
|
||||
_run_sandbox_setup(backend, setup_script_path)
|
||||
try:
|
||||
yield backend
|
||||
finally:
|
||||
console.print(f"[dim]Daytona 샌드박스 {sandbox_id} 삭제 중...[/dim]")
|
||||
try:
|
||||
sandbox.delete()
|
||||
console.print(f"[dim]✓ Daytona 샌드박스 {sandbox_id} 종료됨[/dim]")
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]⚠ 정리 실패: {e}[/yellow]")
|
||||
|
||||
|
||||
# 공급자별 작업 디렉토리 매핑
|
||||
_PROVIDER_TO_WORKING_DIR = {
|
||||
"modal": "/workspace",
|
||||
"runloop": "/home/user",
|
||||
"daytona": "/home/daytona",
|
||||
}
|
||||
|
||||
|
||||
# 샌드박스 유형과 해당 컨텍스트 매니저 팩토리 매핑
|
||||
_SANDBOX_PROVIDERS = {
|
||||
"modal": create_modal_sandbox,
|
||||
"runloop": create_runloop_sandbox,
|
||||
"daytona": create_daytona_sandbox,
|
||||
}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def create_sandbox(
|
||||
provider: str,
|
||||
*,
|
||||
sandbox_id: str | None = None,
|
||||
setup_script_path: str | None = None,
|
||||
) -> Generator[SandboxBackendProtocol, None, None]:
|
||||
"""지정된 공급자의 샌드박스를 생성하거나 연결합니다.
|
||||
|
||||
이것은 적절한 공급자별 컨텍스트 매니저에 위임하는 샌드박스 생성을 위한 통합 인터페이스입니다.
|
||||
|
||||
Args:
|
||||
provider: 샌드박스 공급자 ("modal", "runloop", "daytona")
|
||||
sandbox_id: 재사용할 기존 샌드박스 ID (선택 사항)
|
||||
setup_script_path: 샌드박스 시작 후 실행할 설정 스크립트 경로 (선택 사항)
|
||||
|
||||
Yields:
|
||||
(SandboxBackend, sandbox_id)
|
||||
"""
|
||||
if provider not in _SANDBOX_PROVIDERS:
|
||||
msg = f"알 수 없는 샌드박스 공급자: {provider}. 사용 가능한 공급자: {', '.join(get_available_sandbox_types())}"
|
||||
raise ValueError(msg)
|
||||
|
||||
sandbox_provider = _SANDBOX_PROVIDERS[provider]
|
||||
|
||||
with sandbox_provider(sandbox_id=sandbox_id, setup_script_path=setup_script_path) as backend:
|
||||
yield backend
|
||||
|
||||
|
||||
def get_available_sandbox_types() -> list[str]:
|
||||
"""사용 가능한 샌드박스 공급자 유형 목록을 가져옵니다.
|
||||
|
||||
Returns:
|
||||
샌드박스 유형 이름 목록 (예: ["modal", "runloop", "daytona"])
|
||||
"""
|
||||
return list(_SANDBOX_PROVIDERS.keys())
|
||||
|
||||
|
||||
def get_default_working_dir(provider: str) -> str:
|
||||
"""주어진 샌드박스 공급자의 기본 작업 디렉토리를 가져옵니다.
|
||||
|
||||
Args:
|
||||
provider: 샌드박스 공급자 이름 ("modal", "runloop", "daytona")
|
||||
|
||||
Returns:
|
||||
기본 작업 디렉토리 경로 (문자열)
|
||||
|
||||
Raises:
|
||||
ValueError: 공급자를 알 수 없는 경우
|
||||
"""
|
||||
if provider in _PROVIDER_TO_WORKING_DIR:
|
||||
return _PROVIDER_TO_WORKING_DIR[provider]
|
||||
msg = f"알 수 없는 샌드박스 공급자: {provider}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"create_sandbox",
|
||||
"get_available_sandbox_types",
|
||||
"get_default_working_dir",
|
||||
]
|
||||
468
deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/main.py
Normal file
@@ -0,0 +1,468 @@
|
||||
"""DeepAgents를 위한 메인 진입점 및 CLI 루프."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from deepagents.backends.protocol import SandboxBackendProtocol
|
||||
|
||||
# Now safe to import agent (which imports LangChain modules)
|
||||
from deepagents_cli.agent import create_cli_agent, list_agents, reset_agent
|
||||
from deepagents_cli.commands import execute_bash_command, handle_command
|
||||
|
||||
# CRITICAL: Import config FIRST to set LANGSMITH_PROJECT before LangChain loads
|
||||
from deepagents_cli.config import (
|
||||
COLORS,
|
||||
DEEP_AGENTS_ASCII,
|
||||
SessionState,
|
||||
console,
|
||||
create_model,
|
||||
settings,
|
||||
)
|
||||
from deepagents_cli.execution import execute_task
|
||||
from deepagents_cli.input import ImageTracker, create_prompt_session
|
||||
from deepagents_cli.integrations.sandbox_factory import (
|
||||
create_sandbox,
|
||||
get_default_working_dir,
|
||||
)
|
||||
from deepagents_cli.skills import execute_skills_command, setup_skills_parser
|
||||
from deepagents_cli.tools import fetch_url, http_request, web_search
|
||||
from deepagents_cli.ui import TokenTracker, show_help
|
||||
|
||||
|
||||
def check_cli_dependencies() -> None:
|
||||
"""CLI 선택적 종속성이 설치되어 있는지 확인합니다."""
|
||||
missing = []
|
||||
|
||||
try:
|
||||
import rich
|
||||
except ImportError:
|
||||
missing.append("rich")
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
missing.append("requests")
|
||||
|
||||
try:
|
||||
import dotenv
|
||||
except ImportError:
|
||||
missing.append("python-dotenv")
|
||||
|
||||
try:
|
||||
import tavily
|
||||
except ImportError:
|
||||
missing.append("tavily-python")
|
||||
|
||||
try:
|
||||
import prompt_toolkit
|
||||
except ImportError:
|
||||
missing.append("prompt-toolkit")
|
||||
|
||||
if missing:
|
||||
print("\n❌ 필수 CLI 종속성이 누락되었습니다!")
|
||||
print("\nDeepAgents CLI를 사용하려면 다음 패키지가 필요합니다:")
|
||||
for pkg in missing:
|
||||
print(f" - {pkg}")
|
||||
print("\n다음 명령으로 설치하십시오:")
|
||||
print(" pip install deepagents[cli]")
|
||||
print("\n또는 모든 종속성을 설치하십시오:")
|
||||
print(" pip install 'deepagents[cli]'")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def parse_args():
|
||||
"""명령줄 인수를 파싱합니다."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="DeepAgents - AI 코딩 도우미",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
add_help=False,
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="실행할 명령")
|
||||
|
||||
# List command
|
||||
subparsers.add_parser("list", help="사용 가능한 모든 에이전트 나열")
|
||||
|
||||
# Help command
|
||||
subparsers.add_parser("help", help="도움말 정보 표시")
|
||||
|
||||
# Reset command
|
||||
reset_parser = subparsers.add_parser("reset", help="에이전트 초기화")
|
||||
reset_parser.add_argument("--agent", required=True, help="초기화할 에이전트 이름")
|
||||
reset_parser.add_argument("--target", dest="source_agent", help="다른 에이전트에서 프롬프트 복사")
|
||||
|
||||
# Skills command - setup delegated to skills module
|
||||
setup_skills_parser(subparsers)
|
||||
|
||||
# Default interactive mode
|
||||
parser.add_argument(
|
||||
"--agent",
|
||||
default="agent",
|
||||
help="별도의 메모리 저장소를 위한 에이전트 식별자 (기본값: agent).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
help="사용할 모델 (예: claude-sonnet-4-5-20250929, gpt-5-mini, gemini-3-pro-preview). 모델 이름에서 공급자가 자동 감지됩니다.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--auto-approve",
|
||||
action="store_true",
|
||||
help="프롬프트 없이 도구 사용 자동 승인 (human-in-the-loop 비활성화)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sandbox",
|
||||
choices=["none", "modal", "daytona", "runloop"],
|
||||
default="none",
|
||||
help="코드 실행을 위한 원격 샌드박스 (기본값: none - 로컬 전용)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sandbox-id",
|
||||
help="재사용할 기존 샌드박스 ID (생성 및 정리 건너뜀)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sandbox-setup",
|
||||
help="생성 후 샌드박스에서 실행할 설정 스크립트 경로",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-splash",
|
||||
action="store_true",
|
||||
help="시작 스플래시 화면 비활성화",
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
async def simple_cli(
|
||||
agent,
|
||||
assistant_id: str | None,
|
||||
session_state,
|
||||
baseline_tokens: int = 0,
|
||||
backend=None,
|
||||
sandbox_type: str | None = None,
|
||||
setup_script_path: str | None = None,
|
||||
no_splash: bool = False,
|
||||
) -> None:
|
||||
"""메인 CLI 루프.
|
||||
|
||||
Args:
|
||||
backend: 파일 작업을 위한 백엔드 (CompositeBackend)
|
||||
sandbox_type: 사용 중인 샌드박스 유형 (예: "modal", "runloop", "daytona").
|
||||
None인 경우 로컬 모드에서 실행.
|
||||
sandbox_id: 활성 샌드박스의 ID
|
||||
setup_script_path: 실행된 설정 스크립트 경로 (있는 경우)
|
||||
no_splash: True인 경우 시작 스플래시 화면 표시 건너뜀
|
||||
"""
|
||||
console.clear()
|
||||
if not no_splash:
|
||||
console.print(DEEP_AGENTS_ASCII, style=f"bold {COLORS['primary']}")
|
||||
console.print()
|
||||
|
||||
# Extract sandbox ID from backend if using sandbox mode
|
||||
sandbox_id: str | None = None
|
||||
if backend:
|
||||
from deepagents.backends.composite import CompositeBackend
|
||||
|
||||
# Check if it's a CompositeBackend with a sandbox default backend
|
||||
if isinstance(backend, CompositeBackend):
|
||||
if isinstance(backend.default, SandboxBackendProtocol):
|
||||
sandbox_id = backend.default.id
|
||||
elif isinstance(backend, SandboxBackendProtocol):
|
||||
sandbox_id = backend.id
|
||||
|
||||
# Display sandbox info persistently (survives console.clear())
|
||||
if sandbox_type and sandbox_id:
|
||||
console.print(f"[yellow]⚡ {sandbox_type.capitalize()} 샌드박스: {sandbox_id}[/yellow]")
|
||||
if setup_script_path:
|
||||
console.print(f"[green]✓ 설정 스크립트 ({setup_script_path}) 완료됨[/green]")
|
||||
console.print()
|
||||
|
||||
# Display model info
|
||||
if settings.model_name and settings.model_provider:
|
||||
provider_display = {
|
||||
"openai": "OpenAI",
|
||||
"anthropic": "Anthropic",
|
||||
"google": "Google",
|
||||
}.get(settings.model_provider, settings.model_provider)
|
||||
console.print(
|
||||
f"[green]✓ Model:[/green] {provider_display} → '{settings.model_name}'",
|
||||
style=COLORS["dim"],
|
||||
)
|
||||
console.print()
|
||||
|
||||
if not settings.has_tavily:
|
||||
console.print(
|
||||
"[yellow]⚠ 웹 검색 비활성화됨:[/yellow] TAVILY_API_KEY를 찾을 수 없습니다.",
|
||||
style=COLORS["dim"],
|
||||
)
|
||||
console.print(" 웹 검색을 활성화하려면 Tavily API 키를 설정하세요:", style=COLORS["dim"])
|
||||
console.print(" export TAVILY_API_KEY=your_api_key_here", style=COLORS["dim"])
|
||||
console.print(
|
||||
" 또는 .env 파일에 추가하세요. 키 발급: https://tavily.com",
|
||||
style=COLORS["dim"],
|
||||
)
|
||||
console.print()
|
||||
|
||||
if settings.has_deepagents_langchain_project:
|
||||
console.print(
|
||||
f"[green]✓ LangSmith 추적 활성화됨:[/green] Deepagents → '{settings.deepagents_langchain_project}'",
|
||||
style=COLORS["dim"],
|
||||
)
|
||||
if settings.user_langchain_project:
|
||||
console.print(f" [dim]사용자 코드 (shell) → '{settings.user_langchain_project}'[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("... 코딩 준비 완료! 무엇을 만들고 싶으신가요?", style=COLORS["agent"])
|
||||
|
||||
if sandbox_type:
|
||||
working_dir = get_default_working_dir(sandbox_type)
|
||||
console.print(f" [dim]로컬 CLI 디렉터리: {Path.cwd()}[/dim]")
|
||||
console.print(f" [dim]코드 실행: 원격 샌드박스 ({working_dir})[/dim]")
|
||||
else:
|
||||
console.print(f" [dim]작업 디렉터리: {Path.cwd()}[/dim]")
|
||||
|
||||
console.print()
|
||||
|
||||
if session_state.auto_approve:
|
||||
console.print(" [yellow]⚡ 자동 승인: 켜짐[/yellow] [dim](확인 없이 도구 실행)[/dim]")
|
||||
console.print()
|
||||
|
||||
# Localize modifier names and show key symbols (macOS vs others)
|
||||
if sys.platform == "darwin":
|
||||
tips = (
|
||||
" 팁: ⏎ Enter로 제출, ⌥ Option + ⏎ Enter로 줄바꿈 (또는 Esc+Enter), "
|
||||
"⌃E로 편집기 열기, ⌃T로 자동 승인 전환, ⌃C로 중단"
|
||||
)
|
||||
else:
|
||||
tips = (
|
||||
" 팁: Enter로 제출, Alt+Enter (또는 Esc+Enter)로 줄바꿈, "
|
||||
"Ctrl+E로 편집기 열기, Ctrl+T로 자동 승인 전환, Ctrl+C로 중단"
|
||||
)
|
||||
console.print(tips, style=f"dim {COLORS['dim']}")
|
||||
|
||||
console.print()
|
||||
|
||||
# Create prompt session, image tracker, and token tracker
|
||||
image_tracker = ImageTracker()
|
||||
session = create_prompt_session(assistant_id, session_state, image_tracker=image_tracker)
|
||||
token_tracker = TokenTracker()
|
||||
token_tracker.set_baseline(baseline_tokens)
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_input = await session.prompt_async()
|
||||
if session_state.exit_hint_handle:
|
||||
session_state.exit_hint_handle.cancel()
|
||||
session_state.exit_hint_handle = None
|
||||
session_state.exit_hint_until = None
|
||||
user_input = user_input.strip()
|
||||
except EOFError:
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n안녕히 가세요!", style=COLORS["primary"])
|
||||
break
|
||||
|
||||
if not user_input:
|
||||
continue
|
||||
|
||||
# Check for slash commands first
|
||||
if user_input.startswith("/"):
|
||||
result = handle_command(user_input, agent, token_tracker)
|
||||
if result == "exit":
|
||||
console.print("\n안녕히 가세요!", style=COLORS["primary"])
|
||||
break
|
||||
if result:
|
||||
# Command was handled, continue to next input
|
||||
continue
|
||||
|
||||
# Check for bash commands (!)
|
||||
if user_input.startswith("!"):
|
||||
execute_bash_command(user_input)
|
||||
continue
|
||||
|
||||
# Handle regular quit keywords
|
||||
if user_input.lower() in ["quit", "exit", "q"]:
|
||||
console.print("\n안녕히 가세요!", style=COLORS["primary"])
|
||||
break
|
||||
|
||||
await execute_task(
|
||||
user_input,
|
||||
agent,
|
||||
assistant_id,
|
||||
session_state,
|
||||
token_tracker,
|
||||
backend=backend,
|
||||
image_tracker=image_tracker,
|
||||
)
|
||||
|
||||
|
||||
async def _run_agent_session(
|
||||
model,
|
||||
assistant_id: str,
|
||||
session_state,
|
||||
sandbox_backend=None,
|
||||
sandbox_type: str | None = None,
|
||||
setup_script_path: str | None = None,
|
||||
) -> None:
|
||||
"""에이전트를 생성하고 CLI 세션을 실행하는 도우미.
|
||||
|
||||
샌드박스 모드와 로컬 모드 간의 중복을 피하기 위해 추출되었습니다.
|
||||
|
||||
Args:
|
||||
model: 사용할 LLM 모델
|
||||
assistant_id: 메모리 저장을 위한 에이전트 식별자
|
||||
session_state: 자동 승인 설정이 포함된 세션 상태
|
||||
sandbox_backend: 원격 실행을 위한 선택적 샌드박스 백엔드
|
||||
sandbox_type: 사용 중인 샌드박스 유형
|
||||
setup_script_path: 실행된 설정 스크립트 경로 (있는 경우)
|
||||
"""
|
||||
# Create agent with conditional tools
|
||||
tools = [http_request, fetch_url]
|
||||
if settings.has_tavily:
|
||||
tools.append(web_search)
|
||||
|
||||
agent, composite_backend = create_cli_agent(
|
||||
model=model,
|
||||
assistant_id=assistant_id,
|
||||
tools=tools,
|
||||
sandbox=sandbox_backend,
|
||||
sandbox_type=sandbox_type,
|
||||
auto_approve=session_state.auto_approve,
|
||||
)
|
||||
|
||||
# Calculate baseline token count for accurate token tracking
|
||||
from .agent import get_system_prompt
|
||||
from .token_utils import calculate_baseline_tokens
|
||||
|
||||
agent_dir = settings.get_agent_dir(assistant_id)
|
||||
system_prompt = get_system_prompt(assistant_id=assistant_id, sandbox_type=sandbox_type)
|
||||
baseline_tokens = calculate_baseline_tokens(model, agent_dir, system_prompt, assistant_id)
|
||||
|
||||
await simple_cli(
|
||||
agent,
|
||||
assistant_id,
|
||||
session_state,
|
||||
baseline_tokens,
|
||||
backend=composite_backend,
|
||||
sandbox_type=sandbox_type,
|
||||
setup_script_path=setup_script_path,
|
||||
no_splash=session_state.no_splash,
|
||||
)
|
||||
|
||||
|
||||
async def main(
|
||||
assistant_id: str,
|
||||
session_state,
|
||||
sandbox_type: str = "none",
|
||||
sandbox_id: str | None = None,
|
||||
setup_script_path: str | None = None,
|
||||
model_name: str | None = None,
|
||||
) -> None:
|
||||
"""조건부 샌드박스 지원이 포함된 메인 진입점.
|
||||
|
||||
Args:
|
||||
assistant_id: 메모리 저장을 위한 에이전트 식별자
|
||||
session_state: 자동 승인 설정이 포함된 세션 상태
|
||||
sandbox_type: 샌드박스 유형 ("none", "modal", "runloop", "daytona")
|
||||
sandbox_id: 재사용할 선택적 기존 샌드박스 ID
|
||||
setup_script_path: 샌드박스에서 실행할 선택적 설정 스크립트 경로
|
||||
model_name: 환경 변수 대신 사용할 선택적 모델 이름
|
||||
"""
|
||||
model = create_model(model_name)
|
||||
|
||||
# Branch 1: User wants a sandbox
|
||||
if sandbox_type != "none":
|
||||
# Try to create sandbox
|
||||
try:
|
||||
console.print()
|
||||
with create_sandbox(
|
||||
sandbox_type, sandbox_id=sandbox_id, setup_script_path=setup_script_path
|
||||
) as sandbox_backend:
|
||||
console.print(f"[yellow]⚡ 원격 실행 활성화됨 ({sandbox_type})[/yellow]")
|
||||
console.print()
|
||||
|
||||
await _run_agent_session(
|
||||
model,
|
||||
assistant_id,
|
||||
session_state,
|
||||
sandbox_backend,
|
||||
sandbox_type=sandbox_type,
|
||||
setup_script_path=setup_script_path,
|
||||
)
|
||||
except (ImportError, ValueError, RuntimeError, NotImplementedError) as e:
|
||||
# Sandbox creation failed - fail hard (no silent fallback)
|
||||
console.print()
|
||||
console.print("[red]❌ 샌드박스 생성 실패[/red]")
|
||||
console.print(f"[dim]{e}[/dim]")
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n\n[yellow]중단됨[/yellow]")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
console.print(f"\n[bold red]❌ 오류:[/bold red] {e}\n")
|
||||
console.print_exception()
|
||||
sys.exit(1)
|
||||
|
||||
# Branch 2: User wants local mode (none or default)
|
||||
else:
|
||||
try:
|
||||
await _run_agent_session(model, assistant_id, session_state, sandbox_backend=None)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n\n[yellow]중단됨[/yellow]")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
console.print(f"\n[bold red]❌ 오류:[/bold red] {e}\n")
|
||||
console.print_exception()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cli_main() -> None:
|
||||
"""콘솔 스크립트 진입점."""
|
||||
# Fix for gRPC fork issue on macOS
|
||||
# https://github.com/grpc/grpc/issues/37642
|
||||
if sys.platform == "darwin":
|
||||
os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "0"
|
||||
|
||||
# Note: LANGSMITH_PROJECT is already overridden in config.py (before LangChain imports)
|
||||
# This ensures agent traces → DEEPAGENTS_LANGSMITH_PROJECT
|
||||
# Shell commands → user's original LANGSMITH_PROJECT (via ShellMiddleware env)
|
||||
|
||||
# Check dependencies first
|
||||
check_cli_dependencies()
|
||||
|
||||
try:
|
||||
args = parse_args()
|
||||
|
||||
if args.command == "help":
|
||||
show_help()
|
||||
elif args.command == "list":
|
||||
list_agents()
|
||||
elif args.command == "reset":
|
||||
reset_agent(args.agent, args.source_agent)
|
||||
elif args.command == "skills":
|
||||
execute_skills_command(args)
|
||||
else:
|
||||
# Create session state from args
|
||||
session_state = SessionState(auto_approve=args.auto_approve, no_splash=args.no_splash)
|
||||
|
||||
# API key validation happens in create_model()
|
||||
asyncio.run(
|
||||
main(
|
||||
args.agent,
|
||||
session_state,
|
||||
args.sandbox,
|
||||
args.sandbox_id,
|
||||
args.sandbox_setup,
|
||||
getattr(args, "model", None),
|
||||
)
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
# Clean exit on Ctrl+C - suppress ugly traceback
|
||||
console.print("\n\n[yellow]중단됨[/yellow]")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli_main()
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Utilities for project root detection and project-specific configuration."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def find_project_root(start_path: Path | None = None) -> Path | None:
|
||||
"""Find the project root by looking for .git directory.
|
||||
|
||||
Walks up the directory tree from start_path (or cwd) looking for a .git
|
||||
directory, which indicates the project root.
|
||||
|
||||
Args:
|
||||
start_path: Directory to start searching from. Defaults to current working directory.
|
||||
|
||||
Returns:
|
||||
Path to the project root if found, None otherwise.
|
||||
"""
|
||||
current = Path(start_path or Path.cwd()).resolve()
|
||||
|
||||
# Walk up the directory tree
|
||||
for parent in [current, *list(current.parents)]:
|
||||
git_dir = parent / ".git"
|
||||
if git_dir.exists():
|
||||
return parent
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_project_agent_md(project_root: Path) -> list[Path]:
|
||||
"""Find project-specific agent.md file(s).
|
||||
|
||||
Checks two locations and returns ALL that exist:
|
||||
1. project_root/.deepagents/agent.md
|
||||
2. project_root/agent.md
|
||||
|
||||
Both files will be loaded and combined if both exist.
|
||||
|
||||
Args:
|
||||
project_root: Path to the project root directory.
|
||||
|
||||
Returns:
|
||||
List of paths to project agent.md files (may contain 0, 1, or 2 paths).
|
||||
"""
|
||||
paths = []
|
||||
|
||||
# Check .deepagents/agent.md (preferred)
|
||||
deepagents_md = project_root / ".deepagents" / "agent.md"
|
||||
if deepagents_md.exists():
|
||||
paths.append(deepagents_md)
|
||||
|
||||
# Check root agent.md (fallback, but also include if both exist)
|
||||
root_md = project_root / "agent.md"
|
||||
if root_md.exists():
|
||||
paths.append(root_md)
|
||||
|
||||
return paths
|
||||
@@ -0,0 +1,138 @@
|
||||
"""에이전트에 기본 셸 도구를 노출하는 단순화된 미들웨어."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from langchain.agents.middleware.types import AgentMiddleware, AgentState
|
||||
from langchain.tools import ToolRuntime, tool
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langchain_core.tools.base import ToolException
|
||||
|
||||
|
||||
class ShellMiddleware(AgentMiddleware[AgentState, Any]):
|
||||
"""shell을 통해 에이전트에게 기본 셸 액세스 권한을 부여합니다.
|
||||
|
||||
이 셸은 로컬 머신에서 실행되며 CLI 자체에서 제공하는 human-in-the-loop 안전장치 외에는
|
||||
어떠한 안전장치도 없습니다.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
workspace_root: str,
|
||||
timeout: float = 120.0,
|
||||
max_output_bytes: int = 100_000,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""`ShellMiddleware`의 인스턴스를 초기화합니다.
|
||||
|
||||
Args:
|
||||
workspace_root: 셸 명령을 위한 작업 디렉터리.
|
||||
timeout: 명령 완료를 기다리는 최대 시간(초).
|
||||
기본값은 120초입니다.
|
||||
max_output_bytes: 명령 출력에서 캡처할 최대 바이트 수.
|
||||
기본값은 100,000바이트입니다.
|
||||
env: 하위 프로세스에 전달할 환경 변수. None이면
|
||||
현재 프로세스의 환경을 사용합니다. 기본값은 None입니다.
|
||||
"""
|
||||
super().__init__()
|
||||
self._timeout = timeout
|
||||
self._max_output_bytes = max_output_bytes
|
||||
self._tool_name = "shell"
|
||||
self._env = env if env is not None else os.environ.copy()
|
||||
self._workspace_root = workspace_root
|
||||
|
||||
# Build description with workspace info
|
||||
description = (
|
||||
f"Execute shell commands directly on the host. Commands run in this working directory: "
|
||||
f"{workspace_root}. Each command runs in a fresh shell environment with the "
|
||||
f"current process's environment variables. Commands may be truncated if they exceed "
|
||||
f"configured timeout or output limits."
|
||||
)
|
||||
|
||||
@tool(self._tool_name, description=description)
|
||||
def shell_tool(
|
||||
command: str,
|
||||
runtime: ToolRuntime[None, AgentState],
|
||||
) -> ToolMessage | str:
|
||||
"""Execute a shell command.
|
||||
|
||||
Args:
|
||||
command: The shell command to execute.
|
||||
runtime: The tool runtime context.
|
||||
"""
|
||||
return self._run_shell_command(command, tool_call_id=runtime.tool_call_id)
|
||||
|
||||
self._shell_tool = shell_tool
|
||||
self.tools = [self._shell_tool]
|
||||
|
||||
def _run_shell_command(
|
||||
self,
|
||||
command: str,
|
||||
*,
|
||||
tool_call_id: str | None,
|
||||
) -> ToolMessage | str:
|
||||
"""셸 명령을 실행하고 결과를 반환합니다.
|
||||
|
||||
Args:
|
||||
command: 실행할 셸 명령.
|
||||
tool_call_id: ToolMessage 생성을 위한 도구 호출 ID.
|
||||
|
||||
Returns:
|
||||
명령 출력 또는 오류 메시지가 포함된 ToolMessage.
|
||||
"""
|
||||
if not command or not isinstance(command, str):
|
||||
msg = "Shell 도구는 비어 있지 않은 명령 문자열을 필요로 합니다."
|
||||
raise ToolException(msg)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self._timeout,
|
||||
env=self._env,
|
||||
cwd=self._workspace_root,
|
||||
)
|
||||
|
||||
# Combine stdout and stderr
|
||||
output_parts = []
|
||||
if result.stdout:
|
||||
output_parts.append(result.stdout)
|
||||
if result.stderr:
|
||||
stderr_lines = result.stderr.strip().split("\n")
|
||||
for line in stderr_lines:
|
||||
output_parts.append(f"[stderr] {line}")
|
||||
|
||||
output = "\n".join(output_parts) if output_parts else "<no output>"
|
||||
|
||||
# 필요한 경우 출력 자르기
|
||||
if len(output) > self._max_output_bytes:
|
||||
output = output[: self._max_output_bytes]
|
||||
output += f"\n\n... 출력이 {self._max_output_bytes}바이트에서 잘렸습니다."
|
||||
|
||||
# 0이 아닌 경우 종료 코드 정보 추가
|
||||
if result.returncode != 0:
|
||||
output = f"{output.rstrip()}\n\n종료 코드: {result.returncode}"
|
||||
status = "error"
|
||||
else:
|
||||
status = "success"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
output = f"오류: 명령이 {self._timeout:.1f}초 후에 시간 초과되었습니다."
|
||||
status = "error"
|
||||
|
||||
return ToolMessage(
|
||||
content=output,
|
||||
tool_call_id=tool_call_id,
|
||||
name=self._tool_name,
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["ShellMiddleware"]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""deepagents CLI를 위한 Skills 모듈.
|
||||
|
||||
공개 API:
|
||||
- SkillsMiddleware: 기술을 에이전트 실행에 통합하기 위한 미들웨어
|
||||
- execute_skills_command: 기술 하위 명령(list/create/info) 실행
|
||||
- setup_skills_parser: 기술 명령을 위한 argparse 설정
|
||||
|
||||
기타 모든 구성 요소는 내부 구현 세부 사항입니다.
|
||||
"""
|
||||
|
||||
from deepagents_cli.skills.commands import (
|
||||
execute_skills_command,
|
||||
setup_skills_parser,
|
||||
)
|
||||
from deepagents_cli.skills.middleware import SkillsMiddleware
|
||||
|
||||
__all__ = [
|
||||
"SkillsMiddleware",
|
||||
"execute_skills_command",
|
||||
"setup_skills_parser",
|
||||
]
|
||||
@@ -0,0 +1,486 @@
|
||||
"""기술 관리를 위한 CLI 명령.
|
||||
|
||||
이 명령들은 cli.py를 통해 CLI에 등록됩니다:
|
||||
- deepagents skills list --agent <agent> [--project]
|
||||
- deepagents skills create <name>
|
||||
- deepagents skills info <name>
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from deepagents_cli.config import COLORS, Settings, console
|
||||
from deepagents_cli.skills.load import MAX_SKILL_NAME_LENGTH, list_skills
|
||||
|
||||
|
||||
def _validate_name(name: str) -> tuple[bool, str]:
|
||||
"""Agent Skills 사양에 따라 이름을 검증합니다.
|
||||
|
||||
요구 사항 (https://agentskills.io/specification):
|
||||
- 최대 64자
|
||||
- 소문자 영숫자와 하이픈만 허용 (a-z, 0-9, -)
|
||||
- 하이픈으로 시작하거나 끝날 수 없음
|
||||
- 연속된 하이픈 허용 안 함
|
||||
- 경로 탐색 시퀀스 허용 안 함
|
||||
|
||||
Args:
|
||||
name: 검증할 이름
|
||||
|
||||
Returns:
|
||||
(유효 여부, 오류 메시지) 튜플. 유효한 경우 오류 메시지는 비어 있습니다.
|
||||
"""
|
||||
# 비어 있거나 공백만 있는 이름 확인
|
||||
if not name or not name.strip():
|
||||
return False, "비어 있을 수 없습니다"
|
||||
|
||||
# 길이 확인 (사양: 최대 64자)
|
||||
if len(name) > MAX_SKILL_NAME_LENGTH:
|
||||
return False, "64자를 초과할 수 없습니다"
|
||||
|
||||
# 경로 탐색 시퀀스 확인
|
||||
if ".." in name or "/" in name or "\\" in name:
|
||||
return False, "경로 요소를 포함할 수 없습니다"
|
||||
|
||||
# 사양: 소문자 영숫자와 하이픈만 허용
|
||||
# 패턴 보장: 시작/종료 하이픈 없음, 연속 하이픈 없음
|
||||
if not re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", name):
|
||||
return (
|
||||
False,
|
||||
"소문자, 숫자, 하이픈만 사용해야 합니다 (대문자, 밑줄 불가능, 하이픈으로 시작하거나 끝날 수 없음)",
|
||||
)
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def _validate_skill_path(skill_dir: Path, base_dir: Path) -> tuple[bool, str]:
|
||||
"""해결된 기술 디렉토리가 기본 디렉토리 내에 있는지 확인합니다.
|
||||
|
||||
Args:
|
||||
skill_dir: 검증할 기술 디렉토리 경로
|
||||
base_dir: skill_dir을 포함해야 하는 기본 기술 디렉토리
|
||||
|
||||
Returns:
|
||||
(유효 여부, 오류 메시지) 튜플. 유효한 경우 오류 메시지는 비어 있습니다.
|
||||
"""
|
||||
try:
|
||||
# 두 경로를 정식 형식으로 해결
|
||||
resolved_skill = skill_dir.resolve()
|
||||
resolved_base = base_dir.resolve()
|
||||
|
||||
# skill_dir이 base_dir 내에 있는지 확인
|
||||
# Python 3.9+인 경우 is_relative_to 사용, 그렇지 않으면 문자열 비교 사용
|
||||
if hasattr(resolved_skill, "is_relative_to"):
|
||||
if not resolved_skill.is_relative_to(resolved_base):
|
||||
return False, f"기술 디렉토리는 {base_dir} 내에 있어야 합니다"
|
||||
else:
|
||||
# 이전 Python 버전을 위한 폴백
|
||||
try:
|
||||
resolved_skill.relative_to(resolved_base)
|
||||
except ValueError:
|
||||
return False, f"기술 디렉토리는 {base_dir} 내에 있어야 합니다"
|
||||
|
||||
return True, ""
|
||||
except (OSError, RuntimeError) as e:
|
||||
return False, f"잘못된 경로: {e}"
|
||||
|
||||
|
||||
def _list(agent: str, *, project: bool = False) -> None:
|
||||
"""지정된 에이전트에 대해 사용 가능한 모든 기술을 나열합니다.
|
||||
|
||||
Args:
|
||||
agent: 기술을 위한 에이전트 식별자 (기본값: agent).
|
||||
project: True인 경우 프로젝트 기술만 표시합니다.
|
||||
False인 경우 모든 기술(사용자 + 프로젝트)을 표시합니다.
|
||||
"""
|
||||
settings = Settings.from_environment()
|
||||
user_skills_dir = settings.get_user_skills_dir(agent)
|
||||
project_skills_dir = settings.get_project_skills_dir()
|
||||
|
||||
# --project 플래그가 사용된 경우 프로젝트 기술만 표시
|
||||
if project:
|
||||
if not project_skills_dir:
|
||||
console.print("[yellow]프로젝트 디렉토리가 아닙니다.[/yellow]")
|
||||
console.print(
|
||||
"[dim]프로젝트 기술을 사용하려면 프로젝트 루트에 .git 디렉토리가 필요합니다.[/dim]",
|
||||
style=COLORS["dim"],
|
||||
)
|
||||
return
|
||||
|
||||
if not project_skills_dir.exists() or not any(project_skills_dir.iterdir()):
|
||||
console.print("[yellow]프로젝트 기술을 찾을 수 없습니다.[/yellow]")
|
||||
console.print(
|
||||
f"[dim]프로젝트 기술을 추가하면 {project_skills_dir}/ 에 생성됩니다.[/dim]",
|
||||
style=COLORS["dim"],
|
||||
)
|
||||
console.print(
|
||||
"\n[dim]프로젝트 기술 생성:\n deepagents skills create my-skill --project[/dim]",
|
||||
style=COLORS["dim"],
|
||||
)
|
||||
return
|
||||
|
||||
skills = list_skills(user_skills_dir=None, project_skills_dir=project_skills_dir)
|
||||
console.print("\n[bold]프로젝트 기술:[/bold]\n", style=COLORS["primary"])
|
||||
else:
|
||||
# 사용자 및 프로젝트 기술 모두 로드
|
||||
skills = list_skills(user_skills_dir=user_skills_dir, project_skills_dir=project_skills_dir)
|
||||
|
||||
if not skills:
|
||||
console.print("[yellow]기술을 찾을 수 없습니다.[/yellow]")
|
||||
console.print(
|
||||
"[dim]기술을 추가하면 ~/.deepagents/agent/skills/ 에 생성됩니다.[/dim]",
|
||||
style=COLORS["dim"],
|
||||
)
|
||||
console.print(
|
||||
"\n[dim]첫 번째 기술 생성:\n deepagents skills create my-skill[/dim]",
|
||||
style=COLORS["dim"],
|
||||
)
|
||||
return
|
||||
|
||||
console.print("\n[bold]사용 가능한 기술:[/bold]\n", style=COLORS["primary"])
|
||||
|
||||
# 출처별로 기술 그룹화
|
||||
user_skills = [s for s in skills if s["source"] == "user"]
|
||||
project_skills_list = [s for s in skills if s["source"] == "project"]
|
||||
|
||||
# 사용자 기술 표시
|
||||
if user_skills and not project:
|
||||
console.print("[bold cyan]사용자 기술:[/bold cyan]", style=COLORS["primary"])
|
||||
for skill in user_skills:
|
||||
skill_path = Path(skill["path"])
|
||||
console.print(f" • [bold]{skill['name']}[/bold]", style=COLORS["primary"])
|
||||
console.print(f" {skill['description']}", style=COLORS["dim"])
|
||||
console.print(f" 위치: {skill_path.parent}/", style=COLORS["dim"])
|
||||
console.print()
|
||||
|
||||
# 프로젝트 기술 표시
|
||||
if project_skills_list:
|
||||
if not project and user_skills:
|
||||
console.print()
|
||||
console.print("[bold green]프로젝트 기술:[/bold green]", style=COLORS["primary"])
|
||||
for skill in project_skills_list:
|
||||
skill_path = Path(skill["path"])
|
||||
console.print(f" • [bold]{skill['name']}[/bold]", style=COLORS["primary"])
|
||||
console.print(f" {skill['description']}", style=COLORS["dim"])
|
||||
console.print(f" 위치: {skill_path.parent}/", style=COLORS["dim"])
|
||||
console.print()
|
||||
|
||||
|
||||
def _create(skill_name: str, agent: str, project: bool = False) -> None:
|
||||
"""템플릿 SKILL.md 파일을 사용하여 새 기술을 생성합니다.
|
||||
|
||||
Args:
|
||||
skill_name: 생성할 기술의 이름.
|
||||
agent: 기술을 위한 에이전트 식별자
|
||||
project: True인 경우 프로젝트 기술 디렉토리에 생성합니다.
|
||||
False인 경우 사용자 기술 디렉토리에 생성합니다.
|
||||
"""
|
||||
# 기술 이름 먼저 검증 (Agent Skills 사양에 따름)
|
||||
is_valid, error_msg = _validate_name(skill_name)
|
||||
if not is_valid:
|
||||
console.print(f"[bold red]오류:[/bold red] 잘못된 기술 이름: {error_msg}")
|
||||
console.print(
|
||||
"[dim]Agent Skills 사양에 따라: 이름은 소문자 영숫자와 하이픈만 사용해야 합니다.\n"
|
||||
"예시: web-research, code-review, data-analysis[/dim]",
|
||||
style=COLORS["dim"],
|
||||
)
|
||||
return
|
||||
|
||||
# 대상 디렉토리 결정
|
||||
settings = Settings.from_environment()
|
||||
if project:
|
||||
if not settings.project_root:
|
||||
console.print("[bold red]오류:[/bold red] 프로젝트 디렉토리가 아닙니다.")
|
||||
console.print(
|
||||
"[dim]프로젝트 기술을 사용하려면 프로젝트 루트에 .git 디렉토리가 필요합니다.[/dim]",
|
||||
style=COLORS["dim"],
|
||||
)
|
||||
return
|
||||
skills_dir = settings.ensure_project_skills_dir()
|
||||
else:
|
||||
skills_dir = settings.ensure_user_skills_dir(agent)
|
||||
|
||||
skill_dir = skills_dir / skill_name
|
||||
|
||||
# 해결된 경로가 skills_dir 내에 있는지 확인
|
||||
is_valid_path, path_error = _validate_skill_path(skill_dir, skills_dir)
|
||||
if not is_valid_path:
|
||||
console.print(f"[bold red]오류:[/bold red] {path_error}")
|
||||
return
|
||||
|
||||
if skill_dir.exists():
|
||||
console.print(f"[bold red]오류:[/bold red] '{skill_name}' 기술이 이미 {skill_dir} 에 존재합니다")
|
||||
return
|
||||
|
||||
# 기술 디렉토리 생성
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 템플릿 SKILL.md 생성 (사양: https://agentskills.io/specification)
|
||||
template = f"""---
|
||||
name: {skill_name}
|
||||
description: 이 기술이 수행하는 작업과 사용 시기에 대한 간략한 설명.
|
||||
# Agent Skills 사양에 따른 선택적 필드:
|
||||
# license: Apache-2.0
|
||||
# compatibility: Designed for deepagents CLI
|
||||
# metadata:
|
||||
# author: your-org
|
||||
# version: "1.0"
|
||||
# allowed-tools: Bash(git:*) Read
|
||||
---
|
||||
|
||||
# {skill_name.title().replace("-", " ")} 기술
|
||||
|
||||
## 설명
|
||||
|
||||
[이 기술이 수행하는 작업과 사용해야 하는 시기에 대한 자세한 설명을 제공하십시오]
|
||||
|
||||
## 사용 시기
|
||||
|
||||
- [시나리오 1: 사용자가 ...를 요청할 때]
|
||||
- [시나리오 2: ...가 필요할 때]
|
||||
- [시나리오 3: 태스크에 ...가 포함될 때]
|
||||
|
||||
## 사용 방법
|
||||
|
||||
### 1단계: [첫 번째 작업]
|
||||
[먼저 수행할 작업을 설명하십시오]
|
||||
|
||||
### 2단계: [두 번째 작업]
|
||||
[다음에 수행할 작업을 설명하십시오]
|
||||
|
||||
### 3단계: [최종 작업]
|
||||
[태스크를 완료하는 방법을 설명하십시오]
|
||||
|
||||
## 권장 사항
|
||||
|
||||
- [권장 사항 1]
|
||||
- [권장 사항 2]
|
||||
- [권장 사항 3]
|
||||
|
||||
## 지원 파일
|
||||
|
||||
이 기술 디렉토리에는 지침에서 참조하는 지원 파일이 포함될 수 있습니다:
|
||||
- `helper.py` - 자동화를 위한 Python 스크립트
|
||||
- `config.json` - 설정 파일
|
||||
- `reference.md` - 추가 참조 문서
|
||||
|
||||
## 예시
|
||||
|
||||
### 예시 1: [시나리오 이름]
|
||||
|
||||
**사용자 요청:** "[사용자 요청 예시]"
|
||||
|
||||
**접근 방식:**
|
||||
1. [단계별 분석]
|
||||
2. [도구 및 명령 사용]
|
||||
3. [예상 결과]
|
||||
|
||||
### 예시 2: [다른 시나리오]
|
||||
|
||||
**사용자 요청:** "[다른 예시]"
|
||||
|
||||
**접근 방식:**
|
||||
1. [다른 접근 방식]
|
||||
2. [관련 명령]
|
||||
3. [예상 결과]
|
||||
|
||||
## 참고 사항
|
||||
|
||||
- [추가 팁, 경고 또는 컨텍스트]
|
||||
- [알려진 제한 사항 또는 예외 케이스]
|
||||
- [도움이 되는 외부 리소스 링크]
|
||||
"""
|
||||
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
skill_md.write_text(template)
|
||||
|
||||
console.print(f"✓ '{skill_name}' 기술이 성공적으로 생성되었습니다!", style=COLORS["primary"])
|
||||
console.print(f"위치: {skill_dir}\n", style=COLORS["dim"])
|
||||
console.print(
|
||||
"[dim]SKILL.md 파일을 편집하여 사용자 정의하십시오:\n"
|
||||
" 1. YAML frontmatter에서 설명을 업데이트하십시오\n"
|
||||
" 2. 지침과 예시를 채우십시오\n"
|
||||
" 3. 지원 파일(스크립트, 설정 등)을 추가하십시오\n"
|
||||
"\n"
|
||||
f" nano {skill_md}\n"
|
||||
"\n"
|
||||
"💡 기술 예시는 deepagents 저장소의 examples/skills/ 를 참조하십시오:\n"
|
||||
" - web-research: 구조화된 연구 워크플로우\n"
|
||||
" - langgraph-docs: LangGraph 문서 조회\n"
|
||||
"\n"
|
||||
" 예시 복사: cp -r examples/skills/web-research ~/.deepagents/agent/skills/\n",
|
||||
style=COLORS["dim"],
|
||||
)
|
||||
|
||||
|
||||
def _info(skill_name: str, *, agent: str = "agent", project: bool = False) -> None:
|
||||
"""특정 기술에 대한 자세한 정보를 표시합니다.
|
||||
|
||||
Args:
|
||||
skill_name: 세부 정보를 표시할 기술의 이름.
|
||||
agent: 기술을 위한 에이전트 식별자 (기본값: agent).
|
||||
project: True인 경우 프로젝트 기술만 검색합니다. False인 경우 사용자 및 프로젝트 기술 모두에서 검색합니다.
|
||||
"""
|
||||
settings = Settings.from_environment()
|
||||
user_skills_dir = settings.get_user_skills_dir(agent)
|
||||
project_skills_dir = settings.get_project_skills_dir()
|
||||
|
||||
# --project 플래그에 따라 기술 로드
|
||||
if project:
|
||||
if not project_skills_dir:
|
||||
console.print("[bold red]오류:[/bold red] 프로젝트 디렉토리가 아닙니다.")
|
||||
return
|
||||
skills = list_skills(user_skills_dir=None, project_skills_dir=project_skills_dir)
|
||||
else:
|
||||
skills = list_skills(user_skills_dir=user_skills_dir, project_skills_dir=project_skills_dir)
|
||||
|
||||
# 기술 찾기
|
||||
skill = next((s for s in skills if s["name"] == skill_name), None)
|
||||
|
||||
if not skill:
|
||||
console.print(f"[bold red]오류:[/bold red] '{skill_name}' 기술을 찾을 수 없습니다.")
|
||||
console.print("\n[dim]사용 가능한 기술:[/dim]", style=COLORS["dim"])
|
||||
for s in skills:
|
||||
console.print(f" - {s['name']}", style=COLORS["dim"])
|
||||
return
|
||||
|
||||
# 전체 SKILL.md 파일 읽기
|
||||
skill_path = Path(skill["path"])
|
||||
skill_content = skill_path.read_text()
|
||||
|
||||
# 출처 레이블 결정
|
||||
source_label = "프로젝트 기술" if skill["source"] == "project" else "사용자 기술"
|
||||
source_color = "green" if skill["source"] == "project" else "cyan"
|
||||
|
||||
console.print(
|
||||
f"\n[bold]기술: {skill['name']}[/bold] [bold {source_color}]({source_label})[/bold {source_color}]\n",
|
||||
style=COLORS["primary"],
|
||||
)
|
||||
console.print(f"[bold]설명:[/bold] {skill['description']}\n", style=COLORS["dim"])
|
||||
console.print(f"[bold]위치:[/bold] {skill_path.parent}/\n", style=COLORS["dim"])
|
||||
|
||||
# 지원 파일 나열
|
||||
skill_dir = skill_path.parent
|
||||
supporting_files = [f for f in skill_dir.iterdir() if f.name != "SKILL.md"]
|
||||
|
||||
if supporting_files:
|
||||
console.print("[bold]지원 파일:[/bold]", style=COLORS["dim"])
|
||||
for file in supporting_files:
|
||||
console.print(f" - {file.name}", style=COLORS["dim"])
|
||||
console.print()
|
||||
|
||||
# 전체 SKILL.md 내용 표시
|
||||
console.print("[bold]전체 SKILL.md 내용:[/bold]\n", style=COLORS["primary"])
|
||||
console.print(skill_content, style=COLORS["dim"])
|
||||
console.print()
|
||||
|
||||
|
||||
def setup_skills_parser(
|
||||
subparsers: Any,
|
||||
) -> argparse.ArgumentParser:
|
||||
"""모든 하위 명령과 함께 기술 하위 명령 파서를 설정합니다."""
|
||||
skills_parser = subparsers.add_parser(
|
||||
"skills",
|
||||
help="에이전트 기술 관리",
|
||||
description="에이전트 기술 관리 - 기술 정보 생성, 나열 및 보기",
|
||||
)
|
||||
skills_subparsers = skills_parser.add_subparsers(dest="skills_command", help="기술 명령")
|
||||
|
||||
# 기술 목록
|
||||
list_parser = skills_subparsers.add_parser(
|
||||
"list", help="사용 가능한 모든 기술 나열", description="사용 가능한 모든 기술 나열"
|
||||
)
|
||||
list_parser.add_argument(
|
||||
"--agent",
|
||||
default="agent",
|
||||
help="기술을 위한 에이전트 식별자 (기본값: agent)",
|
||||
)
|
||||
list_parser.add_argument(
|
||||
"--project",
|
||||
action="store_true",
|
||||
help="프로젝트 수준 기술만 표시",
|
||||
)
|
||||
|
||||
# 기술 생성
|
||||
create_parser = skills_subparsers.add_parser(
|
||||
"create",
|
||||
help="새 기술 생성",
|
||||
description="템플릿 SKILL.md 파일을 사용하여 새 기술 생성",
|
||||
)
|
||||
create_parser.add_argument("name", help="생성할 기술 이름 (예: web-research)")
|
||||
create_parser.add_argument(
|
||||
"--agent",
|
||||
default="agent",
|
||||
help="기술을 위한 에이전트 식별자 (기본값: agent)",
|
||||
)
|
||||
create_parser.add_argument(
|
||||
"--project",
|
||||
action="store_true",
|
||||
help="사용자 디렉토리 대신 프로젝트 디렉토리에 기술 생성",
|
||||
)
|
||||
|
||||
# 기술 정보
|
||||
info_parser = skills_subparsers.add_parser(
|
||||
"info",
|
||||
help="기술에 대한 자세한 정보 표시",
|
||||
description="특정 기술에 대한 자세한 정보 표시",
|
||||
)
|
||||
info_parser.add_argument("name", help="정보를 표시할 기술 이름")
|
||||
info_parser.add_argument(
|
||||
"--agent",
|
||||
default="agent",
|
||||
help="기술을 위한 에이전트 식별자 (기본값: agent)",
|
||||
)
|
||||
info_parser.add_argument(
|
||||
"--project",
|
||||
action="store_true",
|
||||
help="프로젝트 기술만 검색",
|
||||
)
|
||||
return skills_parser
|
||||
|
||||
|
||||
def execute_skills_command(args: argparse.Namespace) -> None:
|
||||
"""파싱된 인수를 기반으로 기술 하위 명령을 실행합니다.
|
||||
|
||||
Args:
|
||||
args: skills_command 속성이 있는 파싱된 명령줄 인수
|
||||
"""
|
||||
# agent 인수 검증
|
||||
if args.agent:
|
||||
is_valid, error_msg = _validate_name(args.agent)
|
||||
if not is_valid:
|
||||
console.print(f"[bold red]오류:[/bold red] 잘못된 에이전트 이름: {error_msg}")
|
||||
console.print(
|
||||
"[dim]에이전트 이름은 영문자, 숫자, 하이픈 및 밑줄만 포함할 수 있습니다.[/dim]",
|
||||
style=COLORS["dim"],
|
||||
)
|
||||
return
|
||||
|
||||
if args.skills_command == "list":
|
||||
_list(agent=args.agent, project=args.project)
|
||||
elif args.skills_command == "create":
|
||||
_create(args.name, agent=args.agent, project=args.project)
|
||||
elif args.skills_command == "info":
|
||||
_info(args.name, agent=args.agent, project=args.project)
|
||||
else:
|
||||
# 하위 명령이 제공되지 않은 경우 도움말 표시
|
||||
console.print("[yellow]기술 하위 명령을 지정하십시오: list, create, 또는 info[/yellow]")
|
||||
console.print("\n[bold]사용법:[/bold]", style=COLORS["primary"])
|
||||
console.print(" deepagents skills <command> [options]\n")
|
||||
console.print("[bold]사용 가능한 명령:[/bold]", style=COLORS["primary"])
|
||||
console.print(" list 사용 가능한 모든 기술 나열")
|
||||
console.print(" create <name> 새 기술 생성")
|
||||
console.print(" info <name> 기술에 대한 자세한 정보 표시")
|
||||
console.print("\n[bold]예시:[/bold]", style=COLORS["primary"])
|
||||
console.print(" deepagents skills list")
|
||||
console.print(" deepagents skills create web-research")
|
||||
console.print(" deepagents skills info web-research")
|
||||
console.print("\n[dim]특정 명령에 대한 추가 도움말:[/dim]", style=COLORS["dim"])
|
||||
console.print(" deepagents skills <command> --help", style=COLORS["dim"])
|
||||
|
||||
|
||||
__all__ = [
|
||||
"execute_skills_command",
|
||||
"setup_skills_parser",
|
||||
]
|
||||
@@ -0,0 +1,319 @@
|
||||
"""SKILL.md 파일에서 에이전트 기술을 파싱하고 로드하기 위한 기술 로더.
|
||||
|
||||
이 모듈은 YAML frontmatter 파싱을 통해 Anthropic의 에이전트 기술 패턴을 구현합니다.
|
||||
각 기술은 다음을 포함하는 SKILL.md 파일이 있는 디렉토리입니다:
|
||||
- YAML frontmatter (이름, 설명 필수)
|
||||
- 에이전트를 위한 마크다운 지침
|
||||
- 선택적 지원 파일 (스크립트, 설정 등)
|
||||
|
||||
SKILL.md 구조 예시:
|
||||
```markdown
|
||||
---
|
||||
name: web-research
|
||||
description: 철저한 웹 조사를 수행하기 위한 구조화된 접근 방식
|
||||
---
|
||||
|
||||
# 웹 조사 기술
|
||||
|
||||
## 사용 시기
|
||||
- 사용자가 주제 조사를 요청할 때
|
||||
...
|
||||
```
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING, NotRequired, TypedDict
|
||||
|
||||
import yaml
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# SKILL.md 파일의 최대 크기 (10MB)
|
||||
MAX_SKILL_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
# Agent Skills 사양 제약 조건 (https://agentskills.io/specification)
|
||||
MAX_SKILL_NAME_LENGTH = 64
|
||||
MAX_SKILL_DESCRIPTION_LENGTH = 1024
|
||||
|
||||
|
||||
class SkillMetadata(TypedDict):
|
||||
"""Agent Skills 사양(https://agentskills.io/specification)에 따른 기술 메타데이터."""
|
||||
|
||||
name: str
|
||||
"""기술 이름 (최대 64자, 소문자 영숫자와 하이픈)."""
|
||||
|
||||
description: str
|
||||
"""기술이 수행하는 작업에 대한 설명 (최대 1024자)."""
|
||||
|
||||
path: str
|
||||
"""SKILL.md 파일 경로."""
|
||||
|
||||
source: str
|
||||
"""기술의 출처 ('user' 또는 'project')."""
|
||||
|
||||
# Agent Skills 사양에 따른 선택적 필드
|
||||
license: NotRequired[str | None]
|
||||
"""라이선스 이름 또는 번들로 제공되는 라이선스 파일에 대한 참조."""
|
||||
|
||||
compatibility: NotRequired[str | None]
|
||||
"""환경 요구 사항 (최대 500자)."""
|
||||
|
||||
metadata: NotRequired[dict[str, str] | None]
|
||||
"""추가 메타데이터를 위한 임의의 키-값 매핑."""
|
||||
|
||||
allowed_tools: NotRequired[str | None]
|
||||
"""사전 승인된 도구의 공백으로 구분된 목록."""
|
||||
|
||||
|
||||
def _is_safe_path(path: Path, base_dir: Path) -> bool:
|
||||
"""경로가 base_dir 내에 안전하게 포함되어 있는지 확인합니다.
|
||||
|
||||
심볼릭 링크나 경로 조작을 통한 디렉토리 탐색 공격을 방지합니다.
|
||||
이 함수는 두 경로를 정식 형식(심볼릭 링크 따름)으로 해결하고,
|
||||
대상 경로가 기본 디렉토리 내에 있는지 확인합니다.
|
||||
|
||||
Args:
|
||||
path: 검증할 경로
|
||||
base_dir: 경로를 포함해야 하는 기본 디렉토리
|
||||
|
||||
Returns:
|
||||
경로가 base_dir 내에 안전하게 있으면 True, 그렇지 않으면 False
|
||||
|
||||
예시:
|
||||
>>> base = Path("/home/user/.deepagents/skills")
|
||||
>>> safe = Path("/home/user/.deepagents/skills/web-research/SKILL.md")
|
||||
>>> unsafe = Path("/home/user/.deepagents/skills/../../.ssh/id_rsa")
|
||||
>>> _is_safe_path(safe, base)
|
||||
True
|
||||
>>> _is_safe_path(unsafe, base)
|
||||
False
|
||||
"""
|
||||
try:
|
||||
# 두 경로를 정식 형식으로 해결 (심볼릭 링크 따름)
|
||||
resolved_path = path.resolve()
|
||||
resolved_base = base_dir.resolve()
|
||||
|
||||
# 해결된 경로가 기본 디렉토리 내에 있는지 확인
|
||||
# 이는 기본 디렉토리 외부를 가리키는 심볼릭 링크를 포착함
|
||||
resolved_path.relative_to(resolved_base)
|
||||
return True
|
||||
except ValueError:
|
||||
# 경로가 base_dir의 하위가 아님 (디렉토리 외부)
|
||||
return False
|
||||
except (OSError, RuntimeError):
|
||||
# 경로 해결 중 오류 발생 (예: 순환 심볼릭 링크, 너무 많은 수준)
|
||||
return False
|
||||
|
||||
|
||||
def _validate_skill_name(name: str, directory_name: str) -> tuple[bool, str]:
|
||||
"""Agent Skills 사양에 따라 기술 이름을 검증합니다.
|
||||
|
||||
요구 사항:
|
||||
- 최대 64자
|
||||
- 소문자 영숫자와 하이픈만 허용 (a-z, 0-9, -)
|
||||
- 하이픈으로 시작하거나 끝날 수 없음
|
||||
- 연속된 하이픈 허용 안 함
|
||||
- 상위 디렉토리 이름과 일치해야 함
|
||||
|
||||
Args:
|
||||
name: YAML frontmatter의 기술 이름.
|
||||
directory_name: 상위 디렉토리 이름.
|
||||
|
||||
Returns:
|
||||
(유효 여부, 오류 메시지) 튜플. 유효한 경우 오류 메시지는 비어 있습니다.
|
||||
"""
|
||||
if not name:
|
||||
return False, "이름은 필수입니다"
|
||||
if len(name) > MAX_SKILL_NAME_LENGTH:
|
||||
return False, "이름이 64자를 초과합니다"
|
||||
# 패턴: 소문자 영숫자, 세그먼트 사이의 단일 하이픈, 시작/종료 하이픈 없음
|
||||
if not re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", name):
|
||||
return False, "이름은 소문자 영숫자와 단일 하이픈만 사용해야 합니다"
|
||||
if name != directory_name:
|
||||
return False, f"이름 '{name}'은 디렉토리 이름 '{directory_name}'과 일치해야 합니다"
|
||||
return True, ""
|
||||
|
||||
|
||||
def _parse_skill_metadata(skill_md_path: Path, source: str) -> SkillMetadata | None:
|
||||
"""Agent Skills 사양에 따라 SKILL.md 파일에서 YAML frontmatter를 파싱합니다.
|
||||
|
||||
Args:
|
||||
skill_md_path: SKILL.md 파일 경로.
|
||||
source: 기술 출처 ('user' 또는 'project').
|
||||
|
||||
Returns:
|
||||
모든 필드가 포함된 SkillMetadata, 파싱 실패 시 None.
|
||||
"""
|
||||
try:
|
||||
# 보안: DoS 공격 방지를 위해 파일 크기 확인
|
||||
file_size = skill_md_path.stat().st_size
|
||||
if file_size > MAX_SKILL_FILE_SIZE:
|
||||
logger.warning("건너뛰는 중 %s: 파일이 너무 큼 (%d 바이트)", skill_md_path, file_size)
|
||||
return None
|
||||
|
||||
content = skill_md_path.read_text(encoding="utf-8")
|
||||
|
||||
# --- 구분 기호 사이의 YAML frontmatter 매칭
|
||||
frontmatter_pattern = r"^---\s*\n(.*?)\n---\s*\n"
|
||||
match = re.match(frontmatter_pattern, content, re.DOTALL)
|
||||
|
||||
if not match:
|
||||
logger.warning("건너뛰는 중 %s: 유효한 YAML frontmatter를 찾을 수 없음", skill_md_path)
|
||||
return None
|
||||
|
||||
frontmatter_str = match.group(1)
|
||||
|
||||
# 적절한 중첩 구조 지원을 위해 safe_load를 사용하여 YAML 파싱
|
||||
try:
|
||||
frontmatter_data = yaml.safe_load(frontmatter_str)
|
||||
except yaml.YAMLError as e:
|
||||
logger.warning("%s의 잘못된 YAML: %s", skill_md_path, e)
|
||||
return None
|
||||
|
||||
if not isinstance(frontmatter_data, dict):
|
||||
logger.warning("건너뛰는 중 %s: frontmatter가 매핑이 아님", skill_md_path)
|
||||
return None
|
||||
|
||||
# 필수 필드 검증
|
||||
name = frontmatter_data.get("name")
|
||||
description = frontmatter_data.get("description")
|
||||
|
||||
if not name or not description:
|
||||
logger.warning("건너뛰는 중 %s: 필수 'name' 또는 'description'이 누락됨", skill_md_path)
|
||||
return None
|
||||
|
||||
# 사양에 따라 이름 형식 검증 (경고하지만 하위 호환성을 위해 로드함)
|
||||
directory_name = skill_md_path.parent.name
|
||||
is_valid, error = _validate_skill_name(str(name), directory_name)
|
||||
if not is_valid:
|
||||
logger.warning(
|
||||
"%s의 '%s' 기술이 Agent Skills 사양을 따르지 않음: %s. "
|
||||
"사양을 준수하도록 이름을 변경하는 것을 고려하십시오.",
|
||||
skill_md_path,
|
||||
name,
|
||||
error,
|
||||
)
|
||||
|
||||
# 설명 길이 검증 (사양: 최대 1024자)
|
||||
description_str = str(description)
|
||||
if len(description_str) > MAX_SKILL_DESCRIPTION_LENGTH:
|
||||
logger.warning(
|
||||
"%s의 설명이 %d자를 초과하여 잘림",
|
||||
skill_md_path,
|
||||
MAX_SKILL_DESCRIPTION_LENGTH,
|
||||
)
|
||||
description_str = description_str[:MAX_SKILL_DESCRIPTION_LENGTH]
|
||||
|
||||
return SkillMetadata(
|
||||
name=str(name),
|
||||
description=description_str,
|
||||
path=str(skill_md_path),
|
||||
source=source,
|
||||
license=frontmatter_data.get("license"),
|
||||
compatibility=frontmatter_data.get("compatibility"),
|
||||
metadata=frontmatter_data.get("metadata"),
|
||||
allowed_tools=frontmatter_data.get("allowed-tools"),
|
||||
)
|
||||
|
||||
except (OSError, UnicodeDecodeError) as e:
|
||||
logger.warning("%s 읽기 오류: %s", skill_md_path, e)
|
||||
return None
|
||||
|
||||
|
||||
def _list_skills(skills_dir: Path, source: str) -> list[SkillMetadata]:
|
||||
"""단일 기술 디렉토리에서 모든 기술을 나열합니다(내부 헬퍼).
|
||||
|
||||
기술 디렉토리에서 SKILL.md 파일이 포함된 하위 디렉토리를 스캔하고,
|
||||
YAML frontmatter를 파싱하여 기술 메타데이터를 반환합니다.
|
||||
|
||||
기술 조직 구성:
|
||||
skills/
|
||||
├── skill-name/
|
||||
│ ├── SKILL.md # 필수: YAML frontmatter가 있는 지침
|
||||
│ ├── script.py # 선택 사항: 지원 파일
|
||||
│ └── config.json # 선택 사항: 지원 파일
|
||||
|
||||
Args:
|
||||
skills_dir: 기술 디렉토리 경로.
|
||||
source: 기술 출처 ('user' 또는 'project').
|
||||
|
||||
Returns:
|
||||
이름, 설명, 경로 및 출처가 포함된 기술 메타데이터 딕셔너리 목록.
|
||||
"""
|
||||
# 기술 디렉토리 존재 여부 확인
|
||||
skills_dir = skills_dir.expanduser()
|
||||
if not skills_dir.exists():
|
||||
return []
|
||||
|
||||
# 보안 검사를 위해 기본 디렉토리를 정식 경로로 해결
|
||||
try:
|
||||
resolved_base = skills_dir.resolve()
|
||||
except (OSError, RuntimeError):
|
||||
# 기본 디렉토리를 해결할 수 없음, 안전하게 종료
|
||||
return []
|
||||
|
||||
skills: list[SkillMetadata] = []
|
||||
|
||||
# 하위 디렉토리 순회
|
||||
for skill_dir in skills_dir.iterdir():
|
||||
# 보안: 기술 디렉토리 외부를 가리키는 심볼릭 링크 포착
|
||||
if not _is_safe_path(skill_dir, resolved_base):
|
||||
continue
|
||||
|
||||
if not skill_dir.is_dir():
|
||||
continue
|
||||
|
||||
# SKILL.md 파일 찾기
|
||||
skill_md_path = skill_dir / "SKILL.md"
|
||||
if not skill_md_path.exists():
|
||||
continue
|
||||
|
||||
# 보안: 읽기 전에 SKILL.md 경로가 안전한지 검증
|
||||
# 이는 외부를 가리키는 심볼릭 링크인 SKILL.md 파일을 포착함
|
||||
if not _is_safe_path(skill_md_path, resolved_base):
|
||||
continue
|
||||
|
||||
# 메타데이터 파싱
|
||||
metadata = _parse_skill_metadata(skill_md_path, source=source)
|
||||
if metadata:
|
||||
skills.append(metadata)
|
||||
|
||||
return skills
|
||||
|
||||
|
||||
def list_skills(*, user_skills_dir: Path | None = None, project_skills_dir: Path | None = None) -> list[SkillMetadata]:
|
||||
"""사용자 및/또는 프로젝트 디렉토리에서 기술을 나열합니다.
|
||||
|
||||
두 디렉토리가 모두 제공되면 사용자 기술과 이름이 동일한 프로젝트 기술이
|
||||
사용자 기술을 오버라이드합니다.
|
||||
|
||||
Args:
|
||||
user_skills_dir: 사용자 수준 기술 디렉토리 경로.
|
||||
project_skills_dir: 프로젝트 수준 기술 디렉토리 경로.
|
||||
|
||||
Returns:
|
||||
두 출처의 기술 메타데이터가 병합된 목록이며, 이름이 충돌할 경우
|
||||
프로젝트 기술이 사용자 기술보다 우선합니다.
|
||||
"""
|
||||
all_skills: dict[str, SkillMetadata] = {}
|
||||
|
||||
# 사용자 기술 먼저 로드 (기본)
|
||||
if user_skills_dir:
|
||||
user_skills = _list_skills(user_skills_dir, source="user")
|
||||
for skill in user_skills:
|
||||
all_skills[skill["name"]] = skill
|
||||
|
||||
# 프로젝트 기술 두 번째로 로드 (오버라이드/확장)
|
||||
if project_skills_dir:
|
||||
project_skills = _list_skills(project_skills_dir, source="project")
|
||||
for skill in project_skills:
|
||||
# 프로젝트 기술은 이름이 같은 사용자 기술을 오버라이드함
|
||||
all_skills[skill["name"]] = skill
|
||||
|
||||
return list(all_skills.values())
|
||||
@@ -0,0 +1,273 @@
|
||||
"""에이전트 기술을 시스템 프롬프트에 로드하고 노출하기 위한 미들웨어.
|
||||
|
||||
이 미들웨어는 점진적 노출(progressive disclosure)을 통해 Anthropic의 "Agent Skills" 패턴을 구현합니다:
|
||||
1. 세션 시작 시 SKILL.md 파일에서 YAML frontmatter 파싱
|
||||
2. 시스템 프롬프트에 기술 메타데이터(이름 + 설명) 주입
|
||||
3. 에이전트는 작업과 관련이 있을 때 SKILL.md의 전체 내용을 읽음
|
||||
|
||||
기술 디렉토리 구조 (에이전트별 + 프로젝트):
|
||||
사용자 수준: ~/.deepagents/{AGENT_NAME}/skills/
|
||||
프로젝트 수준: {PROJECT_ROOT}/.deepagents/skills/
|
||||
|
||||
구조 예시:
|
||||
~/.deepagents/{AGENT_NAME}/skills/
|
||||
├── web-research/
|
||||
│ ├── SKILL.md # 필수: YAML frontmatter + 지침
|
||||
│ └── helper.py # 선택 사항: 지원 파일
|
||||
├── code-review/
|
||||
│ ├── SKILL.md
|
||||
│ └── checklist.md
|
||||
|
||||
.deepagents/skills/
|
||||
├── project-specific/
|
||||
│ └── SKILL.md # 프로젝트 전용 기술
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import NotRequired, TypedDict, cast
|
||||
|
||||
from langchain.agents.middleware.types import (
|
||||
AgentMiddleware,
|
||||
AgentState,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
)
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from deepagents_cli.skills.load import SkillMetadata, list_skills
|
||||
|
||||
|
||||
class SkillsState(AgentState):
|
||||
"""기술 미들웨어를 위한 상태."""
|
||||
|
||||
skills_metadata: NotRequired[list[SkillMetadata]]
|
||||
"""로드된 기술 메타데이터 목록 (이름, 설명, 경로)."""
|
||||
|
||||
|
||||
class SkillsStateUpdate(TypedDict):
|
||||
"""기술 미들웨어를 위한 상태 업데이트."""
|
||||
|
||||
skills_metadata: list[SkillMetadata]
|
||||
"""로드된 기술 메타데이터 목록 (이름, 설명, 경로)."""
|
||||
|
||||
|
||||
# 기술 시스템 문서
|
||||
SKILLS_SYSTEM_PROMPT = """
|
||||
|
||||
## 기술 시스템 (Skills System)
|
||||
|
||||
당신은 전문적인 능력과 도메인 지식을 제공하는 기술 라이브러리에 접근할 수 있습니다.
|
||||
|
||||
{skills_locations}
|
||||
|
||||
**사용 가능한 기술:**
|
||||
|
||||
{skills_list}
|
||||
|
||||
**기술 사용 방법 (점진적 노출):**
|
||||
|
||||
기술은 **점진적 노출(progressive disclosure)** 패턴을 따릅니다. 당신은 기술이 존재한다는 것(위의 이름 + 설명)은 알고 있지만, 필요할 때만 전체 지침을 읽습니다:
|
||||
|
||||
1. **기술이 적용되는 시기 파악**: 사용자의 작업이 기술의 설명과 일치하는지 확인하십시오.
|
||||
2. **기술의 전체 지침 읽기**: 위의 기술 목록은 read_file과 함께 사용할 정확한 경로를 보여줍니다.
|
||||
3. **기술의 지침 따르기**: SKILL.md에는 단계별 워크플로우, 권장 사항 및 예시가 포함되어 있습니다.
|
||||
4. **지원 파일 접근**: 기술에는 Python 스크립트, 설정 또는 참조 문서가 포함될 수 있습니다. 절대 경로를 사용하십시오.
|
||||
|
||||
**기술을 사용해야 하는 경우:**
|
||||
- 사용자의 요청이 기술의 도메인과 일치할 때 (예: "X 조사해줘" → web-research 기술)
|
||||
- 전문 지식이나 구조화된 워크플로우가 필요할 때
|
||||
- 기술이 복잡한 작업에 대해 검증된 패턴을 제공할 때
|
||||
|
||||
**기술은 자체 문서화됨:**
|
||||
- 각 SKILL.md는 기술이 수행하는 작업과 사용 방법을 정확하게 알려줍니다.
|
||||
- 위의 기술 목록은 각 기술의 SKILL.md 파일에 대한 전체 경로를 보여줍니다.
|
||||
|
||||
**기술 스크립트 실행:**
|
||||
기술에는 Python 스크립트나 기타 실행 파일이 포함될 수 있습니다. 항상 기술 목록의 절대 경로를 사용하십시오.
|
||||
|
||||
**워크플로우 예시:**
|
||||
|
||||
사용자: "양자 컴퓨팅의 최신 개발 동향을 조사해 줄 수 있어?"
|
||||
|
||||
1. 위에서 사용 가능한 기술 확인 → 전체 경로와 함께 "web-research" 기술 확인
|
||||
2. 목록에 표시된 경로를 사용하여 기술 읽기
|
||||
3. 기술의 조사 워크플로우 따르기 (조사 → 정리 → 합성)
|
||||
4. 절대 경로와 함께 헬퍼 스크립트 사용
|
||||
|
||||
주의: 기술은 당신을 더 유능하고 일관성 있게 만드는 도구입니다. 의심스러울 때는 해당 작업에 대한 기술이 있는지 확인하십시오!
|
||||
"""
|
||||
|
||||
|
||||
class SkillsMiddleware(AgentMiddleware):
|
||||
"""에이전트 기술을 로드하고 노출하기 위한 미들웨어.
|
||||
|
||||
이 미들웨어는 Anthropic의 에이전트 기술 패턴을 구현합니다:
|
||||
- 세션 시작 시 YAML frontmatter에서 기술 메타데이터(이름, 설명)를 로드함
|
||||
- 발견 가능성을 위해 시스템 프롬프트에 기술 목록을 주입함
|
||||
- 기술이 관련 있을 때 에이전트가 전체 SKILL.md 내용을 읽음 (점진적 노출)
|
||||
|
||||
사용자 수준 및 프로젝트 수준 기술을 모두 지원합니다:
|
||||
- 사용자 기술: ~/.deepagents/{AGENT_NAME}/skills/
|
||||
- 프로젝트 기술: {PROJECT_ROOT}/.deepagents/skills/
|
||||
- 프로젝트 기술은 이름이 같은 사용자 기술을 오버라이드함
|
||||
|
||||
Args:
|
||||
skills_dir: 사용자 수준 기술 디렉토리 경로 (에이전트별).
|
||||
assistant_id: 프롬프트의 경로 참조를 위한 에이전트 식별자.
|
||||
project_skills_dir: 선택적인 프로젝트 수준 기술 디렉토리 경로.
|
||||
"""
|
||||
|
||||
state_schema = SkillsState
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
skills_dir: str | Path,
|
||||
assistant_id: str,
|
||||
project_skills_dir: str | Path | None = None,
|
||||
) -> None:
|
||||
"""기술 미들웨어를 초기화합니다.
|
||||
|
||||
Args:
|
||||
skills_dir: 사용자 수준 기술 디렉토리 경로.
|
||||
assistant_id: 에이전트 식별자.
|
||||
project_skills_dir: 선택적인 프로젝트 수준 기술 디렉토리 경로.
|
||||
"""
|
||||
self.skills_dir = Path(skills_dir).expanduser()
|
||||
self.assistant_id = assistant_id
|
||||
self.project_skills_dir = Path(project_skills_dir).expanduser() if project_skills_dir else None
|
||||
# 프롬프트 표시를 위한 경로 저장
|
||||
self.user_skills_display = f"~/.deepagents/{assistant_id}/skills"
|
||||
self.system_prompt_template = SKILLS_SYSTEM_PROMPT
|
||||
|
||||
def _format_skills_locations(self) -> str:
|
||||
"""시스템 프롬프트 표시를 위해 기술 위치 형식을 지정합니다."""
|
||||
locations = [f"**사용자 기술**: `{self.user_skills_display}`"]
|
||||
if self.project_skills_dir:
|
||||
locations.append(f"**프로젝트 기술**: `{self.project_skills_dir}` (사용자 기술을 오버라이드함)")
|
||||
return "\n".join(locations)
|
||||
|
||||
def _format_skills_list(self, skills: list[SkillMetadata]) -> str:
|
||||
"""시스템 프롬프트 표시를 위해 기술 메타데이터 형식을 지정합니다."""
|
||||
if not skills:
|
||||
locations = [f"{self.user_skills_display}/"]
|
||||
if self.project_skills_dir:
|
||||
locations.append(f"{self.project_skills_dir}/")
|
||||
return f"(현재 사용 가능한 기술이 없습니다. {' 또는 '.join(locations)} 에 기술을 생성할 수 있습니다)"
|
||||
|
||||
# 출처별로 기술 그룹화
|
||||
user_skills = [s for s in skills if s["source"] == "user"]
|
||||
project_skills = [s for s in skills if s["source"] == "project"]
|
||||
|
||||
lines = []
|
||||
|
||||
# 사용자 기술 표시
|
||||
if user_skills:
|
||||
lines.append("**사용자 기술:**")
|
||||
for skill in user_skills:
|
||||
lines.append(f"- **{skill['name']}**: {skill['description']}")
|
||||
lines.append(f" → 전체 지침을 보려면 `{skill['path']}` 읽기")
|
||||
lines.append("")
|
||||
|
||||
# 프로젝트 기술 표시
|
||||
if project_skills:
|
||||
lines.append("**프로젝트 기술:**")
|
||||
for skill in project_skills:
|
||||
lines.append(f"- **{skill['name']}**: {skill['description']}")
|
||||
lines.append(f" → 전체 지침을 보려면 `{skill['path']}` 읽기")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def before_agent(self, state: SkillsState, runtime: Runtime) -> SkillsStateUpdate | None:
|
||||
"""에이전트 실행 전 기술 메타데이터를 로드합니다.
|
||||
|
||||
이는 사용자 수준 및 프로젝트 수준 디렉토리 모두에서 사용 가능한 기술을 검색하기 위해
|
||||
세션 시작 시 한 번 실행됩니다.
|
||||
|
||||
Args:
|
||||
state: 현재 에이전트 상태.
|
||||
runtime: 런타임 컨텍스트.
|
||||
|
||||
Returns:
|
||||
skills_metadata가 채워진 업데이트된 상태.
|
||||
"""
|
||||
# 기술 디렉토리의 변경 사항을 포착하기 위해
|
||||
# 에이전트와의 매 상호 작용마다 기술을 다시 로드합니다.
|
||||
skills = list_skills(
|
||||
user_skills_dir=self.skills_dir,
|
||||
project_skills_dir=self.project_skills_dir,
|
||||
)
|
||||
return SkillsStateUpdate(skills_metadata=skills)
|
||||
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelResponse:
|
||||
"""시스템 프롬프트에 기술 문서를 주입합니다.
|
||||
|
||||
이것은 기술 정보가 항상 사용 가능하도록 매 모델 호출 시 실행됩니다.
|
||||
|
||||
Args:
|
||||
request: 처리 중인 모델 요청.
|
||||
handler: 수정된 요청으로 호출할 핸들러 함수.
|
||||
|
||||
Returns:
|
||||
핸들러의 모델 응답.
|
||||
"""
|
||||
# 상태에서 기술 메타데이터 가져오기
|
||||
skills_metadata = request.state.get("skills_metadata", [])
|
||||
|
||||
# 기술 위치 및 목록 형식 지정
|
||||
skills_locations = self._format_skills_locations()
|
||||
skills_list = self._format_skills_list(skills_metadata)
|
||||
|
||||
# 기술 문서 형식 지정
|
||||
skills_section = self.system_prompt_template.format(
|
||||
skills_locations=skills_locations,
|
||||
skills_list=skills_list,
|
||||
)
|
||||
|
||||
if request.system_prompt:
|
||||
system_prompt = request.system_prompt + "\n\n" + skills_section
|
||||
else:
|
||||
system_prompt = skills_section
|
||||
|
||||
return handler(request.override(system_prompt=system_prompt))
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelResponse:
|
||||
"""(비동기) 시스템 프롬프트에 기술 문서를 주입합니다.
|
||||
|
||||
Args:
|
||||
request: 처리 중인 모델 요청.
|
||||
handler: 수정된 요청으로 호출할 핸들러 함수.
|
||||
|
||||
Returns:
|
||||
핸들러의 모델 응답.
|
||||
"""
|
||||
# state_schema로 인해 상태는 SkillsState임이 보장됨
|
||||
state = cast("SkillsState", request.state)
|
||||
skills_metadata = state.get("skills_metadata", [])
|
||||
|
||||
# 기술 위치 및 목록 형식 지정
|
||||
skills_locations = self._format_skills_locations()
|
||||
skills_list = self._format_skills_list(skills_metadata)
|
||||
|
||||
# 기술 문서 형식 지정
|
||||
skills_section = self.system_prompt_template.format(
|
||||
skills_locations=skills_locations,
|
||||
skills_list=skills_list,
|
||||
)
|
||||
|
||||
# 시스템 프롬프트에 주입
|
||||
if request.system_prompt:
|
||||
system_prompt = request.system_prompt + "\n\n" + skills_section
|
||||
else:
|
||||
system_prompt = skills_section
|
||||
|
||||
return await handler(request.override(system_prompt=system_prompt))
|
||||