Harness Academy
Tài liệu gốc 17 14 phútTiếng Việt + English
docs/en/s14-terminal-backends.mdXem bản gốc
Ngôn ngữ bài đọc
Bản dịch tiếng Việt

Bản dịch đầy đủ theo đúng cấu trúc tài liệu; code, liên kết và định danh kỹ thuật được giữ nguyên.

s14: Phần cuối của terminal

s00 > s01 > s02 > s03 > s04 > s05 > s06 > s07 > s08 > s09 > s10 > s11 > s12 > s13 > [ s14 ] > s15 > s16 > s17 > s18 > s19 > s20 > s21 > s22 > s23 > s24 > s25 > s26 > s27

Công cụ gọi terminal để thực thi lệnh. Nhưng lệnh chạy ở đâu -- cục bộ, trong Docker hoặc trên máy từ xa thông qua SSH -- là điều mà công cụ không cần biết.

Tóm tắt phần cuối của terminal

Chương này giải quyết vấn đề gì

Trong s01-s11, việc triển khai công cụ đầu cuối là bốn dòng mã:

def run_terminal(args, **kwargs):
    result = subprocess.run(
        args["command"], shell=True,
        capture_output=True, text=True, timeout=30,
    )
    return result.stdout + result.stderr

Nó chạy trực tiếp trên máy cục bộ. Bây giờ sản phẩm cho biết: "Chúng tôi có thể cho phép agent chạy các lệnh bên trong vùng chứa Docker không?"

Cách tiếp cận rõ ràng nhất là thêm if-else vào hàm công cụ. Nhưng bạn sẽ ngay lập tức gặp phải hai vấn đề.

Đề xuất đọc

Thuật ngữ chính

Ảnh chụp nhanh là gì

agent chạy cd /tmp sau đó chạy pwd, mong được gặp /tmp. Nhưng subprocess.run bắt đầu một bash mới mỗi lần. Thư mục và các biến của bash trước đó sẽ bị mất khi quá trình đó thoát ra.

Ảnh chụp nhanh giải quyết vấn đề này: sau khi mỗi lệnh thực thi, các biến môi trường hiện tại và thư mục làm việc sẽ được lưu vào một tệp; trước khi lệnh tiếp theo được thực thi, chúng sẽ được khôi phục từ tệp đó. Bằng cách này, mặc dù mỗi lệnh chạy trong một bash mới, trạng thái vẫn liên tục.

Ảnh chụp nhanh bao gồm hai tệp, với các đường dẫn được tạo khi BaseEnvironment được tạo:

self._session_id = uuid.uuid4().hex[:12]   # Random 12 chars, e.g. "a1b2c3d4e5f6"
self._snapshot_path = f"/tmp/hermes-snap-{self._session_id}.sh"   # Environment variables
self._cwd_file = f"/tmp/hermes-cwd-{self._session_id}.txt"        # Working directory

Tất cả các lệnh trong cùng một phiên bản BaseEnvironment đều chia sẻ hai tệp này. Nội dung của chúng trông giống như:

# /tmp/hermes-snap-a1b2c3.sh
export PATH="/usr/local/bin:/usr/bin:/bin"
export MY_VAR="hello"
# /tmp/hermes-cwd-a1b2c3.txt
/workspace/myproject

Môi trường cơ sở là gì

Lớp cơ sở trừu tượng cho tất cả các chương trình phụ trợ đầu cuối. Khi tạo, nó tạo ra đường dẫn session_id và ảnh chụp nhanh. Các lớp con chỉ cần thực hiện hai phương thức:

  • _run_bash(cmd_string) -- Cách khởi động bash (tiến trình con/docker exec/ssh)
  • cleanup() -- Cách giải phóng tài nguyên (xóa tệp/dừng vùng chứa/đóng kết nối)

Gói lệnh, khôi phục/lưu ảnh chụp nhanh và xử lý thời gian chờ đều nằm trong lớp cơ sở, được chia sẻ bởi mọi chương trình phụ trợ.

Bắt đầu với cách triển khai đơn giản nhất có thể

Thêm if-other vào công cụ đầu cuối:

if backend == "local":
    result = subprocess.run(command, shell=True, ...)
elif backend == "docker":
    result = subprocess.run(["docker", "exec", container, "bash", "-c", command], ...)
elif backend == "ssh":
    result = subprocess.run(["ssh", f"{user}@{host}", "bash", "-c", command], ...)

Nó hoạt động, nhưng có hai vấn đề.

Vấn đề 1: Trạng thái không liên tục

agent thực hiện ba lệnh theo trình tự:

Command 1: cd /workspace/myproject
Command 2: export MY_VAR=hello
Command 3: echo $MY_VAR && pwd

Mỗi lệnh bắt đầu một quy trình bash mới:

Command 1 -> Process #1 -> cd succeeds -> process exits, directory lost
Command 2 -> Process #2 -> export succeeds -> process exits, variable lost
Command 3 -> Process #3 -> $MY_VAR doesn't exist, pwd outputs the initial directory

Bạn có thể nghĩ: "Duy trì quy trình bash chạy dài, viết lệnh vào stdin và trạng thái vẫn tồn tại một cách tự nhiên."

Con đường này là ngõ cụt, có ba cạm bẫy:

  • Ranh giới đầu ra. Bạn đã viết ls -- làm sao bạn biết khi nào lsđầu ra của nó đã xong chưa? Không có dấu phân cách. Bạn sẽ phải lẻn vào một echo __MARKER__ sau mỗi lệnh và tìm điểm đánh dấu trong thiết bị xuất chuẩn. Rất mong manh.
  • Truyền bá. agent chạy cat (không có tên tệp - nó sẽ đợi đầu vào mãi mãi). Bạn không thể chỉ hủy lệnh đó - hoặc hủy toàn bộ bash (mất tất cả trạng thái) hoặc đợi hết thời gian chờ.
  • Lệnh nhiều dòng. Người đại diện đã viết for i in 1 2 3; do ... done. Bash sẽ không thực thi cho đến khi nó nhìn thấy done. Bạn cần hiểu cú pháp bash để biết khi nào nên đọc đầu ra - về cơ bản là viết lại trình phân tích cú pháp shell.

Cách tiếp cận của agent Hermes: Mỗi lệnh vẫn bắt đầu một bash mới, nhưng trạng thái được chuyển giữa chúng thông qua các tệp chụp nhanh.

Vấn đề 2: Vụ nổ if-else

Sau khi thêm Docker và SSH, nếu bạn cũng cần Modal, Daytona và Singularity, thì đó là sáu nhánh. Đây là vấn đề tương tự như s12 -- và giải pháp cũng giống nhau: trích xuất một giao diện chung và để mỗi chương trình phụ trợ triển khai nó.

Đi qua toàn bộ quy trình bằng ba lệnh

Xem cách ảnh chụp nhanh cho phép trạng thái tồn tại trong các quy trình bash mới.

Trước khi mỗi lệnh được gửi đến phần phụ trợ, lớp cơ sở sẽ gói nó thành một tập lệnh năm dòng:

source snapshot.sh       <-- Restore environment variables from last time
cd {last working dir}    <-- Restore last directory
{user's command}         <-- The actual command to execute
export -p > snapshot.sh  <-- Save new environment variables
pwd -P > cwd.txt         <-- Save new working directory

Luồng hoàn chỉnh cho ba lệnh:

=== Command 1: cd /workspace/myproject ===

Wrapped as:
  source snapshot.sh          <-- Empty on first run, skipped
  cd /home/user               <-- Initial directory
  cd /workspace/myproject     <-- User's command
  export -p > snapshot.sh     <-- Save environment
  pwd -P > cwd.txt            <-- Save /workspace/myproject

-> Bash process #1 finishes, exits
-> Base class reads cwd.txt -> remembers directory is /workspace/myproject

=== Command 2: export MY_VAR=hello ===

Wrapped as:
  source snapshot.sh          <-- Restore environment from command 1
  cd /workspace/myproject     <-- Restore directory from end of command 1
  export MY_VAR=hello         <-- User's command
  export -p > snapshot.sh     <-- Save (now includes MY_VAR=hello)
  pwd -P > cwd.txt

-> Bash process #2 finishes, exits

=== Command 3: echo $MY_VAR && pwd ===

Wrapped as:
  source snapshot.sh          <-- Restore, includes MY_VAR=hello
  cd /workspace/myproject     <-- Correct directory
  echo $MY_VAR && pwd         <-- User's command

Output:
  hello
  /workspace/myproject         OK

Ba quy trình bash khác nhau, nhưng agent thấy trạng thái liên tục.

Cốt lõi là tập lệnh bao bọc năm dòng này. Mọi chương trình phụ trợ (cục bộ, Docker, SSH) đều sử dụng cùng một logic gói -- điểm khác biệt duy nhất là "nơi năm dòng này thực thi".

Mô hình tinh thần tối thiểu

terminal tool
    |
    |  execute("pip install numpy")
    v
BaseEnvironment (base class)
    |
    |  1. Wrap: source snap -> cd -> user command -> export -p -> pwd
    |  2. Hand to subclass: _run_bash(wrapped command)
    |  3. Wait for output, read cwd.txt to update directory
    |
    +-- LocalBackend:   subprocess.Popen(["bash", "-c", ...])
    +-- DockerBackend:  subprocess.Popen(["docker", "exec", ..., "bash", "-c", ...])
    +-- SSHBackend:     subprocess.Popen(["ssh", ..., "bash", "-c", ...])

Lớp cơ sở xử lý các bước 1 và 3 (được chia sẻ bởi tất cả các chương trình phụ trợ); các lớp con chỉ xử lý bước 2 (mỗi lớp khác nhau).

Thực hiện tối thiểu

Gói lệnh (lớp cơ sở, được chia sẻ bởi tất cả các chương trình phụ trợ)

def _wrap_command(self, command: str) -> str:
    parts = []
    if self._snapshot_ready:
        parts.append(f"source {self._snapshot_path} 2>/dev/null")
    parts.append(f"cd {shlex.quote(self.cwd)} 2>/dev/null")
    parts.append(command)
    # Save exit code first, then save snapshot, then exit with saved code
    # Otherwise export -p's exit code (always 0) would overwrite the user command's exit code
    parts.append(f"_exit=$?; export -p > {self._snapshot_path} 2>/dev/null; "
                  f"pwd -P > {self._cwd_file} 2>/dev/null; exit $_exit")
    return "; ".join(parts)

Phụ trợ cục bộ

Đơn giản nhất - chỉ cần gọi quy trình con:

class LocalBackend(BaseEnvironment):

    def _run_bash(self, cmd_string, *, timeout):
        # Filter API keys to prevent agent commands from reading OPENAI_API_KEY etc.
        env = {k: v for k, v in os.environ.items()
               if k not in _SECRET_BLOCKLIST}
        return subprocess.Popen(
            ["bash", "-c", cmd_string],
            stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
            text=True, env=env,
        )

    def cleanup(self):
        for f in [self._snapshot_path, self._cwd_file]:
            try: os.unlink(f)
            except FileNotFoundError: pass

Tại sao lọc các biến môi trường? Bởi vì quy trình con của chương trình phụ trợ cục bộ kế thừa tất cả các biến môi trường của máy chủ. Nếu không lọc, agent có thể chạy env | grep KEY để lấy khóa API của bạn. Các chương trình phụ trợ Docker và SSH không cần điều này -- về bản chất, các thùng chứa và máy từ xa không có các biến này.

Phụ trợ Docker

Điểm khác biệt chính: trước tiên hãy khởi động một vùng chứa chạy dài, sau đó gửi từng lệnh qua docker exec.

class DockerBackend(BaseEnvironment):

    def __init__(self, image="python:3.11-slim", **kwargs):
        super().__init__(**kwargs)
        self._image = image
        self._container_id = None

    def _ensure_container(self):
        """Start a container before the first command; reuse it afterwards."""
        if self._container_id:
            return
        result = subprocess.run([
            "docker", "run", "-d",
            "--name", f"hermes-{self._session_id}",
            "--cap-drop", "ALL",                    # Drop all capabilities
            "--security-opt", "no-new-privileges",  # Prevent privilege escalation
            "--pids-limit", "256",                   # Prevent fork bombs
            "--memory", "512m",                      # Memory cap
            self._image, "sleep", "infinity",        # Keep container running
        ], capture_output=True, text=True)
        self._container_id = result.stdout.strip()

    def _run_bash(self, cmd_string, *, timeout):
        self._ensure_container()
        return subprocess.Popen(
            ["docker", "exec", "-i", self._container_id,
             "bash", "-c", cmd_string],
            stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
        )

    def cleanup(self):
        if self._container_id:
            subprocess.run(["docker", "rm", "-f", self._container_id],
                           capture_output=True)

Tại sao nên sử dụng vùng chứa chạy dài thay vì docker run cho mỗi lệnh? Bởi vì các tệp ảnh chụp nhanh nằm trong hệ thống tệp của vùng chứa. Nếu vùng chứa bị phá hủy, ảnh chụp nhanh sẽ bị mất và trạng thái bị phá vỡ.

Các thông số bảo mật được giải thích:

| Tham số | Nó ngăn cản điều gì | |----------||-------------------| | --cap-drop ALL | agent không thể gắn hệ thống tập tin hoặc sửa đổi quy tắc mạng | | --no-new-privileges | Các chương trình Setuid trong vùng chứa không thể tăng đặc quyền | | --pids-limit 256 | Bom ngã ba chỉ có thể tạo 256 quy trình | | --memory 512m | Việc cạn kiệt bộ nhớ chỉ ảnh hưởng đến vùng chứa chứ không ảnh hưởng đến máy chủ |

Phụ trợ SSH

class SSHBackend(BaseEnvironment):

    def __init__(self, host, user, key_path=None, **kwargs):
        super().__init__(**kwargs)
        self._host = host
        self._user = user
        self._key_path = key_path
        self._control_socket = f"/tmp/hermes-ssh/{user}@{host}.sock"

    def _ssh_args(self):
        args = [
            "ssh",
            "-o", "ControlMaster=auto",           # Connection multiplexing
            "-o", f"ControlPath={self._control_socket}",
            "-o", "ControlPersist=300",            # Disconnect after 5 minutes idle
            "-o", "BatchMode=yes",                 # No interactive prompts
        ]
        if self._key_path:
            args += ["-i", self._key_path]
        args.append(f"{self._user}@{self._host}")
        return args

    def _run_bash(self, cmd_string, *, timeout):
        return subprocess.Popen(
            self._ssh_args() + ["bash", "-c", shlex.quote(cmd_string)],
            stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
        )

    def cleanup(self):
        subprocess.run(
            ["ssh", "-O", "exit",
             "-o", f"ControlPath={self._control_socket}",
             f"{self._user}@{self._host}"],
            capture_output=True)

ControlMaster cho phép ghép kênh kết nối SSH. Lệnh đầu tiên thiết lập kết nối và xác thực TCP; các lệnh tiếp theo sử dụng lại cùng một kết nối. Không có nó, 30 lệnh có nghĩa là 30 lần bắt tay + xác thực.

Lựa chọn phụ trợ

Một dòng trong config.yaml chuyển đổi phần phụ trợ; mã công cụ không thay đổi:

terminal:
  backend: docker    # "local" | "docker" | "ssh"
  docker_image: python:3.11-slim
def create_backend(config):
    backend_type = config.get("terminal", {}).get("backend", "local")
    if backend_type == "docker":
        return DockerBackend(image=..., cwd="/workspace")
    elif backend_type == "ssh":
        return SSHBackend(host=..., user=..., cwd="~")
    else:
        return LocalBackend(cwd=os.getcwd())

Làm thế nào các chương trình phụ trợ từ xa đọc tệp cwd

Ảnh chụp nhanh và tệp cwd của chương trình phụ trợ cục bộ nằm ở thư mục cục bộ /tmp, có thể đọc trực tiếp Phần phụ trợ Docker và SSH có các tệp này trong vùng chứa hoặc trên máy từ xa, yêu cầu thêm một bước:

Local backend:    Read /tmp/hermes-cwd-xxx.txt directly
Docker backend:   docker exec cat /tmp/hermes-cwd-xxx.txt
SSH backend:      ssh cat /tmp/hermes-cwd-xxx.txt (reuses ControlMaster, nearly zero overhead)

Trong suốt đối với lớp trên - sau execute() trở lại, self.cwd luôn được cập nhật.

Cách nó kết nối với vòng lặp chính

Core loop
  |  tool_call: terminal("pip install numpy")
  v
terminal tool
  |  -> Permission check (s09)
  |  -> backend.execute("pip install numpy")
  v
BaseEnvironment (currently DockerBackend)
  |  Wrap: source snap -> cd -> pip install numpy -> export -p -> pwd
  |  Execute: docker exec hermes-a1b2c3 bash -c "..."
  v
Docker container
  |  Output + return code
  v
terminal tool
  |  tool_result: "Successfully installed numpy-1.26.4"
  v
Core loop (continues to next iteration)

Vòng lặp cốt lõi chỉ thấy rằng công cụ đầu cuối trả về một số văn bản. Nó không biết lệnh chạy trong Docker.

Những sai lầm phổ biến nhất của người mới bắt đầu

1. Quên lọc khóa API

Phần phụ trợ cục bộ chuyển tất cả các biến môi trường cho quy trình con. agent chạy env | grep KEY và nhìn thấy bí mật của bạn.

Khắc phục: Lọc danh sách chặn bí mật khỏi các biến môi trường của quy trình con. Docker và SSH không cần điều này -- về bản chất, bộ chứa/máy từ xa không có các biến này.

2. Không thêm giới hạn tài nguyên cho Docker

agent chạy một tập lệnh ngốn bộ nhớ và máy chủ cũng gặp sự cố với tập lệnh đó.

Sửa chữa: --memory và --pids-limit phải được cấu hình.

3. SSH tạo kết nối mới cho mỗi lệnh

30 lệnh = 30 kết nối TCP + xác thực.

Khắc phục: Sử dụng ControlMaster để ghép kênh kết nối.

4. Xâu chuỗi tập lệnh bao bọc với &&

source snap && cd ... && user command && export -p -- nếu lệnh của người dùng trả về khác 0, export -p không thực thi. Ảnh chụp nhanh bị hỏng và tất cả các lệnh tiếp theo sẽ mất trạng thái môi trường.

Khắc phục: Sử dụng ; để phân tách các lệnh hoặc lưu mã thoát trước khi xuất (xem phần _exit=$? mẫu trong quá trình triển khai).

Phạm vi của chương này

Chương này chỉ trình bày phần trừu tượng hóa phần phụ trợ cho công cụ đầu cuối. read_file, write_filevà các công cụ tương tự không đi qua lớp này -- chúng hoạt động trực tiếp trên hệ thống tệp thông qua mã Python, không cần cơ chế chụp nhanh.

Tại sao terminal cần điều này trong khi read_file thì không? Bởi vì terminal có trạng thái lệnh chéo -- của agent cd và export phải có hiệu lực ở các lệnh tiếp theo. Đó là vấn đề cốt lõi mà ảnh chụp nhanh giải quyết. read_file không trạng thái: được cung cấp một đường dẫn, trả về nội dung, không cần nhớ những gì đã đọc lần trước. Trong Docker, thay đổi open(path) để docker exec cat path là một sửa đổi một dòng.

Ba điều được đề cập:

  1. Tại sao cần có sự trừu tượng hóa phụ trợ -- Xuất phát từ nỗi đau của if-else
  2. Cách ảnh chụp nhanh giúp duy trì trạng thái trên các lệnh -- Xem qua ba lệnh
  3. Ba cách triển khai phụ trợ -- Cục bộ (lọc khóa), Docker (vùng chứa chạy dài + tham số bảo mật), SSH (ControlMaster)

Không được bảo hiểm:

  • Modal/Daytona/Singularity -> giống với Docker
  • Quản lý quy trình nền -> tính năng công cụ đầu cuối, không phải trách nhiệm phụ trợ
  • Đồng bộ hóa tệp (cách SSH chuyển các tệp cục bộ sang điều khiển từ xa) -> cải tiến phụ trợ SSH
  • Cải tạo môi trường nhàn rỗi -> tối ưu hóa sản xuất

Chương này liên quan đến các chương sau như thế nào

  • s02 đã đăng ký công cụ đầu cuối -> chương này xác định môi trường thực thi cơ bản của nó
  • Hệ thống cấp phép của s09 chặn các lệnh nguy hiểm -> trước khi chúng đến được phần phụ trợ
  • Sự trừu tượng hóa bộ chuyển đổi của s13 và sự trừu tượng hóa phụ trợ của chương này tuân theo cùng một cách tiếp cận -- gói gọn những khác biệt để lớp ở trên không quan tâm
  • Các tác vụ được lên lịch của s15 cũng thực thi các lệnh -> chúng sử dụng lại cùng một chương trình phụ trợ

Sau chương này, bạn sẽ có thể trả lời

  • Người đại diện chạy cd /tmp sau đó pwd -- tại sao nó lại xuất ra /tmp? Không phải mỗi lệnh đều chạy trong một bash mới sao?
  • Tại sao không duy trì một tiến trình bash chạy dài?
  • Phần phụ trợ cục bộ có cần lọc khóa API không? Docker có phụ trợ không? Tại sao hoặc tại sao không?
  • Tại sao phần phụ trợ của Docker lại sử dụng vùng chứa chạy dài thay vì docker run cho mỗi lệnh?
  • Chuyển từ local để docker trong config.yaml -- cần thay đổi bao nhiêu dòng mã công cụ?

Trong một câu: Phần phụ trợ chỉ cần triển khai "cách bắt đầu bash" và "cách dọn dẹp". Ảnh chụp nhanh giúp các biến môi trường và thư mục hoạt động tồn tại liền mạch trên các quy trình bash mới.

Mục lục nguồn