mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 03:30:48 +08:00
Add background jobs with Chat UI so long agent scripts can run for hours safely.
start_job/get_job/sleep replace blocked run_command for multi-hour work; Chat shows a live count badge and allows manual kill, with a reaper reclaiming timed-out or stale processes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
dbba8cafbf
commit
0cf6aafa30
10 changed files with 1817 additions and 1 deletions
173
runtime/tools/public/job_tools.py
Normal file
173
runtime/tools/public/job_tools.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from runtime.tools.base import ToolSpec
|
||||
from runtime.tools.path_guard import resolve_workspace_path
|
||||
from svc.jobs.background_jobs import (
|
||||
DEFAULT_TIMEOUT_S,
|
||||
MAX_TIMEOUT_S,
|
||||
get_job_store,
|
||||
is_shell_exec_enabled,
|
||||
)
|
||||
|
||||
|
||||
def start_job_tool() -> ToolSpec:
|
||||
def _handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
enabled, hint = is_shell_exec_enabled()
|
||||
if not enabled:
|
||||
return {"ok": False, "error": "disabled", "hint": hint}
|
||||
command = str(args.get("command") or "").strip()
|
||||
if not command:
|
||||
return {"ok": False, "error": "command_required"}
|
||||
cwd_raw = str(args.get("cwd") or "").strip() or "."
|
||||
try:
|
||||
cwd = str(resolve_workspace_path(cwd_raw))
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
timeout_s = args.get("timeout_s")
|
||||
name = str(args.get("name") or "").strip()
|
||||
notify = args.get("notify") if isinstance(args.get("notify"), dict) else None
|
||||
return get_job_store().start(
|
||||
command=command,
|
||||
cwd=cwd,
|
||||
timeout_s=int(timeout_s) if timeout_s is not None else DEFAULT_TIMEOUT_S,
|
||||
name=name,
|
||||
notify=notify,
|
||||
)
|
||||
|
||||
return ToolSpec(
|
||||
name="start_job",
|
||||
description=(
|
||||
"Start a long-running shell command in the background and return job_id immediately "
|
||||
f"(default timeout {DEFAULT_TIMEOUT_S}s / 2h, max {MAX_TIMEOUT_S}s / 3h). "
|
||||
"The process keeps running after this agent turn ends or the chat disconnects. "
|
||||
"Tell the user the job_id and end the turn — do NOT sleep for hours. "
|
||||
"Resume later with get_job/list_jobs. Optional notify={channel,chat_id,...} pings the "
|
||||
"channel when the job finishes. Same enable gate as run_command (AIA_ENABLE_RUN_COMMAND)."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {"type": "string", "description": "Shell command to run in background."},
|
||||
"cwd": {"type": "string", "description": "Working directory (workspace-relative or allowed path)."},
|
||||
"timeout_s": {
|
||||
"type": "integer",
|
||||
"description": f"Kill after N seconds (default {DEFAULT_TIMEOUT_S}, max {MAX_TIMEOUT_S}).",
|
||||
"default": DEFAULT_TIMEOUT_S,
|
||||
},
|
||||
"name": {"type": "string", "description": "Optional human label for the job."},
|
||||
"notify": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Optional channel ping on completion. "
|
||||
"Fields: channel (whatsapp/weixin), chat_id, account_id?, tenant_id?, "
|
||||
"context_token? (weixin), message? (custom text)."
|
||||
),
|
||||
"properties": {
|
||||
"channel": {"type": "string"},
|
||||
"chat_id": {"type": "string"},
|
||||
"account_id": {"type": "string"},
|
||||
"tenant_id": {"type": "string"},
|
||||
"context_token": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"required": ["command"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
handler=_handler,
|
||||
tags=frozenset({"public", "exec", "job"}),
|
||||
risk_level="high",
|
||||
read_only=False,
|
||||
timeout_s=30.0,
|
||||
)
|
||||
|
||||
|
||||
def get_job_tool() -> ToolSpec:
|
||||
def _handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
job_id = str(args.get("job_id") or "").strip()
|
||||
log_tail = int(args.get("log_tail_chars") or 4000)
|
||||
return get_job_store().get(job_id, log_tail_chars=log_tail)
|
||||
|
||||
return ToolSpec(
|
||||
name="get_job",
|
||||
description=(
|
||||
"Get background job status by job_id (running/succeeded/failed/timeout/cancelled), "
|
||||
"exit_code, and stdout/stderr tails. Poll until done=true."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job_id": {"type": "string", "description": "Job id returned by start_job."},
|
||||
"log_tail_chars": {
|
||||
"type": "integer",
|
||||
"description": "Max characters of each log tail (default 4000).",
|
||||
"default": 4000,
|
||||
},
|
||||
},
|
||||
"required": ["job_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
handler=_handler,
|
||||
tags=frozenset({"public", "job", "read"}),
|
||||
risk_level="low",
|
||||
read_only=True,
|
||||
timeout_s=10.0,
|
||||
)
|
||||
|
||||
|
||||
def cancel_job_tool() -> ToolSpec:
|
||||
def _handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
enabled, hint = is_shell_exec_enabled()
|
||||
if not enabled:
|
||||
return {"ok": False, "error": "disabled", "hint": hint}
|
||||
job_id = str(args.get("job_id") or "").strip()
|
||||
return get_job_store().cancel(job_id)
|
||||
|
||||
return ToolSpec(
|
||||
name="cancel_job",
|
||||
description="Cancel a running background job (best-effort process tree kill).",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job_id": {"type": "string", "description": "Job id returned by start_job."},
|
||||
},
|
||||
"required": ["job_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
handler=_handler,
|
||||
tags=frozenset({"public", "exec", "job"}),
|
||||
risk_level="high",
|
||||
read_only=False,
|
||||
timeout_s=20.0,
|
||||
)
|
||||
|
||||
|
||||
def list_jobs_tool() -> ToolSpec:
|
||||
def _handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
limit = int(args.get("limit") or 20)
|
||||
return get_job_store().list_jobs(limit=limit)
|
||||
|
||||
return ToolSpec(
|
||||
name="list_jobs",
|
||||
description="List recent background jobs (id, name, status, timestamps).",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "description": "Max jobs to return (default 20).", "default": 20},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
handler=_handler,
|
||||
tags=frozenset({"public", "job", "read"}),
|
||||
risk_level="low",
|
||||
read_only=True,
|
||||
timeout_s=10.0,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["start_job_tool", "get_job_tool", "cancel_job_tool", "list_jobs_tool"]
|
||||
63
runtime/tools/public/sleep_tool.py
Normal file
63
runtime/tools/public/sleep_tool.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from runtime.tools.base import ToolSpec
|
||||
|
||||
_MAX_SLEEP_S = 120
|
||||
|
||||
|
||||
def sleep_tool() -> ToolSpec:
|
||||
def _handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
seconds = float(args.get("seconds") or 0)
|
||||
except Exception:
|
||||
return {"ok": False, "error": "invalid_seconds"}
|
||||
if seconds <= 0:
|
||||
return {"ok": False, "error": "seconds_must_be_positive"}
|
||||
if seconds > _MAX_SLEEP_S:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "seconds_too_large",
|
||||
"max_seconds": _MAX_SLEEP_S,
|
||||
"hint": (
|
||||
"For multi-hour work use start_job + get_job, or schedule_create. "
|
||||
f"sleep is only for short poll gaps (max {_MAX_SLEEP_S}s)."
|
||||
),
|
||||
}
|
||||
started = time.time()
|
||||
time.sleep(seconds)
|
||||
return {
|
||||
"ok": True,
|
||||
"slept_seconds": round(time.time() - started, 3),
|
||||
"requested_seconds": seconds,
|
||||
}
|
||||
|
||||
return ToolSpec(
|
||||
name="sleep",
|
||||
description=(
|
||||
f"Block the current turn for N seconds (max {_MAX_SLEEP_S}). "
|
||||
"Use between get_job polls or after a change that needs a short settle time. "
|
||||
"Do NOT use for multi-hour waits — use start_job or schedule_create instead."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"seconds": {
|
||||
"type": "number",
|
||||
"description": f"Seconds to wait (1–{_MAX_SLEEP_S}).",
|
||||
},
|
||||
},
|
||||
"required": ["seconds"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
handler=_handler,
|
||||
tags=frozenset({"public", "utility"}),
|
||||
risk_level="low",
|
||||
read_only=True,
|
||||
timeout_s=float(_MAX_SLEEP_S + 5),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["sleep_tool"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue