feat: add shared runtime with FastAPI job server and safety pipeline
This commit is contained in:
173
src/cli.py
173
src/cli.py
@@ -2,146 +2,96 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from openai import OpenAI
|
||||
|
||||
from .agent import ScreenJobAgent
|
||||
from .utils import setup_artifacts, setup_logger
|
||||
|
||||
try:
|
||||
import pyautogui
|
||||
except Exception as import_exc:
|
||||
raise RuntimeError(
|
||||
"pyautogui is required. Install dependencies with: pip install pyautogui pillow"
|
||||
) from import_exc
|
||||
from .config import load_app_config
|
||||
from .models import RuntimeOptions
|
||||
from .runtime import create_openai_client, run_job
|
||||
from .safety import assess_task_safety
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run an autonomous desktop task agent using OpenAI + UI tools.",
|
||||
description="Run an autonomous desktop task agent using OpenAI + local tools.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"Examples:\n"
|
||||
' python main.py "Open amazon.de"\n'
|
||||
' python main.py "Open amazon.de and search for mechanical keyboard" --max-steps 80\n\n'
|
||||
"Artifacts:\n"
|
||||
" Each run stores logs/screens in ./screenjob_runs/run_YYYYMMDD_HHMMSS/"
|
||||
' python main.py run "Open amazon.de"\n'
|
||||
' python main.py run "Open amazon.de and search for keyboard" --max-steps 80\n'
|
||||
' python main.py run "Open amazon.de" --disable-tool click --disable-tool type\n'
|
||||
),
|
||||
)
|
||||
parser.add_argument("job", type=str, help="Task objective for the agent.")
|
||||
parser.add_argument("--model", type=str, default="gpt-5.2", help="OpenAI model name.")
|
||||
parser.add_argument("--model", type=str, default=None, help="OpenAI model name.")
|
||||
parser.add_argument("--max-steps", type=int, default=60, help="Max tool-iteration steps.")
|
||||
parser.add_argument(
|
||||
"--command-timeout",
|
||||
type=int,
|
||||
default=45,
|
||||
help="Timeout (seconds) for execute_command tool.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--type-interval",
|
||||
type=float,
|
||||
default=0.02,
|
||||
help="Seconds between typed characters.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--click-pause",
|
||||
type=float,
|
||||
default=0.10,
|
||||
help="Mouse move duration before click (seconds).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-failsafe",
|
||||
action="store_true",
|
||||
help="Disable PyAutoGUI fail-safe. Not recommended.",
|
||||
)
|
||||
parser.add_argument("--command-timeout", type=int, default=45, help="Timeout in seconds for execute_command.")
|
||||
parser.add_argument("--type-interval", type=float, default=0.02, help="Seconds between typed characters.")
|
||||
parser.add_argument("--click-pause", type=float, default=0.10, help="Mouse move duration before click.")
|
||||
parser.add_argument("--disable-tool", action="append", default=[], help="Disable a tool by name.")
|
||||
parser.add_argument("--skip-safety-check", action="store_true", help="Bypass pre-flight safety check.")
|
||||
parser.add_argument("--no-failsafe", action="store_true", help="Disable PyAutoGUI fail-safe.")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
load_dotenv()
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
args = parser.parse_args(argv)
|
||||
cwd = Path.cwd()
|
||||
config = load_app_config(cwd)
|
||||
|
||||
api_key = os.getenv("OPENAI_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
print("ERROR: Missing OPENAI_API_KEY (expected in environment or .env).", file=sys.stderr)
|
||||
if not config.openai_api_key:
|
||||
print("ERROR: Missing OPENAI_API_KEY in environment/.env", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
pyautogui.FAILSAFE = not args.no_failsafe
|
||||
pyautogui.PAUSE = 0.05
|
||||
model = args.model or config.default_model
|
||||
disabled_tools = sorted({str(x).strip() for x in args.disable_tool if str(x).strip()})
|
||||
|
||||
runs_base = Path.cwd() / "screenjob_runs"
|
||||
artifacts = setup_artifacts(runs_base)
|
||||
logger = setup_logger(artifacts.log_file, verbose=True)
|
||||
if not args.skip_safety_check:
|
||||
safety_client = create_openai_client(config.openai_api_key)
|
||||
safe, reason, parsed = assess_task_safety(
|
||||
safety_client,
|
||||
model=config.safety_model,
|
||||
objective=args.job,
|
||||
disabled_tools=disabled_tools,
|
||||
)
|
||||
if not safe:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"completed": False,
|
||||
"result": f"Blocked by safety check: {reason}",
|
||||
"safety": parsed,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 1
|
||||
|
||||
logger.info("ScreenJob booting. Artifacts: %s", str(artifacts.root_dir.resolve()))
|
||||
logger.info("PyAutoGUI FAILSAFE=%s", pyautogui.FAILSAFE)
|
||||
|
||||
try:
|
||||
client = OpenAI(api_key=api_key)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("Failed to create OpenAI client.")
|
||||
print(f"ERROR: Could not initialize OpenAI client: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
agent = ScreenJobAgent(
|
||||
client=client,
|
||||
logger=logger,
|
||||
artifacts=artifacts,
|
||||
model=args.model,
|
||||
options = RuntimeOptions(
|
||||
model=model,
|
||||
max_steps=args.max_steps,
|
||||
command_timeout=args.command_timeout,
|
||||
type_interval=args.type_interval,
|
||||
click_pause=args.click_pause,
|
||||
disable_tools=set(disabled_tools),
|
||||
)
|
||||
|
||||
try:
|
||||
result = agent.run(args.job)
|
||||
elapsed = result.ended_at - result.started_at
|
||||
logger.info("Run finished. completed=%s elapsed=%.2fs", result.completed, elapsed)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"completed": result.completed,
|
||||
"result": result.result,
|
||||
"steps": result.steps,
|
||||
"elapsed_seconds": round(elapsed, 3),
|
||||
"artifacts_dir": str(artifacts.root_dir.resolve()),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
result, artifacts = run_job(
|
||||
api_key=config.openai_api_key,
|
||||
objective=args.job,
|
||||
options=options,
|
||||
runs_base=config.runs_dir,
|
||||
no_failsafe=args.no_failsafe,
|
||||
)
|
||||
return 0 if result.completed else 1
|
||||
except KeyboardInterrupt:
|
||||
logger.warning("Interrupted by user.")
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"completed": False,
|
||||
"result": "Interrupted by user.",
|
||||
"steps": agent.step,
|
||||
"artifacts_dir": str(artifacts.root_dir.resolve()),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
print(json.dumps({"completed": False, "result": "Interrupted by user."}, ensure_ascii=False, indent=2))
|
||||
return 130
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("Fatal runtime error.")
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"completed": False,
|
||||
"result": f"Fatal error: {type(exc).__name__}: {exc}",
|
||||
"steps": agent.step,
|
||||
"artifacts_dir": str(artifacts.root_dir.resolve()),
|
||||
},
|
||||
{"completed": False, "result": f"Fatal error: {type(exc).__name__}: {exc}"},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
@@ -149,3 +99,16 @@ def main() -> int:
|
||||
)
|
||||
return 1
|
||||
|
||||
payload = {
|
||||
"completed": result.completed,
|
||||
"result": result.result,
|
||||
"steps": result.steps,
|
||||
"elapsed_seconds": round(result.ended_at - result.started_at, 3),
|
||||
"artifacts_dir": str(artifacts.root_dir.resolve()),
|
||||
"usage": result.usage.to_dict(),
|
||||
"error": result.error,
|
||||
"cancelled": result.cancelled,
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 0 if result.completed else 1
|
||||
|
||||
|
||||
Reference in New Issue
Block a user