mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-11 12:27:13 +00:00
feat(agent): add manage_bg_jobs tool to inspect and kill background bash jobs (#4577)
Detached bash jobs (#!bg) could be launched and auto-reported on completion, but the agent had no way to act on a running one: no on-demand output read and no kill (it blocked until the 1h max-runtime). bg_jobs had the pieces (_read_output, list_for_session, internal _kill) but none was exposed. Adds: - bg_jobs.kill(job_id): tears down the process tree, marks the job killed, and sets followed_up so the monitor does not also auto-continue a deliberate kill. - manage_bg_jobs registry tool with actions list / output / kill, scoped to the chat that launched the job (cross-session access reads as not found). - Wiring: TOOL_HANDLERS/TAGS, function schema, RAG index + keyword hints, parser name map, dispatch (threads session_id via _direct_fallback). Gated like bash (NON_ADMIN_BLOCKED_TOOLS; plan-mode mutator). - agent_loop: background-job intent regex maps to the files domain (and the tool joins _DOMAIN_TOOL_MAP[files]) so short commands like 'kill that job' are not dropped by the low-signal gate that skips tool retrieval. - bg launch message tells the model to call manage_bg_jobs itself for check/stop rather than printing raw tool syntax to the user. Tests: tests/test_bg_job_tools.py (kill semantics, per-chat scoping, actions, and the intent classifier).
This commit is contained in:
committed by
GitHub
parent
39a802bea2
commit
cdae9879f2
@@ -23,6 +23,7 @@ from .web_tools import WebSearchTool, WebFetchTool
|
||||
from .filesystem_tools import ReadFileTool, WriteFileTool, EditFileTool, LsTool, GlobTool, GrepTool, GetWorkspaceTool
|
||||
from .document_tools import CreateDocumentTool, UpdateDocumentTool, EditDocumentTool, SuggestDocumentTool, ManageDocumentTool
|
||||
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
|
||||
from .bg_job_tools import ManageBgJobsTool
|
||||
|
||||
TOOL_HANDLERS = {
|
||||
"bash": BashTool().execute,
|
||||
@@ -44,6 +45,7 @@ TOOL_HANDLERS = {
|
||||
"chat_with_model": ChatWithModelTool().execute,
|
||||
"ask_teacher": AskTeacherTool().execute,
|
||||
"list_models": ListModelsTool().execute,
|
||||
"manage_bg_jobs": ManageBgJobsTool().execute,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -56,7 +58,7 @@ PYTHON_TIMEOUT = 30
|
||||
|
||||
# Tool types that trigger execution
|
||||
TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_file", "edit_file",
|
||||
"grep", "glob", "ls", "get_workspace",
|
||||
"grep", "glob", "ls", "get_workspace", "manage_bg_jobs",
|
||||
"create_document", "update_document", "edit_document",
|
||||
"search_chats",
|
||||
"chat_with_model", "create_session", "list_sessions",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Agent tool to inspect and control detached background `bash` jobs.
|
||||
|
||||
`bash` blocks prefixed with a `#!bg` marker run detached via `src.bg_jobs`; the
|
||||
agent is auto-re-invoked with the output when they finish. This tool covers the
|
||||
gaps in that flow: list the jobs in the current chat, read a still-running job's
|
||||
output on demand, and kill a runaway job instead of waiting out its max-runtime.
|
||||
|
||||
Registry tool (`TOOL_HANDLERS["manage_bg_jobs"]`). Jobs are scoped to the chat
|
||||
that launched them, so every action requires the caller's `session_id` and a job
|
||||
from another session is treated as not found.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
|
||||
_LIST_ACTIONS = {"list", "ls", "jobs"}
|
||||
_OUTPUT_ACTIONS = {"output", "get", "read", "tail", "status", "show"}
|
||||
_KILL_ACTIONS = {"kill", "stop", "cancel", "terminate"}
|
||||
|
||||
|
||||
def _age(rec: Dict[str, Any]) -> str:
|
||||
start = rec.get("started_at")
|
||||
if not start:
|
||||
return "?"
|
||||
secs = int(time.time() - start)
|
||||
if secs < 60:
|
||||
return f"{secs}s"
|
||||
if secs < 3600:
|
||||
return f"{secs // 60}m"
|
||||
return f"{secs // 3600}h{(secs % 3600) // 60}m"
|
||||
|
||||
|
||||
def _status_label(rec: Dict[str, Any]) -> str:
|
||||
status = rec.get("status", "?")
|
||||
if rec.get("killed"):
|
||||
return "killed"
|
||||
if rec.get("timed_out"):
|
||||
return "timed out"
|
||||
if rec.get("died"):
|
||||
return "died"
|
||||
if status in ("done", "failed"):
|
||||
return f"{status} (exit {rec.get('exit_code')})"
|
||||
return status
|
||||
|
||||
|
||||
def _row(rec: Dict[str, Any]) -> str:
|
||||
cmd = (rec.get("command") or "").strip().splitlines()[0][:80]
|
||||
return f"[{rec.get('id')}] {_status_label(rec)} | {_age(rec)} | {cmd}"
|
||||
|
||||
|
||||
class ManageBgJobsTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src import bg_jobs
|
||||
|
||||
session_id = ctx.get("session_id")
|
||||
raw = (content or "").strip()
|
||||
try:
|
||||
args = json.loads(raw) if raw else {}
|
||||
except (ValueError, TypeError):
|
||||
args = {}
|
||||
if not isinstance(args, dict):
|
||||
args = {}
|
||||
action = str(args.get("action", "list")).strip().lower()
|
||||
job_id = str(args.get("job_id") or args.get("id") or "").strip()
|
||||
|
||||
if not session_id:
|
||||
return {"error": "manage_bg_jobs: no active chat session; background jobs are scoped to a chat.", "exit_code": 1}
|
||||
|
||||
if action in _LIST_ACTIONS:
|
||||
jobs: List[Dict[str, Any]] = bg_jobs.list_for_session(session_id)
|
||||
if not jobs:
|
||||
return {"output": "No background jobs in this chat.", "exit_code": 0}
|
||||
jobs.sort(key=lambda r: r.get("started_at") or 0, reverse=True)
|
||||
lines = "\n".join(_row(r) for r in jobs)
|
||||
return {"output": f"{len(jobs)} background job(s):\n{lines}", "exit_code": 0}
|
||||
|
||||
if action in _OUTPUT_ACTIONS or action in _KILL_ACTIONS:
|
||||
if not job_id:
|
||||
return {"error": f"manage_bg_jobs: action '{action}' requires a job_id (see action='list').", "exit_code": 1}
|
||||
rec = bg_jobs.get(job_id)
|
||||
# Scope: only the chat that launched a job may see or control it.
|
||||
if rec is None or rec.get("session_id") != session_id:
|
||||
return {"error": f"manage_bg_jobs: no background job '{job_id}' in this chat.", "exit_code": 1}
|
||||
|
||||
if action in _KILL_ACTIONS:
|
||||
if rec.get("status") != "running":
|
||||
return {"output": f"Job `{job_id}` already {_status_label(rec)}; nothing to kill.", "exit_code": 0}
|
||||
killed = bg_jobs.kill(job_id)
|
||||
return {"output": f"Killed background job `{job_id}` ({(killed or {}).get('command', '').splitlines()[0][:80]}).", "exit_code": 0}
|
||||
|
||||
out = rec.get("output") or "(no output yet)"
|
||||
return {
|
||||
"output": f"Job `{job_id}` [{_status_label(rec)}, {_age(rec)}]\nCommand: {rec.get('command')}\n\nOutput:\n{out}",
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
return {"error": f"manage_bg_jobs: unknown action '{action}'. Use list, output, or kill.", "exit_code": 1}
|
||||
Reference in New Issue
Block a user