diff --git a/s11_background_tasks/README.ja.md b/s11_background_tasks/README.ja.md index 736e9b98a..02fd01663 100644 --- a/s11_background_tasks/README.ja.md +++ b/s11_background_tasks/README.ja.md @@ -78,7 +78,7 @@ class BackgroundManager: self._ready.append(task_id) ``` -command が非ゼロで終了した場合や worker で例外が起きた場合は `failed` となる。Shell は独立した process group で起動し、command の完了、timeout、または Agent が通常経路や `SIGTERM` で終了する時に元の group を停止する。これは lifecycle cleanup であって sandbox ではなく、別の session を作った process は group から離れられる。 +command が非ゼロで終了した場合や worker で例外が起きた場合は `failed` となる。POSIX では Shell を独立した process group で起動し、command の完了、timeout、または Agent が通常経路や `SIGTERM` で終了する時に元の group を停止する。Windows には POSIX の process-group signal がないため、実行中の Shell process に `Popen.terminate()` と `Popen.kill()` を使う。これは lifecycle cleanup であって sandbox ではなく、管理対象の Shell から離れたり別の session を作ったりした process は残る場合がある。 ### collect_background_results: 通知収集 @@ -160,9 +160,9 @@ python s11_background_tasks/code.py 以下のプロンプトを試してください: -1. `Run pip list in the background and find all Python files in this directory` -2. `Run npm install (use run_in_background) and while waiting, read package.json` -3. `Run a short sleep in the background, then list all Markdown files` +1. `Run python -m pip list in the background and find all Python files in this directory` +2. `Run npm --prefix web install (use run_in_background) and while waiting, read web/package.json` +3. `Run python -c "import time; time.sleep(3); print('done')" in the background, then list all Markdown files` 観察ポイント:`run_in_background` を明示的に設定すると、コマンドがバックグラウンドに送られるか?`bg_id` は返されるか?後続のターンで完了結果が `` 形式で収集されるか? diff --git a/s11_background_tasks/README.md b/s11_background_tasks/README.md index 8443ff1dc..545a21e74 100644 --- a/s11_background_tasks/README.md +++ b/s11_background_tasks/README.md @@ -78,7 +78,7 @@ class BackgroundManager: self._ready.append(task_id) ``` -A non-zero exit code or worker exception becomes `failed`. The shell starts in its own process group. When the command finishes, times out, or the Agent exits through the normal or `SIGTERM` path, the runtime stops that original group. This is lifecycle cleanup, not a sandbox: a process that creates another session can leave the group. +A non-zero exit code or worker exception becomes `failed`. On POSIX, the shell starts in its own process group, which the runtime stops when the command finishes, times out, or the Agent exits through the normal or `SIGTERM` path. Windows has no POSIX process-group signals, so the runtime uses `Popen.terminate()` and `Popen.kill()` for a still-running shell process. This is lifecycle cleanup, not a sandbox: a process that escapes the managed shell or creates another session may survive it. ### collect_background_results: Notification Collection @@ -160,9 +160,9 @@ python s11_background_tasks/code.py Try these prompts: -1. `Run pip list in the background and find all Python files in this directory` -2. `Run npm install (use run_in_background) and while waiting, read package.json` -3. `Run a short sleep in the background, then list all Markdown files` +1. `Run python -m pip list in the background and find all Python files in this directory` +2. `Run npm --prefix web install (use run_in_background) and while waiting, read web/package.json` +3. `Run python -c "import time; time.sleep(3); print('done')" in the background, then list all Markdown files` What to observe: After explicitly setting `run_in_background`, is the command dispatched to the background? Is a `bg_id` returned? Are completed results collected in `` format on a later turn? diff --git a/s11_background_tasks/README.zh.md b/s11_background_tasks/README.zh.md index 06d167f4f..27fe7d27f 100644 --- a/s11_background_tasks/README.zh.md +++ b/s11_background_tasks/README.zh.md @@ -78,7 +78,7 @@ class BackgroundManager: self._ready.append(task_id) ``` -命令以非零状态退出或 worker 抛出异常时,任务会进入 `failed`。Shell 会在独立的进程组中启动;命令完成、超时,或 Agent 经正常路径、`SIGTERM` 退出时,运行时会停止原进程组。这只是生命周期清理,并不是沙箱;另建 session 的进程仍可能离开该进程组。 +命令以非零状态退出或 worker 抛出异常时,任务会进入 `failed`。在 POSIX 上,Shell 会在独立的进程组中启动;命令完成、超时,或 Agent 经正常路径、`SIGTERM` 退出时,运行时会停止原进程组。Windows 没有 POSIX 进程组信号,因此运行时会对仍在运行的 Shell 进程调用 `Popen.terminate()` 和 `Popen.kill()`。这只是生命周期清理,并不是沙箱;脱离受管 Shell 或另建 session 的进程仍可能存活。 ### collect_background_results: 通知收集 @@ -160,9 +160,9 @@ python s11_background_tasks/code.py 试试这些 prompt: -1. `Run pip list in the background and find all Python files in this directory` -2. `Run npm install (use run_in_background) and while waiting, read package.json` -3. `Run a short sleep in the background, then list all Markdown files` +1. `Run python -m pip list in the background and find all Python files in this directory` +2. `Run npm --prefix web install (use run_in_background) and while waiting, read web/package.json` +3. `Run python -c "import time; time.sleep(3); print('done')" in the background, then list all Markdown files` 观察重点:显式设置 `run_in_background` 后,命令有没有被送到后台?`bg_id` 是否返回?后续轮次有没有以 `` 格式收集完成结果? diff --git a/s11_background_tasks/code.py b/s11_background_tasks/code.py index 22b74694b..220707466 100644 --- a/s11_background_tasks/code.py +++ b/s11_background_tasks/code.py @@ -44,7 +44,13 @@ SYSTEM = ( f"You are a coding agent at {WORKDIR}. Use tools to solve tasks. " - "Set run_in_background to true only for independent Bash commands." + "Set run_in_background to true only for independent shell commands." + + ( + " On Windows, the bash tool uses cmd.exe; use cmd-compatible or " + "cross-platform commands." + if os.name == "nt" + else "" + ) ) @@ -56,6 +62,23 @@ def _stop_process_group(process: subprocess.Popen): """Stop processes that remain in the command's original process group.""" + if os.name == "nt": + # Windows has no os.killpg or SIGKILL. Use Popen's native methods + # without changing the background-task protocol demonstrated here. + if process.poll() is not None: + return + try: + process.terminate() + process.wait(timeout=0.2) + except subprocess.TimeoutExpired: + try: + process.kill() + except OSError: + pass + except OSError: + pass + return + for sig in (signal.SIGTERM, signal.SIGKILL): try: os.killpg(process.pid, sig) @@ -383,7 +406,7 @@ def collect(self) -> list[str]: notifications = [] for task_id, task, result in ready: - notifications.append( + notification = ( f"\n" f" {task_id}\n" f" {task['status']}\n" @@ -391,7 +414,11 @@ def collect(self) -> list[str]: f" {result[:500]}\n" f"" ) - print(f" [background] collected {task_id}: {task['status']}") + notifications.append(notification) + print( + f" [background] collected {task_id} " + f"as : {task['status']}" + ) return notifications diff --git a/tests/test_background_tasks.py b/tests/test_background_tasks.py index e3b5ce5cf..23a3df466 100644 --- a/tests/test_background_tasks.py +++ b/tests/test_background_tasks.py @@ -1,8 +1,10 @@ import copy import importlib.util import os +import subprocess import sys import tempfile +import threading import time import types from pathlib import Path @@ -63,6 +65,50 @@ def wait_until(predicate, timeout: float = 2.0) -> bool: return False +def test_windows_process_cleanup_avoids_posix_only_signals(): + with tempfile.TemporaryDirectory() as tmp: + lesson = load_lesson(Path(tmp)) + calls = [] + + class RunningProcess: + def poll(self): + return None + + def terminate(self): + calls.append("terminate") + + def wait(self, timeout): + calls.append(("wait", timeout)) + return 0 + + def kill(self): + calls.append("kill") + + original_os_name = lesson.os.name + try: + lesson.os.name = "nt" + lesson._stop_process_group(RunningProcess()) + finally: + lesson.os.name = original_os_name + + assert calls == ["terminate", ("wait", 0.2)] + + calls.clear() + + class StubbornProcess(RunningProcess): + def wait(self, timeout): + calls.append(("wait", timeout)) + raise subprocess.TimeoutExpired("test", timeout) + + try: + lesson.os.name = "nt" + lesson._stop_process_group(StubbornProcess()) + finally: + lesson.os.name = original_os_name + + assert calls == ["terminate", ("wait", 0.2), "kill"] + + def test_s11_keeps_the_s04_kernel_and_adds_one_bash_option(): with tempfile.TemporaryDirectory() as tmp: lesson = load_lesson(Path(tmp)) @@ -122,12 +168,30 @@ def test_background_bash_passes_permission_before_dispatch(): def test_completed_result_is_collected_once_before_a_later_llm_call(): with tempfile.TemporaryDirectory() as tmp: lesson = load_lesson(Path(tmp)) + worker_started = threading.Event() + release_worker = threading.Event() + + def controlled_command(command): + assert command == "controlled command" + worker_started.set() + assert release_worker.wait(timeout=2) + return "ready", 0 + + lesson._run_bash_process = controlled_command block = types.SimpleNamespace( id="tool_ready", name="bash", - input={"command": "printf ready", "run_in_background": True}, + input={"command": "controlled command", "run_in_background": True}, ) - task_id = lesson.start_background_task(block) + start_result = lesson.execute_tool(block) + task_id = next(iter(lesson.background_tasks)) + + assert worker_started.wait(timeout=2) + assert task_id == "bg_0001" + assert task_id in start_result + assert lesson.background_tasks[task_id]["status"] == "running" + + release_worker.set() assert wait_until( lambda: lesson.background_tasks[task_id]["status"] == "completed" ) diff --git a/web/src/data/generated/docs.json b/web/src/data/generated/docs.json index ab8048d6e..32de7bd69 100644 --- a/web/src/data/generated/docs.json +++ b/web/src/data/generated/docs.json @@ -183,19 +183,19 @@ "version": "s11", "locale": "en", "title": "s11: Background Tasks — Slow Operations Go to the Background", - "content": "# s11: Background Tasks — Slow Operations Go to the Background\n\ns01 → ... → s09 → s10 → `s11` → [s12](/en/s12) → s13 → ... → s16 → s17\n\n> *\"Slow operations go to the background, the Agent Loop continues\"* — Background threads run commands, and later turns collect completed results.\n>\n> **Harness Layer**: Background — Async execution, doesn't block the main loop.\n\n---\n\n## The Problem\n\nReading a file or running `git status` usually returns quickly, so synchronous execution causes little noticeable delay. Installing dependencies, running a full test suite, or building a project can take several minutes. Until the command returns, the Harness cannot process the next tool call in the current response or start the next model turn.\n\nIf later work does not depend on that command, there is no need to block it. For example, after starting a full test suite, the Agent could inspect documentation or organize other files while the tests run.\n\nS11 addresses this by running slow Bash commands in the background, allowing the Agent Loop to continue and collect completed results on a later turn.\n\n---\n\n## The Solution\n\n![Background Tasks Overview](/course-assets/s11_background_tasks/background-tasks-overview.en.svg)\n\nThis chapter sends slow operations to background threads. The current tool call first returns a placeholder `tool_result`, allowing the Agent Loop to continue. At the start of a later turn, completed results are collected and added to the conversation as notifications.\n\nSync vs Background:\n\n| | Sync (s04) | Background (s11) |\n|---|---|---|\n| Slow operations | Current tool call blocks | Background thread executes |\n| Agent Loop | Waits for the command to return | Continues after the placeholder result |\n| Result | Returned after the command finishes | Returns `bg_id` first; collects the result on a later turn |\n| Decision criteria | — | bash `run_in_background` parameter |\n\n---\n\n## How It Works\n\n### should_run_background: Explicit Request\n\nThe model requests background execution through the bash tool's `run_in_background` parameter. Only bash calls with the parameter explicitly set to `true` enter this path. Other calls still run synchronously.\n\n```python\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n return (\n tool_name == \"bash\"\n and tool_input.get(\"run_in_background\") is True\n )\n```\n\nThe Harness no longer guesses from keywords such as `install`, `build`, or `test`. The tool call chooses the execution mode explicitly.\n\n### BackgroundManager: Background Execution and Lifecycle\n\n`BackgroundManager` owns task state and the completion queue. `start()` registers a task, starts a daemon thread, and returns `bg_id` immediately:\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self.results = {}\n self._ready = []\n self._lock = threading.Lock()\n\n def start(self, block) -> str:\n # Register task, then run _run() in a daemon thread.\n ...\n\n def _run(self, task_id: str, command: str):\n output, exit_code = _run_bash_process(command)\n status = \"completed\" if exit_code == 0 else \"failed\"\n with self._lock:\n self.tasks[task_id][\"status\"] = status\n self.results[task_id] = _format_bash_result(output, exit_code)\n self._ready.append(task_id)\n```\n\nA non-zero exit code or worker exception becomes `failed`. The shell starts in its own process group. When the command finishes, times out, or the Agent exits through the normal or `SIGTERM` path, the runtime stops that original group. This is lifecycle cleanup, not a sandbox: a process that creates another session can leave the group.\n\n### collect_background_results: Notification Collection\n\nAt the start of a later turn, `collect()` removes completed results from the queue and formats them as `` messages:\n\n```python\ndef collect_background_results() -> list[str]:\n return BACKGROUND.collect()\n```\n\nNotifications don't reuse the original `tool_use_id`. The original tool call was already answered with a placeholder `tool_result`; when the completed result is collected, it is added as an independent event in `task_notification` format. One `tool_use` still gets exactly one `tool_result`.\n\n### Loop Integration\n\nBefore each LLM call, the Agent Loop collects completed background results. `execute_tool()` still runs `PreToolUse` on the main thread before choosing synchronous or background execution:\n\n```python\nwhile True:\n inject_background_results(messages)\n response = client.messages.create(...)\n\ndef execute_tool(block) -> str:\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked is not None:\n return str(blocked)\n if should_run_background(block.name, block.input):\n task_id = start_background_task(block)\n output = f\"[Background task {task_id} started]\"\n else:\n output = call_tool(block)\n trigger_hooks(\"PostToolUse\", block, output)\n return output\n```\n\nSlow operations first return a placeholder tool_result with `bg_id`. A completed task does not wake the Agent by itself; `inject_background_results()` collects it the next time the Agent Loop runs.\n\n### Putting It Together\n\n```\nTurn 1:\n LLM → bash \"npm install\" (run_in_background=true)\n → start_background_task → bg_0001\n → tool_result: \"[Background task bg_0001 started]...\"\n → LLM: \"OK, I'll check later. Let me also read the config.\"\n\nTurn 2:\n LLM → read_file \"package.json\" (fast, sync)\n → tool_result: file content\n\nTurn 3:\n → collect bg_0001 as \n → LLM sees: config file + install notification in one message\n```\n\nWhile npm install ran in the background, the Agent Loop continued with read_file.\n\n---\n\n## What s11 Adds\n\n| Component | s04 Kernel | s11 |\n|-----------|-------------|-------------|\n| Execution model | All synchronous | Slow ops to background thread + notification injection |\n| bash schema | `command` | `command` + `run_in_background` |\n| New functions | — | `should_run_background`, `start_background_task`, `collect_background_results`, `inject_background_results` |\n| New types | — | `BackgroundManager` |\n| Notification format | — | `` (doesn't reuse tool_use_id) |\n| Loop behavior | Tools execute synchronously | Explicit background execution, completed results collected on later turns |\n| Tools | 5 | 5 (one parameter added to the bash schema) |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s11_background_tasks/code.py\n```\n\nTry these prompts:\n\n1. `Run pip list in the background and find all Python files in this directory`\n2. `Run npm install (use run_in_background) and while waiting, read package.json`\n3. `Run a short sleep in the background, then list all Markdown files`\n\nWhat to observe: After explicitly setting `run_in_background`, is the command dispatched to the background? Is a `bg_id` returned? Are completed results collected in `` format on a later turn?\n\n---\n\n## What's Next\n\nBackground tasks solved \"slow operations don't block.\" But what if you want to do something on a schedule? Like \"run tests every morning at 9am\" or \"check server status every 5 minutes.\"\n\ns12 Cron Scheduler → Give the agent an alarm clock.\n\n\n\n" + "content": "# s11: Background Tasks — Slow Operations Go to the Background\n\ns01 → ... → s09 → s10 → `s11` → [s12](/en/s12) → s13 → ... → s16 → s17\n\n> *\"Slow operations go to the background, the Agent Loop continues\"* — Background threads run commands, and later turns collect completed results.\n>\n> **Harness Layer**: Background — Async execution, doesn't block the main loop.\n\n---\n\n## The Problem\n\nReading a file or running `git status` usually returns quickly, so synchronous execution causes little noticeable delay. Installing dependencies, running a full test suite, or building a project can take several minutes. Until the command returns, the Harness cannot process the next tool call in the current response or start the next model turn.\n\nIf later work does not depend on that command, there is no need to block it. For example, after starting a full test suite, the Agent could inspect documentation or organize other files while the tests run.\n\nS11 addresses this by running slow Bash commands in the background, allowing the Agent Loop to continue and collect completed results on a later turn.\n\n---\n\n## The Solution\n\n![Background Tasks Overview](/course-assets/s11_background_tasks/background-tasks-overview.en.svg)\n\nThis chapter sends slow operations to background threads. The current tool call first returns a placeholder `tool_result`, allowing the Agent Loop to continue. At the start of a later turn, completed results are collected and added to the conversation as notifications.\n\nSync vs Background:\n\n| | Sync (s04) | Background (s11) |\n|---|---|---|\n| Slow operations | Current tool call blocks | Background thread executes |\n| Agent Loop | Waits for the command to return | Continues after the placeholder result |\n| Result | Returned after the command finishes | Returns `bg_id` first; collects the result on a later turn |\n| Decision criteria | — | bash `run_in_background` parameter |\n\n---\n\n## How It Works\n\n### should_run_background: Explicit Request\n\nThe model requests background execution through the bash tool's `run_in_background` parameter. Only bash calls with the parameter explicitly set to `true` enter this path. Other calls still run synchronously.\n\n```python\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n return (\n tool_name == \"bash\"\n and tool_input.get(\"run_in_background\") is True\n )\n```\n\nThe Harness no longer guesses from keywords such as `install`, `build`, or `test`. The tool call chooses the execution mode explicitly.\n\n### BackgroundManager: Background Execution and Lifecycle\n\n`BackgroundManager` owns task state and the completion queue. `start()` registers a task, starts a daemon thread, and returns `bg_id` immediately:\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self.results = {}\n self._ready = []\n self._lock = threading.Lock()\n\n def start(self, block) -> str:\n # Register task, then run _run() in a daemon thread.\n ...\n\n def _run(self, task_id: str, command: str):\n output, exit_code = _run_bash_process(command)\n status = \"completed\" if exit_code == 0 else \"failed\"\n with self._lock:\n self.tasks[task_id][\"status\"] = status\n self.results[task_id] = _format_bash_result(output, exit_code)\n self._ready.append(task_id)\n```\n\nA non-zero exit code or worker exception becomes `failed`. On POSIX, the shell starts in its own process group, which the runtime stops when the command finishes, times out, or the Agent exits through the normal or `SIGTERM` path. Windows has no POSIX process-group signals, so the runtime uses `Popen.terminate()` and `Popen.kill()` for a still-running shell process. This is lifecycle cleanup, not a sandbox: a process that escapes the managed shell or creates another session may survive it.\n\n### collect_background_results: Notification Collection\n\nAt the start of a later turn, `collect()` removes completed results from the queue and formats them as `` messages:\n\n```python\ndef collect_background_results() -> list[str]:\n return BACKGROUND.collect()\n```\n\nNotifications don't reuse the original `tool_use_id`. The original tool call was already answered with a placeholder `tool_result`; when the completed result is collected, it is added as an independent event in `task_notification` format. One `tool_use` still gets exactly one `tool_result`.\n\n### Loop Integration\n\nBefore each LLM call, the Agent Loop collects completed background results. `execute_tool()` still runs `PreToolUse` on the main thread before choosing synchronous or background execution:\n\n```python\nwhile True:\n inject_background_results(messages)\n response = client.messages.create(...)\n\ndef execute_tool(block) -> str:\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked is not None:\n return str(blocked)\n if should_run_background(block.name, block.input):\n task_id = start_background_task(block)\n output = f\"[Background task {task_id} started]\"\n else:\n output = call_tool(block)\n trigger_hooks(\"PostToolUse\", block, output)\n return output\n```\n\nSlow operations first return a placeholder tool_result with `bg_id`. A completed task does not wake the Agent by itself; `inject_background_results()` collects it the next time the Agent Loop runs.\n\n### Putting It Together\n\n```\nTurn 1:\n LLM → bash \"npm install\" (run_in_background=true)\n → start_background_task → bg_0001\n → tool_result: \"[Background task bg_0001 started]...\"\n → LLM: \"OK, I'll check later. Let me also read the config.\"\n\nTurn 2:\n LLM → read_file \"package.json\" (fast, sync)\n → tool_result: file content\n\nTurn 3:\n → collect bg_0001 as \n → LLM sees: config file + install notification in one message\n```\n\nWhile npm install ran in the background, the Agent Loop continued with read_file.\n\n---\n\n## What s11 Adds\n\n| Component | s04 Kernel | s11 |\n|-----------|-------------|-------------|\n| Execution model | All synchronous | Slow ops to background thread + notification injection |\n| bash schema | `command` | `command` + `run_in_background` |\n| New functions | — | `should_run_background`, `start_background_task`, `collect_background_results`, `inject_background_results` |\n| New types | — | `BackgroundManager` |\n| Notification format | — | `` (doesn't reuse tool_use_id) |\n| Loop behavior | Tools execute synchronously | Explicit background execution, completed results collected on later turns |\n| Tools | 5 | 5 (one parameter added to the bash schema) |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s11_background_tasks/code.py\n```\n\nTry these prompts:\n\n1. `Run python -m pip list in the background and find all Python files in this directory`\n2. `Run npm --prefix web install (use run_in_background) and while waiting, read web/package.json`\n3. `Run python -c \"import time; time.sleep(3); print('done')\" in the background, then list all Markdown files`\n\nWhat to observe: After explicitly setting `run_in_background`, is the command dispatched to the background? Is a `bg_id` returned? Are completed results collected in `` format on a later turn?\n\n---\n\n## What's Next\n\nBackground tasks solved \"slow operations don't block.\" But what if you want to do something on a schedule? Like \"run tests every morning at 9am\" or \"check server status every 5 minutes.\"\n\ns12 Cron Scheduler → Give the agent an alarm clock.\n\n\n\n" }, { "version": "s11", "locale": "zh", "title": "s11: Background Tasks — 慢操作放后台", - "content": "# s11: Background Tasks — 慢操作放后台\n\ns01 → ... → s09 → s10 → `s11` → [s12](/zh/s12) → s13 → ... → s16 → s17\n\n> *\"慢操作放后台,Agent Loop 继续运行\"* — 后台线程执行命令,后续轮次收集完成结果。\n>\n> **Harness 层**: 后台 — 异步执行, 不阻塞主循环。\n\n---\n\n## 问题\n\n读取文件或运行 `git status` 通常很快,同步执行时等待并不明显。但安装依赖、执行完整测试或构建项目可能持续几分钟。在命令返回前,Harness 无法处理当前响应中的下一个工具调用,也不能进入下一轮。\n\n如果后续工作并不依赖这个命令,继续等待就没有必要。例如,Agent 启动完整测试后,本来还可以检查文档或整理其他文件,但同步执行会让整个 Agent Loop 停在这次 Bash 调用上。\n\nS11 要解决的问题是:让耗时的 Bash 命令在后台执行,使 Agent Loop 可以继续处理其他工作,并在后续轮次收集完成结果。\n\n---\n\n## 解决方案\n\n![Background Tasks Overview](/course-assets/s11_background_tasks/background-tasks-overview.svg)\n\n本章把慢操作放入后台线程。当前工具调用先返回一个占位 `tool_result`,Agent Loop 可以继续运行;后续轮次开始时再收集已经完成的结果,以通知形式加入对话。\n\n同步 vs 后台:\n\n| | 同步 (s04) | 后台 (s11) |\n|---|---|---|\n| 慢操作 | 当前工具调用被阻塞 | 后台线程执行 |\n| Agent Loop | 等待命令返回 | 收到占位结果后继续运行 |\n| 结果 | 命令结束后返回 | 先返回 `bg_id`,后续轮次收集结果 |\n| 判断标准 | — | bash 的 `run_in_background` 参数 |\n\n---\n\n## 工作原理\n\n### should_run_background: 显式请求\n\n模型通过 bash 工具的 `run_in_background` 参数请求后台执行。只有参数明确为 `true`,并且工具是 bash 时,才会进入后台执行路径。其他调用仍然同步执行。\n\n```python\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n return (\n tool_name == \"bash\"\n and tool_input.get(\"run_in_background\") is True\n )\n```\n\n不再根据 `install`、`build` 或 `test` 等关键词猜测。是否进入后台由工具调用明确决定。\n\n### BackgroundManager: 后台执行与生命周期\n\n`BackgroundManager` 保存任务状态和完成队列。`start()` 先登记任务,再启动 daemon 线程,并立即返回 `bg_id`:\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self.results = {}\n self._ready = []\n self._lock = threading.Lock()\n\n def start(self, block) -> str:\n # Register task, then run _run() in a daemon thread.\n ...\n\n def _run(self, task_id: str, command: str):\n output, exit_code = _run_bash_process(command)\n status = \"completed\" if exit_code == 0 else \"failed\"\n with self._lock:\n self.tasks[task_id][\"status\"] = status\n self.results[task_id] = _format_bash_result(output, exit_code)\n self._ready.append(task_id)\n```\n\n命令以非零状态退出或 worker 抛出异常时,任务会进入 `failed`。Shell 会在独立的进程组中启动;命令完成、超时,或 Agent 经正常路径、`SIGTERM` 退出时,运行时会停止原进程组。这只是生命周期清理,并不是沙箱;另建 session 的进程仍可能离开该进程组。\n\n### collect_background_results: 通知收集\n\n后续轮次开始时,`collect()` 从完成队列中取出结果,并格式化为 `` 通知:\n\n```python\ndef collect_background_results() -> list[str]:\n return BACKGROUND.collect()\n```\n\n通知不复用原始 `tool_use_id`。原始 tool call 已经用占位 `tool_result` 回复了;后续收集完成结果时,会用 `task_notification` 格式把它作为独立事件加入对话。一个 `tool_use` 仍然只对应一个 `tool_result`。\n\n### 循环中的集成\n\n每次调用 LLM 前,Agent Loop 先收集已经完成的后台结果。`execute_tool()` 仍然在主线程执行 `PreToolUse`,然后再选择同步或后台执行:\n\n```python\nwhile True:\n inject_background_results(messages)\n response = client.messages.create(...)\n\ndef execute_tool(block) -> str:\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked is not None:\n return str(blocked)\n if should_run_background(block.name, block.input):\n task_id = start_background_task(block)\n output = f\"[Background task {task_id} started]\"\n else:\n output = call_tool(block)\n trigger_hooks(\"PostToolUse\", block, output)\n return output\n```\n\n慢操作先返回一个带 `bg_id` 的占位 tool_result。后台结果不会主动唤醒 Agent;下一次进入 Agent Loop 时,`inject_background_results()` 才会收集已经完成的结果。\n\n### 合起来跑\n\n```\nTurn 1:\n LLM → bash \"npm install\" (run_in_background=true)\n → start_background_task → bg_0001\n → tool_result: \"[Background task bg_0001 started]...\"\n → LLM: \"OK, I'll check later. Let me also read the config.\"\n\nTurn 2:\n LLM → read_file \"package.json\" (fast, sync)\n → tool_result: file content\n\nTurn 3:\n → collect bg_0001 as \n → LLM sees: config file + install notification in one message\n```\n\nnpm install 在后台运行时,Agent Loop 继续执行了 read_file。\n\n---\n\n## 本章新增了什么\n\n| 组件 | S04 Kernel | S11 |\n|------|-----------|-----------|\n| 执行模型 | 全部同步 | 慢操作后台线程 + 通知注入 |\n| bash schema | `command` | `command` + `run_in_background` |\n| 新函数 | — | `should_run_background`, `start_background_task`, `collect_background_results`, `inject_background_results` |\n| 新类型 | — | `BackgroundManager` |\n| 通知格式 | — | ``(不复用 tool_use_id) |\n| 循环行为 | 工具同步执行 | 显式后台执行,后续轮次收集完成结果 |\n| 工具 | 5 | 5(bash schema 增加一个参数) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s11_background_tasks/code.py\n```\n\n试试这些 prompt:\n\n1. `Run pip list in the background and find all Python files in this directory`\n2. `Run npm install (use run_in_background) and while waiting, read package.json`\n3. `Run a short sleep in the background, then list all Markdown files`\n\n观察重点:显式设置 `run_in_background` 后,命令有没有被送到后台?`bg_id` 是否返回?后续轮次有没有以 `` 格式收集完成结果?\n\n---\n\n## 接下来\n\n后台任务解决了\"慢操作不阻塞\"。但如果想定时做某件事呢?比如\"每天早上 9 点跑测试\"、\"每 5 分钟检查一次服务器状态\"。\n\ns12 Cron Scheduler → 给 Agent 装一个闹钟。\n\n\n\n" + "content": "# s11: Background Tasks — 慢操作放后台\n\ns01 → ... → s09 → s10 → `s11` → [s12](/zh/s12) → s13 → ... → s16 → s17\n\n> *\"慢操作放后台,Agent Loop 继续运行\"* — 后台线程执行命令,后续轮次收集完成结果。\n>\n> **Harness 层**: 后台 — 异步执行, 不阻塞主循环。\n\n---\n\n## 问题\n\n读取文件或运行 `git status` 通常很快,同步执行时等待并不明显。但安装依赖、执行完整测试或构建项目可能持续几分钟。在命令返回前,Harness 无法处理当前响应中的下一个工具调用,也不能进入下一轮。\n\n如果后续工作并不依赖这个命令,继续等待就没有必要。例如,Agent 启动完整测试后,本来还可以检查文档或整理其他文件,但同步执行会让整个 Agent Loop 停在这次 Bash 调用上。\n\nS11 要解决的问题是:让耗时的 Bash 命令在后台执行,使 Agent Loop 可以继续处理其他工作,并在后续轮次收集完成结果。\n\n---\n\n## 解决方案\n\n![Background Tasks Overview](/course-assets/s11_background_tasks/background-tasks-overview.svg)\n\n本章把慢操作放入后台线程。当前工具调用先返回一个占位 `tool_result`,Agent Loop 可以继续运行;后续轮次开始时再收集已经完成的结果,以通知形式加入对话。\n\n同步 vs 后台:\n\n| | 同步 (s04) | 后台 (s11) |\n|---|---|---|\n| 慢操作 | 当前工具调用被阻塞 | 后台线程执行 |\n| Agent Loop | 等待命令返回 | 收到占位结果后继续运行 |\n| 结果 | 命令结束后返回 | 先返回 `bg_id`,后续轮次收集结果 |\n| 判断标准 | — | bash 的 `run_in_background` 参数 |\n\n---\n\n## 工作原理\n\n### should_run_background: 显式请求\n\n模型通过 bash 工具的 `run_in_background` 参数请求后台执行。只有参数明确为 `true`,并且工具是 bash 时,才会进入后台执行路径。其他调用仍然同步执行。\n\n```python\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n return (\n tool_name == \"bash\"\n and tool_input.get(\"run_in_background\") is True\n )\n```\n\n不再根据 `install`、`build` 或 `test` 等关键词猜测。是否进入后台由工具调用明确决定。\n\n### BackgroundManager: 后台执行与生命周期\n\n`BackgroundManager` 保存任务状态和完成队列。`start()` 先登记任务,再启动 daemon 线程,并立即返回 `bg_id`:\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self.results = {}\n self._ready = []\n self._lock = threading.Lock()\n\n def start(self, block) -> str:\n # Register task, then run _run() in a daemon thread.\n ...\n\n def _run(self, task_id: str, command: str):\n output, exit_code = _run_bash_process(command)\n status = \"completed\" if exit_code == 0 else \"failed\"\n with self._lock:\n self.tasks[task_id][\"status\"] = status\n self.results[task_id] = _format_bash_result(output, exit_code)\n self._ready.append(task_id)\n```\n\n命令以非零状态退出或 worker 抛出异常时,任务会进入 `failed`。在 POSIX 上,Shell 会在独立的进程组中启动;命令完成、超时,或 Agent 经正常路径、`SIGTERM` 退出时,运行时会停止原进程组。Windows 没有 POSIX 进程组信号,因此运行时会对仍在运行的 Shell 进程调用 `Popen.terminate()` 和 `Popen.kill()`。这只是生命周期清理,并不是沙箱;脱离受管 Shell 或另建 session 的进程仍可能存活。\n\n### collect_background_results: 通知收集\n\n后续轮次开始时,`collect()` 从完成队列中取出结果,并格式化为 `` 通知:\n\n```python\ndef collect_background_results() -> list[str]:\n return BACKGROUND.collect()\n```\n\n通知不复用原始 `tool_use_id`。原始 tool call 已经用占位 `tool_result` 回复了;后续收集完成结果时,会用 `task_notification` 格式把它作为独立事件加入对话。一个 `tool_use` 仍然只对应一个 `tool_result`。\n\n### 循环中的集成\n\n每次调用 LLM 前,Agent Loop 先收集已经完成的后台结果。`execute_tool()` 仍然在主线程执行 `PreToolUse`,然后再选择同步或后台执行:\n\n```python\nwhile True:\n inject_background_results(messages)\n response = client.messages.create(...)\n\ndef execute_tool(block) -> str:\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked is not None:\n return str(blocked)\n if should_run_background(block.name, block.input):\n task_id = start_background_task(block)\n output = f\"[Background task {task_id} started]\"\n else:\n output = call_tool(block)\n trigger_hooks(\"PostToolUse\", block, output)\n return output\n```\n\n慢操作先返回一个带 `bg_id` 的占位 tool_result。后台结果不会主动唤醒 Agent;下一次进入 Agent Loop 时,`inject_background_results()` 才会收集已经完成的结果。\n\n### 合起来跑\n\n```\nTurn 1:\n LLM → bash \"npm install\" (run_in_background=true)\n → start_background_task → bg_0001\n → tool_result: \"[Background task bg_0001 started]...\"\n → LLM: \"OK, I'll check later. Let me also read the config.\"\n\nTurn 2:\n LLM → read_file \"package.json\" (fast, sync)\n → tool_result: file content\n\nTurn 3:\n → collect bg_0001 as \n → LLM sees: config file + install notification in one message\n```\n\nnpm install 在后台运行时,Agent Loop 继续执行了 read_file。\n\n---\n\n## 本章新增了什么\n\n| 组件 | S04 Kernel | S11 |\n|------|-----------|-----------|\n| 执行模型 | 全部同步 | 慢操作后台线程 + 通知注入 |\n| bash schema | `command` | `command` + `run_in_background` |\n| 新函数 | — | `should_run_background`, `start_background_task`, `collect_background_results`, `inject_background_results` |\n| 新类型 | — | `BackgroundManager` |\n| 通知格式 | — | ``(不复用 tool_use_id) |\n| 循环行为 | 工具同步执行 | 显式后台执行,后续轮次收集完成结果 |\n| 工具 | 5 | 5(bash schema 增加一个参数) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s11_background_tasks/code.py\n```\n\n试试这些 prompt:\n\n1. `Run python -m pip list in the background and find all Python files in this directory`\n2. `Run npm --prefix web install (use run_in_background) and while waiting, read web/package.json`\n3. `Run python -c \"import time; time.sleep(3); print('done')\" in the background, then list all Markdown files`\n\n观察重点:显式设置 `run_in_background` 后,命令有没有被送到后台?`bg_id` 是否返回?后续轮次有没有以 `` 格式收集完成结果?\n\n---\n\n## 接下来\n\n后台任务解决了\"慢操作不阻塞\"。但如果想定时做某件事呢?比如\"每天早上 9 点跑测试\"、\"每 5 分钟检查一次服务器状态\"。\n\ns12 Cron Scheduler → 给 Agent 装一个闹钟。\n\n\n\n" }, { "version": "s11", "locale": "ja", "title": "s11: Background Tasks — 遅い操作はバックグラウンドへ", - "content": "# s11: Background Tasks — 遅い操作はバックグラウンドへ\n\ns01 → ... → s09 → s10 → `s11` → [s12](/ja/s12) → s13 → ... → s16 → s17\n\n> *\"遅い操作はバックグラウンドへ、Agent Loop は処理を継続\"* — バックグラウンドスレッドでコマンドを実行し、後続のターンで完了結果を収集する。\n>\n> **Harness 層**: バックグラウンド — 非同期実行、メインループをブロックしない。\n\n---\n\n## 課題\n\nファイルの読み込みや `git status` は通常すぐに返るため、同期実行でも待ち時間はほとんど気にならない。しかし、依存関係のインストール、全テストの実行、プロジェクトのビルドには数分かかることがある。コマンドが返るまで、Harness は現在のレスポンスに含まれる次のツール呼び出しを処理できず、次のターンにも進めない。\n\n後続の作業がそのコマンドに依存しないなら、終了まで待つ必要はない。例えば全テストを開始した後も、テストの実行中にドキュメントを確認したり、別のファイルを整理したりできる。\n\nS11 では、時間のかかる Bash コマンドをバックグラウンドで実行し、Agent Loop が他の作業を続けられるようにする。完了結果は後続のターンで収集する。\n\n---\n\n## ソリューション\n\n![Background Tasks Overview](/course-assets/s11_background_tasks/background-tasks-overview.ja.svg)\n\nこの章では、時間のかかる操作をバックグラウンドスレッドに送る。現在のツール呼び出しはまずプレースホルダー `tool_result` を返すため、Agent Loop は処理を続けられる。後続のターンの開始時に完了済みの結果を収集し、通知として会話に追加する。\n\n同期 vs バックグラウンド:\n\n| | 同期 (s04) | バックグラウンド (s11) |\n|---|---|---|\n| 遅い操作 | 現在のツール呼び出しがブロックされる | バックグラウンドスレッドで実行 |\n| Agent Loop | コマンドの返却を待つ | プレースホルダー結果を受け取って続行 |\n| 結果 | コマンド終了後に返す | 先に `bg_id` を返し、後続のターンで結果を収集 |\n| 判断基準 | — | bash の `run_in_background` パラメータ |\n\n---\n\n## 仕組み\n\n### should_run_background: 明示的リクエスト\n\nモデルは bash ツールの `run_in_background` パラメータでバックグラウンド実行をリクエストする。ツールが bash で、パラメータが明示的に `true` の場合だけ、この経路に入る。他の呼び出しは同期実行を続ける:\n\n```python\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n return (\n tool_name == \"bash\"\n and tool_input.get(\"run_in_background\") is True\n )\n```\n\n`install`、`build`、`test` などのキーワードから推測しない。実行方法はツール呼び出しが明示的に選ぶ。\n\n### BackgroundManager: バックグラウンド実行とライフサイクル\n\n`BackgroundManager` がタスク状態と完了キューを保持する。`start()` はタスクを登録して daemon スレッドを起動し、すぐに `bg_id` を返す:\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self.results = {}\n self._ready = []\n self._lock = threading.Lock()\n\n def start(self, block) -> str:\n # Register task, then run _run() in a daemon thread.\n ...\n\n def _run(self, task_id: str, command: str):\n output, exit_code = _run_bash_process(command)\n status = \"completed\" if exit_code == 0 else \"failed\"\n with self._lock:\n self.tasks[task_id][\"status\"] = status\n self.results[task_id] = _format_bash_result(output, exit_code)\n self._ready.append(task_id)\n```\n\ncommand が非ゼロで終了した場合や worker で例外が起きた場合は `failed` となる。Shell は独立した process group で起動し、command の完了、timeout、または Agent が通常経路や `SIGTERM` で終了する時に元の group を停止する。これは lifecycle cleanup であって sandbox ではなく、別の session を作った process は group から離れられる。\n\n### collect_background_results: 通知収集\n\n後続のターンの開始時に、`collect()` が完了キューから結果を取り出し、`` メッセージとしてフォーマットする:\n\n```python\ndef collect_background_results() -> list[str]:\n return BACKGROUND.collect()\n```\n\n通知は元の `tool_use_id` を再利用しない。元のツール呼び出しはプレースホルダー `tool_result` で応答済みであり、完了結果を収集した時点で `task_notification` 形式の独立したイベントとして会話に追加する。1 つの `tool_use` に対応する `tool_result` は 1 つのままである。\n\n### ループ統合\n\n各 LLM 呼び出しの前に、Agent Loop は完了済みのバックグラウンド結果を収集する。`execute_tool()` は引き続きメインスレッドで `PreToolUse` を実行し、その後で同期実行かバックグラウンド実行かを選ぶ:\n\n```python\nwhile True:\n inject_background_results(messages)\n response = client.messages.create(...)\n\ndef execute_tool(block) -> str:\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked is not None:\n return str(blocked)\n if should_run_background(block.name, block.input):\n task_id = start_background_task(block)\n output = f\"[Background task {task_id} started]\"\n else:\n output = call_tool(block)\n trigger_hooks(\"PostToolUse\", block, output)\n return output\n```\n\n遅い操作はまず `bg_id` 付きプレースホルダー tool_result を返す。バックグラウンドタスクの完了だけでは Agent は起動せず、次に Agent Loop が動く時に `inject_background_results()` が結果を収集する。\n\n### 組み合わせて実行\n\n```\nTurn 1:\n LLM → bash \"npm install\" (run_in_background=true)\n → start_background_task → bg_0001\n → tool_result: \"[Background task bg_0001 started]...\"\n → LLM: \"OK, I'll check later. Let me also read the config.\"\n\nTurn 2:\n LLM → read_file \"package.json\" (fast, sync)\n → tool_result: file content\n\nTurn 3:\n → collect bg_0001 as \n → LLM sees: config file + install notification in one message\n```\n\nnpm install がバックグラウンドで実行されている間、Agent Loop は read_file を続けて実行した。\n\n---\n\n## s11 で追加するもの\n\n| コンポーネント | S04 Kernel | S11 |\n|--------------|------------|------------|\n| 実行モデル | すべて同期 | 遅い操作はバックグラウンドスレッド + 通知注入 |\n| bash スキーマ | `command` | `command` + `run_in_background` |\n| 新規関数 | — | `should_run_background`, `start_background_task`, `collect_background_results`, `inject_background_results` |\n| 新規型 | — | `BackgroundManager` |\n| 通知形式 | — | ``(tool_use_id を再利用しない) |\n| ループ動作 | ツールを同期実行 | 明示的なバックグラウンド実行、後続のターンで完了結果を収集 |\n| ツール | 5 | 5(bash スキーマにパラメータを 1 つ追加) |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s11_background_tasks/code.py\n```\n\n以下のプロンプトを試してください:\n\n1. `Run pip list in the background and find all Python files in this directory`\n2. `Run npm install (use run_in_background) and while waiting, read package.json`\n3. `Run a short sleep in the background, then list all Markdown files`\n\n観察ポイント:`run_in_background` を明示的に設定すると、コマンドがバックグラウンドに送られるか?`bg_id` は返されるか?後続のターンで完了結果が `` 形式で収集されるか?\n\n---\n\n## 次の章\n\nバックグラウンドタスクは「遅い操作がブロックしない」を解決した。しかし、定期的に何かをしたい場合は?例えば「毎朝 9 時にテストを実行」「5 分ごとにサーバーステータスを確認」。\n\ns12 Cron Scheduler → Agent にアラームクロックを付ける。\n\n\n\n" + "content": "# s11: Background Tasks — 遅い操作はバックグラウンドへ\n\ns01 → ... → s09 → s10 → `s11` → [s12](/ja/s12) → s13 → ... → s16 → s17\n\n> *\"遅い操作はバックグラウンドへ、Agent Loop は処理を継続\"* — バックグラウンドスレッドでコマンドを実行し、後続のターンで完了結果を収集する。\n>\n> **Harness 層**: バックグラウンド — 非同期実行、メインループをブロックしない。\n\n---\n\n## 課題\n\nファイルの読み込みや `git status` は通常すぐに返るため、同期実行でも待ち時間はほとんど気にならない。しかし、依存関係のインストール、全テストの実行、プロジェクトのビルドには数分かかることがある。コマンドが返るまで、Harness は現在のレスポンスに含まれる次のツール呼び出しを処理できず、次のターンにも進めない。\n\n後続の作業がそのコマンドに依存しないなら、終了まで待つ必要はない。例えば全テストを開始した後も、テストの実行中にドキュメントを確認したり、別のファイルを整理したりできる。\n\nS11 では、時間のかかる Bash コマンドをバックグラウンドで実行し、Agent Loop が他の作業を続けられるようにする。完了結果は後続のターンで収集する。\n\n---\n\n## ソリューション\n\n![Background Tasks Overview](/course-assets/s11_background_tasks/background-tasks-overview.ja.svg)\n\nこの章では、時間のかかる操作をバックグラウンドスレッドに送る。現在のツール呼び出しはまずプレースホルダー `tool_result` を返すため、Agent Loop は処理を続けられる。後続のターンの開始時に完了済みの結果を収集し、通知として会話に追加する。\n\n同期 vs バックグラウンド:\n\n| | 同期 (s04) | バックグラウンド (s11) |\n|---|---|---|\n| 遅い操作 | 現在のツール呼び出しがブロックされる | バックグラウンドスレッドで実行 |\n| Agent Loop | コマンドの返却を待つ | プレースホルダー結果を受け取って続行 |\n| 結果 | コマンド終了後に返す | 先に `bg_id` を返し、後続のターンで結果を収集 |\n| 判断基準 | — | bash の `run_in_background` パラメータ |\n\n---\n\n## 仕組み\n\n### should_run_background: 明示的リクエスト\n\nモデルは bash ツールの `run_in_background` パラメータでバックグラウンド実行をリクエストする。ツールが bash で、パラメータが明示的に `true` の場合だけ、この経路に入る。他の呼び出しは同期実行を続ける:\n\n```python\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n return (\n tool_name == \"bash\"\n and tool_input.get(\"run_in_background\") is True\n )\n```\n\n`install`、`build`、`test` などのキーワードから推測しない。実行方法はツール呼び出しが明示的に選ぶ。\n\n### BackgroundManager: バックグラウンド実行とライフサイクル\n\n`BackgroundManager` がタスク状態と完了キューを保持する。`start()` はタスクを登録して daemon スレッドを起動し、すぐに `bg_id` を返す:\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self.results = {}\n self._ready = []\n self._lock = threading.Lock()\n\n def start(self, block) -> str:\n # Register task, then run _run() in a daemon thread.\n ...\n\n def _run(self, task_id: str, command: str):\n output, exit_code = _run_bash_process(command)\n status = \"completed\" if exit_code == 0 else \"failed\"\n with self._lock:\n self.tasks[task_id][\"status\"] = status\n self.results[task_id] = _format_bash_result(output, exit_code)\n self._ready.append(task_id)\n```\n\ncommand が非ゼロで終了した場合や worker で例外が起きた場合は `failed` となる。POSIX では Shell を独立した process group で起動し、command の完了、timeout、または Agent が通常経路や `SIGTERM` で終了する時に元の group を停止する。Windows には POSIX の process-group signal がないため、実行中の Shell process に `Popen.terminate()` と `Popen.kill()` を使う。これは lifecycle cleanup であって sandbox ではなく、管理対象の Shell から離れたり別の session を作ったりした process は残る場合がある。\n\n### collect_background_results: 通知収集\n\n後続のターンの開始時に、`collect()` が完了キューから結果を取り出し、`` メッセージとしてフォーマットする:\n\n```python\ndef collect_background_results() -> list[str]:\n return BACKGROUND.collect()\n```\n\n通知は元の `tool_use_id` を再利用しない。元のツール呼び出しはプレースホルダー `tool_result` で応答済みであり、完了結果を収集した時点で `task_notification` 形式の独立したイベントとして会話に追加する。1 つの `tool_use` に対応する `tool_result` は 1 つのままである。\n\n### ループ統合\n\n各 LLM 呼び出しの前に、Agent Loop は完了済みのバックグラウンド結果を収集する。`execute_tool()` は引き続きメインスレッドで `PreToolUse` を実行し、その後で同期実行かバックグラウンド実行かを選ぶ:\n\n```python\nwhile True:\n inject_background_results(messages)\n response = client.messages.create(...)\n\ndef execute_tool(block) -> str:\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked is not None:\n return str(blocked)\n if should_run_background(block.name, block.input):\n task_id = start_background_task(block)\n output = f\"[Background task {task_id} started]\"\n else:\n output = call_tool(block)\n trigger_hooks(\"PostToolUse\", block, output)\n return output\n```\n\n遅い操作はまず `bg_id` 付きプレースホルダー tool_result を返す。バックグラウンドタスクの完了だけでは Agent は起動せず、次に Agent Loop が動く時に `inject_background_results()` が結果を収集する。\n\n### 組み合わせて実行\n\n```\nTurn 1:\n LLM → bash \"npm install\" (run_in_background=true)\n → start_background_task → bg_0001\n → tool_result: \"[Background task bg_0001 started]...\"\n → LLM: \"OK, I'll check later. Let me also read the config.\"\n\nTurn 2:\n LLM → read_file \"package.json\" (fast, sync)\n → tool_result: file content\n\nTurn 3:\n → collect bg_0001 as \n → LLM sees: config file + install notification in one message\n```\n\nnpm install がバックグラウンドで実行されている間、Agent Loop は read_file を続けて実行した。\n\n---\n\n## s11 で追加するもの\n\n| コンポーネント | S04 Kernel | S11 |\n|--------------|------------|------------|\n| 実行モデル | すべて同期 | 遅い操作はバックグラウンドスレッド + 通知注入 |\n| bash スキーマ | `command` | `command` + `run_in_background` |\n| 新規関数 | — | `should_run_background`, `start_background_task`, `collect_background_results`, `inject_background_results` |\n| 新規型 | — | `BackgroundManager` |\n| 通知形式 | — | ``(tool_use_id を再利用しない) |\n| ループ動作 | ツールを同期実行 | 明示的なバックグラウンド実行、後続のターンで完了結果を収集 |\n| ツール | 5 | 5(bash スキーマにパラメータを 1 つ追加) |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s11_background_tasks/code.py\n```\n\n以下のプロンプトを試してください:\n\n1. `Run python -m pip list in the background and find all Python files in this directory`\n2. `Run npm --prefix web install (use run_in_background) and while waiting, read web/package.json`\n3. `Run python -c \"import time; time.sleep(3); print('done')\" in the background, then list all Markdown files`\n\n観察ポイント:`run_in_background` を明示的に設定すると、コマンドがバックグラウンドに送られるか?`bg_id` は返されるか?後続のターンで完了結果が `` 形式で収集されるか?\n\n---\n\n## 次の章\n\nバックグラウンドタスクは「遅い操作がブロックしない」を解決した。しかし、定期的に何かをしたい場合は?例えば「毎朝 9 時にテストを実行」「5 分ごとにサーバーステータスを確認」。\n\ns12 Cron Scheduler → Agent にアラームクロックを付ける。\n\n\n\n" }, { "version": "s12", diff --git a/web/src/data/generated/versions.json b/web/src/data/generated/versions.json index 09a5dcb05..93b7e6f7b 100644 --- a/web/src/data/generated/versions.json +++ b/web/src/data/generated/versions.json @@ -1177,7 +1177,7 @@ "filename": "s11_background_tasks/code.py", "title": "Background Tasks", "subtitle": "Slow Operations Go to the Background", - "loc": 412, + "loc": 436, "tools": [ "bash", "read_file", @@ -1191,139 +1191,139 @@ "classes": [ { "name": "BackgroundManager", - "startLine": 319, - "endLine": 397 + "startLine": 342, + "endLine": 424 } ], "functions": [ { "name": "_stop_process_group", "signature": "def _stop_process_group(process: subprocess.Popen)", - "startLine": 57 + "startLine": 63 }, { "name": "_stop_all_shell_processes", "signature": "def _stop_all_shell_processes()", - "startLine": 67 + "startLine": 90 }, { "name": "_handle_termination_signal", "signature": "def _handle_termination_signal(signum, _frame)", - "startLine": 74 + "startLine": 97 }, { "name": "_run_bash_process", "signature": "def _run_bash_process(command: str)", - "startLine": 83 + "startLine": 106 }, { "name": "_format_bash_result", "signature": "def _format_bash_result(output: str, exit_code: int | None)", - "startLine": 115 + "startLine": 138 }, { "name": "run_bash", "signature": "def run_bash(command: str, run_in_background: bool = False)", - "startLine": 121 + "startLine": 144 }, { "name": "run_read", "signature": "def run_read(path: str, limit: int | None = None)", - "startLine": 125 + "startLine": 148 }, { "name": "run_write", "signature": "def run_write(path: str, content: str)", - "startLine": 136 + "startLine": 159 }, { "name": "run_edit", "signature": "def run_edit(path: str, old_text: str, new_text: str)", - "startLine": 146 + "startLine": 169 }, { "name": "run_glob", "signature": "def run_glob(pattern: str)", - "startLine": 158 + "startLine": 181 }, { "name": "register_hook", "signature": "def register_hook(event: str, callback)", - "startLine": 216 + "startLine": 239 }, { "name": "trigger_hooks", "signature": "def trigger_hooks(event: str, *args)", - "startLine": 220 + "startLine": 243 }, { "name": "contains_destructive_command", "signature": "def contains_destructive_command(command: str)", - "startLine": 235 + "startLine": 258 }, { "name": "permission_hook", "signature": "def permission_hook(block)", - "startLine": 239 + "startLine": 262 }, { "name": "log_hook", "signature": "def log_hook(block)", - "startLine": 266 + "startLine": 289 }, { "name": "large_output_hook", "signature": "def large_output_hook(block, output)", - "startLine": 272 + "startLine": 295 }, { "name": "context_inject_hook", "signature": "def context_inject_hook(query: str)", - "startLine": 281 + "startLine": 304 }, { "name": "summary_hook", "signature": "def summary_hook(messages: list)", - "startLine": 286 + "startLine": 309 }, { "name": "call_tool", "signature": "def call_tool(block)", - "startLine": 308 + "startLine": 331 }, { "name": "should_run_background", "signature": "def should_run_background(tool_name: str, tool_input: dict)", - "startLine": 403 + "startLine": 430 }, { "name": "start_background_task", "signature": "def start_background_task(block)", - "startLine": 410 + "startLine": 437 }, { "name": "collect_background_results", "signature": "def collect_background_results()", - "startLine": 414 + "startLine": 441 }, { "name": "inject_background_results", "signature": "def inject_background_results(messages: list)", - "startLine": 418 + "startLine": 445 }, { "name": "execute_tool", "signature": "def execute_tool(block)", - "startLine": 438 + "startLine": 465 }, { "name": "agent_loop", "signature": "def agent_loop(messages: list)", - "startLine": 461 + "startLine": 488 } ], "layer": "concurrency", - "source": "#!/usr/bin/env python3\n\"\"\"\ns11_background_tasks.py - Background Tasks\n\n Main thread Background thread\n +------------------------------+ +----------------------+\n | bash(run_in_background=True) | ------> | run command |\n | return bg_id | | queue result |\n | continue agent loop | <------ +----------------------+\n | next turn: collect |\n +------------------------------+\n\"\"\"\n\nimport atexit\nimport glob\nimport os\nimport re\nimport signal\nimport subprocess\nimport threading\nimport time\nfrom pathlib import Path\n\ntry:\n import readline\n\n readline.parse_and_bind(\"set bind-tty-special-chars off\")\n readline.parse_and_bind(\"set input-meta on\")\n readline.parse_and_bind(\"set output-meta on\")\n readline.parse_and_bind(\"set convert-meta off\")\nexcept ImportError:\n pass\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\n\nSYSTEM = (\n f\"You are a coding agent at {WORKDIR}. Use tools to solve tasks. \"\n \"Set run_in_background to true only for independent Bash commands.\"\n)\n\n\n# -- From s04: tool implementations --\n\n_shell_processes: set[subprocess.Popen] = set()\n_shell_process_lock = threading.RLock()\n\n\ndef _stop_process_group(process: subprocess.Popen):\n \"\"\"Stop processes that remain in the command's original process group.\"\"\"\n for sig in (signal.SIGTERM, signal.SIGKILL):\n try:\n os.killpg(process.pid, sig)\n except (ProcessLookupError, OSError):\n return\n time.sleep(0.05)\n\n\ndef _stop_all_shell_processes():\n with _shell_process_lock:\n processes = list(_shell_processes)\n for process in processes:\n _stop_process_group(process)\n\n\ndef _handle_termination_signal(signum, _frame):\n _stop_all_shell_processes()\n raise SystemExit(128 + signum)\n\n\natexit.register(_stop_all_shell_processes)\nsignal.signal(signal.SIGTERM, _handle_termination_signal)\n\n\ndef _run_bash_process(command: str) -> tuple[str, int | None]:\n process = None\n try:\n process = subprocess.Popen(\n command,\n shell=True,\n cwd=WORKDIR,\n stdout=subprocess.PIPE,\n stderr=subprocess.PIPE,\n text=True, errors=\"replace\",\n start_new_session=True,\n )\n with _shell_process_lock:\n _shell_processes.add(process)\n stdout, stderr = process.communicate(timeout=120)\n output = (stdout + stderr).strip()\n return (output[:50000] if output else \"(no output)\"), process.returncode\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\", None\n except OSError as error:\n return f\"Error: {type(error).__name__}: {error}\", None\n finally:\n if process is not None:\n _stop_process_group(process)\n try:\n process.wait(timeout=0.2)\n except subprocess.TimeoutExpired:\n pass\n with _shell_process_lock:\n _shell_processes.discard(process)\n\n\ndef _format_bash_result(output: str, exit_code: int | None) -> str:\n if exit_code in (0, None):\n return output\n return f\"Error: command exited with status {exit_code}\\n{output}\"\n\n\ndef run_bash(command: str, run_in_background: bool = False) -> str:\n return _format_bash_result(*_run_bash_process(command))\n\n\ndef run_read(path: str, limit: int | None = None) -> str:\n try:\n file_path = (WORKDIR / path).resolve()\n lines = file_path.read_text(encoding=\"utf-8\").splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as error:\n return f\"Error: {error}\"\n\n\ndef run_write(path: str, content: str) -> str:\n try:\n file_path = (WORKDIR / path).resolve()\n file_path.parent.mkdir(parents=True, exist_ok=True)\n file_path.write_text(content, encoding=\"utf-8\")\n return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as error:\n return f\"Error: {error}\"\n\n\ndef run_edit(path: str, old_text: str, new_text: str) -> str:\n try:\n file_path = (WORKDIR / path).resolve()\n text = file_path.read_text(encoding=\"utf-8\")\n if old_text not in text:\n return f\"Error: text not found in {path}\"\n file_path.write_text(text.replace(old_text, new_text, 1), encoding=\"utf-8\")\n return f\"Edited {path}\"\n except Exception as error:\n return f\"Error: {error}\"\n\n\ndef run_glob(pattern: str) -> str:\n try:\n matches = sorted({\n match\n for match in glob.glob(pattern, root_dir=WORKDIR, recursive=True)\n if (WORKDIR / match).resolve().is_relative_to(WORKDIR)\n })\n shown = matches[:200]\n if len(matches) > 200:\n shown.append(\"... (more matches omitted; narrow the pattern)\")\n return \"\\n\".join(shown) if shown else \"(no matches)\"\n except Exception as error:\n return f\"Error: {error}\"\n\n\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\"},\n \"run_in_background\": {\"type\": \"boolean\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"limit\": {\"type\": \"integer\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in a file once.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"old_text\": {\"type\": \"string\"},\n \"new_text\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n {\"name\": \"glob\", \"description\": \"Find files matching a glob pattern; ** matches recursively.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"pattern\": {\"type\": \"string\"}},\n \"required\": [\"pattern\"]}},\n]\n\nTOOL_HANDLERS = {\n \"bash\": run_bash,\n \"read_file\": run_read,\n \"write_file\": run_write,\n \"edit_file\": run_edit,\n \"glob\": run_glob,\n}\n\n\n# -- From s04: hooks and permission checks --\n\nHOOKS = {\"UserPromptSubmit\": [], \"PreToolUse\": [], \"PostToolUse\": [], \"Stop\": []}\n\n\ndef register_hook(event: str, callback):\n HOOKS[event].append(callback)\n\n\ndef trigger_hooks(event: str, *args):\n for callback in HOOKS[event]:\n result = callback(*args)\n if result is not None:\n return result\n return None\n\n\nDENY_LIST = [\"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\", \"mkfs\", \"dd if=\"]\nDESTRUCTIVE_COMMAND_WORD = re.compile(\n r\"(?i)(?:^|[;&|()\\n])\\s*(?:rm|del)(?=\\s|$|[;&|()])\"\n)\nDESTRUCTIVE = [\"rm \", \"> /etc/\", \"chmod 777\"]\n\n\ndef contains_destructive_command(command: str) -> bool:\n return bool(DESTRUCTIVE_COMMAND_WORD.search(command))\n\n\ndef permission_hook(block):\n if block.name == \"bash\":\n command = block.input.get(\"command\", \"\")\n for pattern in DENY_LIST:\n if pattern in command:\n print(f\"\\n\\033[31m[blocked] '{pattern}'\\033[0m\")\n return \"Permission denied by deny list\"\n if contains_destructive_command(command) or any(\n keyword in command for keyword in DESTRUCTIVE\n ):\n print(\"\\n\\033[33m[permission] Potentially destructive command\\033[0m\")\n print(f\" Tool: {block.name}({block.input})\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n\n if block.name in (\"read_file\", \"write_file\", \"edit_file\"):\n path = block.input.get(\"path\", \"\")\n if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):\n print(\"\\n\\033[33m[permission] Access outside workspace\\033[0m\")\n print(f\" Tool: {block.name}({block.input})\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n return None\n\n\ndef log_hook(block):\n preview = str(list(block.input.values())[:2])[:60]\n print(f\"\\033[90m[HOOK] {block.name}({preview})\\033[0m\")\n return None\n\n\ndef large_output_hook(block, output):\n if len(str(output)) > 100000:\n print(\n f\"\\033[33m[HOOK] Large output from {block.name}: \"\n f\"{len(str(output))} chars\\033[0m\"\n )\n return None\n\n\ndef context_inject_hook(query: str):\n print(f\"\\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\\033[0m\")\n return None\n\n\ndef summary_hook(messages: list):\n tool_count = sum(\n 1\n for message in messages\n for block in (\n message.get(\"content\")\n if isinstance(message.get(\"content\"), list)\n else []\n )\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\"\n )\n print(f\"\\033[90m[HOOK] Stop: session used {tool_count} tool calls\\033[0m\")\n return None\n\n\nregister_hook(\"UserPromptSubmit\", context_inject_hook)\nregister_hook(\"PreToolUse\", permission_hook)\nregister_hook(\"PreToolUse\", log_hook)\nregister_hook(\"PostToolUse\", large_output_hook)\nregister_hook(\"Stop\", summary_hook)\n\n\ndef call_tool(block) -> str:\n handler = TOOL_HANDLERS.get(block.name)\n try:\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n except Exception as error:\n output = f\"Error: {error}\"\n return str(output)\n\n\n# -- New in s11: background execution --\n\nclass BackgroundManager:\n def __init__(self):\n self.tasks: dict[str, dict] = {}\n self.results: dict[str, str] = {}\n self._ready: list[str] = []\n self._counter = 0\n self._lock = threading.Lock()\n\n def start(self, block) -> str:\n if block.name != \"bash\":\n raise ValueError(\"Only Bash commands can run in the background\")\n command = block.input.get(\"command\")\n if not isinstance(command, str) or not command.strip():\n raise ValueError(\"Bash command cannot be empty\")\n\n with self._lock:\n self._counter += 1\n task_id = f\"bg_{self._counter:04d}\"\n self.tasks[task_id] = {\n \"tool_use_id\": block.id,\n \"command\": command,\n \"status\": \"running\",\n }\n\n thread = threading.Thread(\n target=self._run,\n args=(task_id, command),\n daemon=True,\n )\n try:\n thread.start()\n except Exception:\n with self._lock:\n self.tasks.pop(task_id, None)\n raise\n print(f\" [background] started {task_id}: {command[:60]}\")\n return task_id\n\n def _run(self, task_id: str, command: str):\n try:\n output, exit_code = _run_bash_process(command)\n result = _format_bash_result(output, exit_code)\n status = \"completed\" if exit_code == 0 else \"failed\"\n except Exception as error:\n result = f\"Error: {type(error).__name__}: {error}\"\n status = \"failed\"\n\n with self._lock:\n task = self.tasks.get(task_id)\n if task is None:\n return\n task[\"status\"] = status\n self.results[task_id] = result\n self._ready.append(task_id)\n\n def collect(self) -> list[str]:\n with self._lock:\n ready = []\n for task_id in self._ready:\n task = self.tasks.pop(task_id, None)\n result = self.results.pop(task_id, \"\")\n if task is not None:\n ready.append((task_id, task, result))\n self._ready.clear()\n\n notifications = []\n for task_id, task, result in ready:\n notifications.append(\n f\"\\n\"\n f\" {task_id}\\n\"\n f\" {task['status']}\\n\"\n f\" {task['command']}\\n\"\n f\" {result[:500]}\\n\"\n f\"\"\n )\n print(f\" [background] collected {task_id}: {task['status']}\")\n return notifications\n\n\nBACKGROUND = BackgroundManager()\nbackground_tasks = BACKGROUND.tasks\nbackground_results = BACKGROUND.results\n\n\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n return (\n tool_name == \"bash\"\n and tool_input.get(\"run_in_background\") is True\n )\n\n\ndef start_background_task(block) -> str:\n return BACKGROUND.start(block)\n\n\ndef collect_background_results() -> list[str]:\n return BACKGROUND.collect()\n\n\ndef inject_background_results(messages: list) -> int:\n notifications = collect_background_results()\n if not notifications:\n return 0\n\n blocks = [{\"type\": \"text\", \"text\": item} for item in notifications]\n if messages and messages[-1].get(\"role\") == \"user\":\n content = messages[-1].get(\"content\", \"\")\n if isinstance(content, list):\n content.extend(blocks)\n else:\n messages[-1][\"content\"] = [\n {\"type\": \"text\", \"text\": str(content)},\n *blocks,\n ]\n else:\n messages.append({\"role\": \"user\", \"content\": blocks})\n return len(notifications)\n\n\ndef execute_tool(block) -> str:\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked is not None:\n return str(blocked)\n\n if should_run_background(block.name, block.input):\n try:\n task_id = start_background_task(block)\n output = (\n f\"[Background task {task_id} started] \"\n \"The result will be collected on a later turn.\"\n )\n except Exception as error:\n output = f\"Error: {error}\"\n else:\n output = call_tool(block)\n\n trigger_hooks(\"PostToolUse\", block, output)\n return output\n\n\n# -- Agent loop --\n\ndef agent_loop(messages: list):\n while True:\n inject_background_results(messages)\n response = client.messages.create(\n model=MODEL,\n system=SYSTEM,\n messages=messages,\n tools=TOOLS,\n max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n tool_calls = [\n block for block in response.content if block.type == \"tool_use\"\n ]\n if not tool_calls:\n force = trigger_hooks(\"Stop\", messages)\n if force:\n messages.append({\"role\": \"user\", \"content\": force})\n continue\n return\n\n results = []\n for block in tool_calls:\n output = execute_tool(block)\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n messages.append({\"role\": \"user\", \"content\": results})\n\n\nif __name__ == \"__main__\":\n print(\"s11: Background Tasks - explicit background Bash execution\")\n print(\"Enter a question, press Enter to send. Type q to quit.\\n\")\n\n history = []\n while True:\n try:\n # \\001/\\002 tell Readline the ANSI escapes have zero display width.\n query = input(\"\\001\\033[36m\\002s11 >> \\001\\033[0m\\002\")\n except (EOFError, KeyboardInterrupt):\n break\n if query.strip().lower() in (\"q\", \"exit\", \"\"):\n break\n trigger_hooks(\"UserPromptSubmit\", query)\n history.append({\"role\": \"user\", \"content\": query})\n agent_loop(history)\n for block in history[-1][\"content\"]:\n if getattr(block, \"type\", None) == \"text\":\n print(block.text)\n print()\n", + "source": "#!/usr/bin/env python3\n\"\"\"\ns11_background_tasks.py - Background Tasks\n\n Main thread Background thread\n +------------------------------+ +----------------------+\n | bash(run_in_background=True) | ------> | run command |\n | return bg_id | | queue result |\n | continue agent loop | <------ +----------------------+\n | next turn: collect |\n +------------------------------+\n\"\"\"\n\nimport atexit\nimport glob\nimport os\nimport re\nimport signal\nimport subprocess\nimport threading\nimport time\nfrom pathlib import Path\n\ntry:\n import readline\n\n readline.parse_and_bind(\"set bind-tty-special-chars off\")\n readline.parse_and_bind(\"set input-meta on\")\n readline.parse_and_bind(\"set output-meta on\")\n readline.parse_and_bind(\"set convert-meta off\")\nexcept ImportError:\n pass\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\n\nSYSTEM = (\n f\"You are a coding agent at {WORKDIR}. Use tools to solve tasks. \"\n \"Set run_in_background to true only for independent shell commands.\"\n + (\n \" On Windows, the bash tool uses cmd.exe; use cmd-compatible or \"\n \"cross-platform commands.\"\n if os.name == \"nt\"\n else \"\"\n )\n)\n\n\n# -- From s04: tool implementations --\n\n_shell_processes: set[subprocess.Popen] = set()\n_shell_process_lock = threading.RLock()\n\n\ndef _stop_process_group(process: subprocess.Popen):\n \"\"\"Stop processes that remain in the command's original process group.\"\"\"\n if os.name == \"nt\":\n # Windows has no os.killpg or SIGKILL. Use Popen's native methods\n # without changing the background-task protocol demonstrated here.\n if process.poll() is not None:\n return\n try:\n process.terminate()\n process.wait(timeout=0.2)\n except subprocess.TimeoutExpired:\n try:\n process.kill()\n except OSError:\n pass\n except OSError:\n pass\n return\n\n for sig in (signal.SIGTERM, signal.SIGKILL):\n try:\n os.killpg(process.pid, sig)\n except (ProcessLookupError, OSError):\n return\n time.sleep(0.05)\n\n\ndef _stop_all_shell_processes():\n with _shell_process_lock:\n processes = list(_shell_processes)\n for process in processes:\n _stop_process_group(process)\n\n\ndef _handle_termination_signal(signum, _frame):\n _stop_all_shell_processes()\n raise SystemExit(128 + signum)\n\n\natexit.register(_stop_all_shell_processes)\nsignal.signal(signal.SIGTERM, _handle_termination_signal)\n\n\ndef _run_bash_process(command: str) -> tuple[str, int | None]:\n process = None\n try:\n process = subprocess.Popen(\n command,\n shell=True,\n cwd=WORKDIR,\n stdout=subprocess.PIPE,\n stderr=subprocess.PIPE,\n text=True, errors=\"replace\",\n start_new_session=True,\n )\n with _shell_process_lock:\n _shell_processes.add(process)\n stdout, stderr = process.communicate(timeout=120)\n output = (stdout + stderr).strip()\n return (output[:50000] if output else \"(no output)\"), process.returncode\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\", None\n except OSError as error:\n return f\"Error: {type(error).__name__}: {error}\", None\n finally:\n if process is not None:\n _stop_process_group(process)\n try:\n process.wait(timeout=0.2)\n except subprocess.TimeoutExpired:\n pass\n with _shell_process_lock:\n _shell_processes.discard(process)\n\n\ndef _format_bash_result(output: str, exit_code: int | None) -> str:\n if exit_code in (0, None):\n return output\n return f\"Error: command exited with status {exit_code}\\n{output}\"\n\n\ndef run_bash(command: str, run_in_background: bool = False) -> str:\n return _format_bash_result(*_run_bash_process(command))\n\n\ndef run_read(path: str, limit: int | None = None) -> str:\n try:\n file_path = (WORKDIR / path).resolve()\n lines = file_path.read_text(encoding=\"utf-8\").splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as error:\n return f\"Error: {error}\"\n\n\ndef run_write(path: str, content: str) -> str:\n try:\n file_path = (WORKDIR / path).resolve()\n file_path.parent.mkdir(parents=True, exist_ok=True)\n file_path.write_text(content, encoding=\"utf-8\")\n return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as error:\n return f\"Error: {error}\"\n\n\ndef run_edit(path: str, old_text: str, new_text: str) -> str:\n try:\n file_path = (WORKDIR / path).resolve()\n text = file_path.read_text(encoding=\"utf-8\")\n if old_text not in text:\n return f\"Error: text not found in {path}\"\n file_path.write_text(text.replace(old_text, new_text, 1), encoding=\"utf-8\")\n return f\"Edited {path}\"\n except Exception as error:\n return f\"Error: {error}\"\n\n\ndef run_glob(pattern: str) -> str:\n try:\n matches = sorted({\n match\n for match in glob.glob(pattern, root_dir=WORKDIR, recursive=True)\n if (WORKDIR / match).resolve().is_relative_to(WORKDIR)\n })\n shown = matches[:200]\n if len(matches) > 200:\n shown.append(\"... (more matches omitted; narrow the pattern)\")\n return \"\\n\".join(shown) if shown else \"(no matches)\"\n except Exception as error:\n return f\"Error: {error}\"\n\n\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\"},\n \"run_in_background\": {\"type\": \"boolean\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"limit\": {\"type\": \"integer\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in a file once.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"old_text\": {\"type\": \"string\"},\n \"new_text\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n {\"name\": \"glob\", \"description\": \"Find files matching a glob pattern; ** matches recursively.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"pattern\": {\"type\": \"string\"}},\n \"required\": [\"pattern\"]}},\n]\n\nTOOL_HANDLERS = {\n \"bash\": run_bash,\n \"read_file\": run_read,\n \"write_file\": run_write,\n \"edit_file\": run_edit,\n \"glob\": run_glob,\n}\n\n\n# -- From s04: hooks and permission checks --\n\nHOOKS = {\"UserPromptSubmit\": [], \"PreToolUse\": [], \"PostToolUse\": [], \"Stop\": []}\n\n\ndef register_hook(event: str, callback):\n HOOKS[event].append(callback)\n\n\ndef trigger_hooks(event: str, *args):\n for callback in HOOKS[event]:\n result = callback(*args)\n if result is not None:\n return result\n return None\n\n\nDENY_LIST = [\"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\", \"mkfs\", \"dd if=\"]\nDESTRUCTIVE_COMMAND_WORD = re.compile(\n r\"(?i)(?:^|[;&|()\\n])\\s*(?:rm|del)(?=\\s|$|[;&|()])\"\n)\nDESTRUCTIVE = [\"rm \", \"> /etc/\", \"chmod 777\"]\n\n\ndef contains_destructive_command(command: str) -> bool:\n return bool(DESTRUCTIVE_COMMAND_WORD.search(command))\n\n\ndef permission_hook(block):\n if block.name == \"bash\":\n command = block.input.get(\"command\", \"\")\n for pattern in DENY_LIST:\n if pattern in command:\n print(f\"\\n\\033[31m[blocked] '{pattern}'\\033[0m\")\n return \"Permission denied by deny list\"\n if contains_destructive_command(command) or any(\n keyword in command for keyword in DESTRUCTIVE\n ):\n print(\"\\n\\033[33m[permission] Potentially destructive command\\033[0m\")\n print(f\" Tool: {block.name}({block.input})\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n\n if block.name in (\"read_file\", \"write_file\", \"edit_file\"):\n path = block.input.get(\"path\", \"\")\n if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):\n print(\"\\n\\033[33m[permission] Access outside workspace\\033[0m\")\n print(f\" Tool: {block.name}({block.input})\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n return None\n\n\ndef log_hook(block):\n preview = str(list(block.input.values())[:2])[:60]\n print(f\"\\033[90m[HOOK] {block.name}({preview})\\033[0m\")\n return None\n\n\ndef large_output_hook(block, output):\n if len(str(output)) > 100000:\n print(\n f\"\\033[33m[HOOK] Large output from {block.name}: \"\n f\"{len(str(output))} chars\\033[0m\"\n )\n return None\n\n\ndef context_inject_hook(query: str):\n print(f\"\\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\\033[0m\")\n return None\n\n\ndef summary_hook(messages: list):\n tool_count = sum(\n 1\n for message in messages\n for block in (\n message.get(\"content\")\n if isinstance(message.get(\"content\"), list)\n else []\n )\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\"\n )\n print(f\"\\033[90m[HOOK] Stop: session used {tool_count} tool calls\\033[0m\")\n return None\n\n\nregister_hook(\"UserPromptSubmit\", context_inject_hook)\nregister_hook(\"PreToolUse\", permission_hook)\nregister_hook(\"PreToolUse\", log_hook)\nregister_hook(\"PostToolUse\", large_output_hook)\nregister_hook(\"Stop\", summary_hook)\n\n\ndef call_tool(block) -> str:\n handler = TOOL_HANDLERS.get(block.name)\n try:\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n except Exception as error:\n output = f\"Error: {error}\"\n return str(output)\n\n\n# -- New in s11: background execution --\n\nclass BackgroundManager:\n def __init__(self):\n self.tasks: dict[str, dict] = {}\n self.results: dict[str, str] = {}\n self._ready: list[str] = []\n self._counter = 0\n self._lock = threading.Lock()\n\n def start(self, block) -> str:\n if block.name != \"bash\":\n raise ValueError(\"Only Bash commands can run in the background\")\n command = block.input.get(\"command\")\n if not isinstance(command, str) or not command.strip():\n raise ValueError(\"Bash command cannot be empty\")\n\n with self._lock:\n self._counter += 1\n task_id = f\"bg_{self._counter:04d}\"\n self.tasks[task_id] = {\n \"tool_use_id\": block.id,\n \"command\": command,\n \"status\": \"running\",\n }\n\n thread = threading.Thread(\n target=self._run,\n args=(task_id, command),\n daemon=True,\n )\n try:\n thread.start()\n except Exception:\n with self._lock:\n self.tasks.pop(task_id, None)\n raise\n print(f\" [background] started {task_id}: {command[:60]}\")\n return task_id\n\n def _run(self, task_id: str, command: str):\n try:\n output, exit_code = _run_bash_process(command)\n result = _format_bash_result(output, exit_code)\n status = \"completed\" if exit_code == 0 else \"failed\"\n except Exception as error:\n result = f\"Error: {type(error).__name__}: {error}\"\n status = \"failed\"\n\n with self._lock:\n task = self.tasks.get(task_id)\n if task is None:\n return\n task[\"status\"] = status\n self.results[task_id] = result\n self._ready.append(task_id)\n\n def collect(self) -> list[str]:\n with self._lock:\n ready = []\n for task_id in self._ready:\n task = self.tasks.pop(task_id, None)\n result = self.results.pop(task_id, \"\")\n if task is not None:\n ready.append((task_id, task, result))\n self._ready.clear()\n\n notifications = []\n for task_id, task, result in ready:\n notification = (\n f\"\\n\"\n f\" {task_id}\\n\"\n f\" {task['status']}\\n\"\n f\" {task['command']}\\n\"\n f\" {result[:500]}\\n\"\n f\"\"\n )\n notifications.append(notification)\n print(\n f\" [background] collected {task_id} \"\n f\"as : {task['status']}\"\n )\n return notifications\n\n\nBACKGROUND = BackgroundManager()\nbackground_tasks = BACKGROUND.tasks\nbackground_results = BACKGROUND.results\n\n\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n return (\n tool_name == \"bash\"\n and tool_input.get(\"run_in_background\") is True\n )\n\n\ndef start_background_task(block) -> str:\n return BACKGROUND.start(block)\n\n\ndef collect_background_results() -> list[str]:\n return BACKGROUND.collect()\n\n\ndef inject_background_results(messages: list) -> int:\n notifications = collect_background_results()\n if not notifications:\n return 0\n\n blocks = [{\"type\": \"text\", \"text\": item} for item in notifications]\n if messages and messages[-1].get(\"role\") == \"user\":\n content = messages[-1].get(\"content\", \"\")\n if isinstance(content, list):\n content.extend(blocks)\n else:\n messages[-1][\"content\"] = [\n {\"type\": \"text\", \"text\": str(content)},\n *blocks,\n ]\n else:\n messages.append({\"role\": \"user\", \"content\": blocks})\n return len(notifications)\n\n\ndef execute_tool(block) -> str:\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked is not None:\n return str(blocked)\n\n if should_run_background(block.name, block.input):\n try:\n task_id = start_background_task(block)\n output = (\n f\"[Background task {task_id} started] \"\n \"The result will be collected on a later turn.\"\n )\n except Exception as error:\n output = f\"Error: {error}\"\n else:\n output = call_tool(block)\n\n trigger_hooks(\"PostToolUse\", block, output)\n return output\n\n\n# -- Agent loop --\n\ndef agent_loop(messages: list):\n while True:\n inject_background_results(messages)\n response = client.messages.create(\n model=MODEL,\n system=SYSTEM,\n messages=messages,\n tools=TOOLS,\n max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n tool_calls = [\n block for block in response.content if block.type == \"tool_use\"\n ]\n if not tool_calls:\n force = trigger_hooks(\"Stop\", messages)\n if force:\n messages.append({\"role\": \"user\", \"content\": force})\n continue\n return\n\n results = []\n for block in tool_calls:\n output = execute_tool(block)\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n messages.append({\"role\": \"user\", \"content\": results})\n\n\nif __name__ == \"__main__\":\n print(\"s11: Background Tasks - explicit background Bash execution\")\n print(\"Enter a question, press Enter to send. Type q to quit.\\n\")\n\n history = []\n while True:\n try:\n # \\001/\\002 tell Readline the ANSI escapes have zero display width.\n query = input(\"\\001\\033[36m\\002s11 >> \\001\\033[0m\\002\")\n except (EOFError, KeyboardInterrupt):\n break\n if query.strip().lower() in (\"q\", \"exit\", \"\"):\n break\n trigger_hooks(\"UserPromptSubmit\", query)\n history.append({\"role\": \"user\", \"content\": query})\n agent_loop(history)\n for block in history[-1][\"content\"]:\n if getattr(block, \"type\", None) == \"text\":\n print(block.text)\n print()\n", "images": [ { "src": "/course-assets/s11_background_tasks/background-tasks-overview.svg", @@ -3461,7 +3461,7 @@ "inject_background_results" ], "newTools": [], - "locDelta": -61 + "locDelta": -37 }, { "from": "s11", @@ -3495,7 +3495,7 @@ "stop_runtime_threads" ], "newTools": [], - "locDelta": 238 + "locDelta": 214 }, { "from": "s12",