Refine course progression and runtime safety

This commit is contained in:
Haoran
2026-08-11 15:13:13 +08:00
parent b36dbcd84f
commit ab35e59672
83 changed files with 5291 additions and 2267 deletions

View File

@@ -33,7 +33,7 @@ Agent の bash ツールも同じ。`pip install torch` は 10 分、`npm run bu
| 遅い操作 | Agent が待機 | バックグラウンドスレッドで実行 |
| Agent アイドル | はい | いいえ、処理を継続 |
| 結果 | 即時返却 | 次ターンで通知を注入 |
| 判断基準 | — | `run_in_background` パラメータ(モデル明示的リクエスト)、ヒューリスティックフォールバック |
| 判断基準 | — | bash の `run_in_background` パラメータ、ヒューリスティックフォールバック |
---
@@ -41,7 +41,7 @@ Agent の bash ツールも同じ。`pip install torch` は 10 分、`npm run bu
### should_run_background: 明示的リクエスト優先、ヒューリスティックフォールバック
モデルは bash ツールの `run_in_background` パラメータで明示的にバックグラウンド実行をリクエストする。指定がない場合は、キーワードヒューリスティックで判断する:
モデルは bash ツールの `run_in_background` パラメータで明示的にバックグラウンド実行をリクエストする。指定がない場合は、キーワードヒューリスティックで判断する。この経路に入るのは bash だけであり、他のツールは従来どおり引数を検証して実行する
```python
def is_slow_operation(tool_name: str, tool_input: dict) -> bool:
@@ -56,7 +56,9 @@ def is_slow_operation(tool_name: str, tool_input: dict) -> bool:
def should_run_background(tool_name: str, tool_input: dict) -> bool:
"""Model explicit request takes priority; fallback to heuristic."""
if tool_input.get("run_in_background"):
if tool_name != "bash":
return False
if tool_input.get("run_in_background") is True:
return True
return is_slow_operation(tool_name, tool_input)
```
@@ -78,9 +80,14 @@ def start_background_task(block) -> str:
bg_id = f"bg_{_bg_counter:04d}"
def worker():
result = execute_tool(block)
try:
output, exit_code = _run_bash_process(block.input["command"])
status = "completed" if exit_code == 0 else "failed"
result = _format_bash_result(output, exit_code)
except Exception as exc:
status, result = "failed", f"Error: {exc}"
with background_lock:
background_tasks[bg_id]["status"] = "completed"
background_tasks[bg_id]["status"] = status
background_results[bg_id] = result
with background_lock:
@@ -94,7 +101,7 @@ def start_background_task(block) -> str:
return bg_id
```
`start_background_task()``bg_id` を返す。`daemon=True` により、Agent プロセスの終了時にスレッドも終了する。
`start_background_task()``bg_id` を返す。command が非ゼロで終了した場合や worker で例外が起きた場合は `failed` となり、成功として扱わない。Shell は独立した process group で起動し、command の完了、timeout、または Agent が通常経路や `SIGTERM` で終了する時に元の group を停止する。これは lifecycle cleanup であって sandbox ではなく、別の session を作った process は group から離れられる。
### collect_background_results: 通知収集
@@ -102,10 +109,10 @@ def start_background_task(block) -> str:
```python
def collect_background_results() -> list[str]:
"""Collect completed results as task_notification messages."""
"""Collect terminal results as task_notification messages."""
with background_lock:
ready_ids = [bid for bid, task in background_tasks.items()
if task["status"] == "completed"]
if task["status"] in ("completed", "failed")]
notifications = []
for bg_id in ready_ids:
with background_lock:
@@ -114,7 +121,7 @@ def collect_background_results() -> list[str]:
notifications.append(
f"<task_notification>\n"
f" <task_id>{bg_id}</task_id>\n"
f" <status>completed</status>\n"
f" <status>{task['status']}</status>\n"
f" <command>{task['command']}</command>\n"
f" <summary>{output[:200]}</summary>\n"
f"</task_notification>")
@@ -213,4 +220,4 @@ python s13_background_tasks/code.py
s14 Cron Scheduler → Agent にアラームクロックを付ける。
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
<!-- translation-sync: zh@v4, en@v4, ja@v4 -->

View File

@@ -33,7 +33,7 @@ Sync vs Background:
| Slow operations | Agent waits | Background thread executes |
| Agent idle | Yes | No, continues processing |
| Result | Immediate return | Notification injected next turn |
| Decision criteria | — | `run_in_background` param (model explicit request), heuristic fallback |
| Decision criteria | — | bash `run_in_background` param, heuristic fallback |
---
@@ -41,7 +41,7 @@ Sync vs Background:
### should_run_background: Explicit Request First, Heuristic Fallback
The model explicitly requests background execution via the bash tool's `run_in_background` parameter. If the model does not specify it, keyword heuristics decide:
The model explicitly requests background execution via the bash tool's `run_in_background` parameter. If the model does not specify it, keyword heuristics decide. Only bash enters this path; other tools still run through their normal argument validation.
```python
def is_slow_operation(tool_name: str, tool_input: dict) -> bool:
@@ -56,7 +56,9 @@ def is_slow_operation(tool_name: str, tool_input: dict) -> bool:
def should_run_background(tool_name: str, tool_input: dict) -> bool:
"""Model explicit request takes priority; fallback to heuristic."""
if tool_input.get("run_in_background"):
if tool_name != "bash":
return False
if tool_input.get("run_in_background") is True:
return True
return is_slow_operation(tool_name, tool_input)
```
@@ -78,9 +80,14 @@ def start_background_task(block) -> str:
bg_id = f"bg_{_bg_counter:04d}"
def worker():
result = execute_tool(block)
try:
output, exit_code = _run_bash_process(block.input["command"])
status = "completed" if exit_code == 0 else "failed"
result = _format_bash_result(output, exit_code)
except Exception as exc:
status, result = "failed", f"Error: {exc}"
with background_lock:
background_tasks[bg_id]["status"] = "completed"
background_tasks[bg_id]["status"] = status
background_results[bg_id] = result
with background_lock:
@@ -94,7 +101,7 @@ def start_background_task(block) -> str:
return bg_id
```
`start_background_task()` returns `bg_id`. `daemon=True` ensures the thread exits with the agent process.
`start_background_task()` returns `bg_id`. A non-zero exit code or worker exception becomes `failed`, instead of being reported as a successful completion. 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.
### collect_background_results: Notification Collection
@@ -102,10 +109,10 @@ When background tasks complete, results are collected and formatted as `<task_no
```python
def collect_background_results() -> list[str]:
"""Collect completed results as task_notification messages."""
"""Collect terminal results as task_notification messages."""
with background_lock:
ready_ids = [bid for bid, task in background_tasks.items()
if task["status"] == "completed"]
if task["status"] in ("completed", "failed")]
notifications = []
for bg_id in ready_ids:
with background_lock:
@@ -114,7 +121,7 @@ def collect_background_results() -> list[str]:
notifications.append(
f"<task_notification>\n"
f" <task_id>{bg_id}</task_id>\n"
f" <status>completed</status>\n"
f" <status>{task['status']}</status>\n"
f" <command>{task['command']}</command>\n"
f" <summary>{output[:200]}</summary>\n"
f"</task_notification>")
@@ -213,4 +220,4 @@ Background tasks solved "slow operations don't block." But what if you want to d
s14 Cron Scheduler → Give the agent an alarm clock.
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
<!-- translation-sync: zh@v4, en@v4, ja@v4 -->

View File

@@ -33,7 +33,7 @@ Agent 的 bash 工具也一样。`pip install torch` 要 10 分钟,`npm run bu
| 慢操作 | Agent 干等 | 后台线程执行 |
| Agent 空闲 | 是 | 否,继续处理 |
| 结果 | 立即返回 | 下轮注入通知 |
| 判断标准 | — | `run_in_background` 参数(模型显式请求),启发式兜底 |
| 判断标准 | — | bash 的 `run_in_background` 参数,启发式兜底 |
---
@@ -41,7 +41,7 @@ Agent 的 bash 工具也一样。`pip install torch` 要 10 分钟,`npm run bu
### should_run_background: 显式请求优先,启发式兜底
模型通过 bash 工具的 `run_in_background` 参数显式请求后台执行。如果模型没有指定,则使用关键词启发式判断
模型通过 bash 工具的 `run_in_background` 参数显式请求后台执行。如果模型没有指定,则使用关键词启发式判断。只有 bash 会进入这条路径,其他工具仍按原来的参数规则校验和执行。
```python
def is_slow_operation(tool_name: str, tool_input: dict) -> bool:
@@ -56,7 +56,9 @@ def is_slow_operation(tool_name: str, tool_input: dict) -> bool:
def should_run_background(tool_name: str, tool_input: dict) -> bool:
"""Model explicit request takes priority; fallback to heuristic."""
if tool_input.get("run_in_background"):
if tool_name != "bash":
return False
if tool_input.get("run_in_background") is True:
return True
return is_slow_operation(tool_name, tool_input)
```
@@ -78,9 +80,14 @@ def start_background_task(block) -> str:
bg_id = f"bg_{_bg_counter:04d}"
def worker():
result = execute_tool(block)
try:
output, exit_code = _run_bash_process(block.input["command"])
status = "completed" if exit_code == 0 else "failed"
result = _format_bash_result(output, exit_code)
except Exception as exc:
status, result = "failed", f"Error: {exc}"
with background_lock:
background_tasks[bg_id]["status"] = "completed"
background_tasks[bg_id]["status"] = status
background_results[bg_id] = result
with background_lock:
@@ -94,7 +101,7 @@ def start_background_task(block) -> str:
return bg_id
```
`start_background_task()` 返回 `bg_id``daemon=True` 确保 Agent 进程退出时线程一起退出
`start_background_task()` 返回 `bg_id`命令以非零状态退出或 worker 抛出异常时,任务会进入 `failed`不会再被写成成功完成。Shell 会在独立的进程组中启动;命令完成、超时,或 Agent 经正常路径、`SIGTERM` 退出时,运行时会停止原进程组。这只是生命周期清理,并不是沙箱;另建 session 的进程仍可能离开该进程组
### collect_background_results: 通知收集
@@ -102,10 +109,10 @@ def start_background_task(block) -> str:
```python
def collect_background_results() -> list[str]:
"""Collect completed results as task_notification messages."""
"""Collect terminal results as task_notification messages."""
with background_lock:
ready_ids = [bid for bid, task in background_tasks.items()
if task["status"] == "completed"]
if task["status"] in ("completed", "failed")]
notifications = []
for bg_id in ready_ids:
with background_lock:
@@ -114,7 +121,7 @@ def collect_background_results() -> list[str]:
notifications.append(
f"<task_notification>\n"
f" <task_id>{bg_id}</task_id>\n"
f" <status>completed</status>\n"
f" <status>{task['status']}</status>\n"
f" <command>{task['command']}</command>\n"
f" <summary>{output[:200]}</summary>\n"
f"</task_notification>")
@@ -213,4 +220,4 @@ python s13_background_tasks/code.py
s14 Cron Scheduler → 给 Agent 装一个闹钟。
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
<!-- translation-sync: zh@v4, en@v4, ja@v4 -->

View File

@@ -20,7 +20,7 @@ This chapter keeps the agent loop focused on background tasks. Error recovery
remains the independent layer introduced in s11.
"""
import os, subprocess, json, time, random, threading
import atexit, os, signal, subprocess, json, time, random, threading
from pathlib import Path
from dataclasses import dataclass, asdict
@@ -180,15 +180,77 @@ def safe_path(p: str) -> Path:
return path
_shell_processes: set[subprocess.Popen] = set()
_shell_process_lock = threading.RLock()
def _stop_process_group(process: subprocess.Popen):
"""Stop processes that remain in the command's original process group."""
for sig in (signal.SIGTERM, signal.SIGKILL):
try:
os.killpg(process.pid, sig)
except ProcessLookupError:
return
except OSError:
return
time.sleep(0.05)
def _stop_all_shell_processes():
with _shell_process_lock:
processes = list(_shell_processes)
for process in processes:
_stop_process_group(process)
def _handle_termination_signal(signum, _frame):
_stop_all_shell_processes()
raise SystemExit(128 + signum)
atexit.register(_stop_all_shell_processes)
signal.signal(signal.SIGTERM, _handle_termination_signal)
def _run_bash_process(command: str, cwd: Path | None = None) -> tuple[str, int | None]:
process = None
try:
process = subprocess.Popen(
command, shell=True, cwd=cwd or WORKDIR,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, start_new_session=True,
)
with _shell_process_lock:
_shell_processes.add(process)
stdout, stderr = process.communicate(timeout=120)
out = (stdout + stderr).strip()
return (out[:50000] if out else "(no output)"), process.returncode
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)", None
except OSError as exc:
return f"Error: {type(exc).__name__}: {exc}", None
finally:
if process is not None:
_stop_process_group(process)
try:
process.wait(timeout=0.2)
except subprocess.TimeoutExpired:
pass
with _shell_process_lock:
_shell_processes.discard(process)
def _format_bash_result(output: str, exit_code: int | None) -> str:
if exit_code == 0:
return output
if exit_code is None:
return output
return f"Error: command exited with status {exit_code}\n{output}"
def run_bash(command: str, run_in_background: bool = False) -> str:
# run_in_background is handled by agent_loop dispatch, not here
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
return _format_bash_result(*_run_bash_process(command))
def run_read(path: str, limit: int | None = None) -> str:
@@ -327,30 +389,42 @@ def is_slow_operation(tool_name: str, tool_input: dict) -> bool:
def should_run_background(tool_name: str, tool_input: dict) -> bool:
"""Model explicit request takes priority; fallback to heuristic."""
if tool_input.get("run_in_background"):
return True
return is_slow_operation(tool_name, tool_input)
return tool_name == "bash" and (
tool_input.get("run_in_background") is True
or is_slow_operation(tool_name, tool_input)
)
def execute_tool(block) -> str:
"""Execute a tool call block, return output."""
handler = TOOL_HANDLERS.get(block.name)
if handler:
return handler(**block.input)
return f"Unknown tool: {block.name}"
if not handler:
return f"Unknown tool: {block.name}"
try:
return str(handler(**block.input))
except (TypeError, ValueError) as exc:
return f"Error: {exc}"
def start_background_task(block) -> str:
"""Run tool in a daemon thread. Returns background task ID."""
"""Run one bash call in a daemon thread. Returns background task ID."""
global _bg_counter
_bg_counter += 1
bg_id = f"bg_{_bg_counter:04d}"
cmd = block.input.get("command", block.name)
def worker():
result = execute_tool(block)
try:
if block.name != "bash":
raise ValueError("only bash can run in the background")
output, exit_code = _run_bash_process(str(block.input["command"]))
result = _format_bash_result(output, exit_code)
status = "completed" if exit_code == 0 else "failed"
except Exception as exc:
result = f"Error: {type(exc).__name__}: {exc}"
status = "failed"
with background_lock:
background_tasks[bg_id]["status"] = "completed"
background_tasks[bg_id]["status"] = status
background_results[bg_id] = result
with background_lock:
@@ -366,10 +440,10 @@ def start_background_task(block) -> str:
def collect_background_results() -> list[str]:
"""Collect completed background results as task_notification messages."""
"""Collect terminal background results as task_notification messages."""
with background_lock:
ready_ids = [bid for bid, task in background_tasks.items()
if task["status"] == "completed"]
if task["status"] in {"completed", "failed"}]
notifications = []
for bg_id in ready_ids:
with background_lock:
@@ -379,7 +453,7 @@ def collect_background_results() -> list[str]:
notifications.append(
f"<task_notification>\n"
f" <task_id>{bg_id}</task_id>\n"
f" <status>completed</status>\n"
f" <status>{task['status']}</status>\n"
f" <command>{task['command']}</command>\n"
f" <summary>{summary}</summary>\n"
f"</task_notification>")