96659759b0
- Agent OS:agents 决策循环、runtime 编排、providers、governance/security/sandbox - app 服务层(认证转发到 server-core)、channels 19 路频道、memory、plugins
1071 lines
30 KiB
Python
1071 lines
30 KiB
Python
# -*- coding: utf-8 -*-
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
|
|
import click
|
|
|
|
from .http import client, print_json
|
|
from ..app.channels.schema import DEFAULT_CHANNEL
|
|
|
|
|
|
def _base_url(ctx: click.Context, base_url: Optional[str]) -> str:
|
|
"""Resolve base_url with priority:
|
|
1) command --base-url
|
|
2) global --host/--port
|
|
(already resolved in main.py, may come from config.json)
|
|
"""
|
|
if base_url:
|
|
return base_url.rstrip("/")
|
|
host = (ctx.obj or {}).get("host", "127.0.0.1")
|
|
port = (ctx.obj or {}).get("port", 8088)
|
|
return f"http://{host}:{port}"
|
|
|
|
|
|
@click.group("cron")
|
|
def cron_group() -> None:
|
|
"""Manage scheduled cron jobs via the HTTP API (/cron).
|
|
|
|
Use list/get/state to inspect jobs; create/update/delete to
|
|
add, modify, or remove; pause/resume to toggle execution;
|
|
run to trigger a one-off run.
|
|
"""
|
|
|
|
|
|
@cron_group.command("list")
|
|
@click.option(
|
|
"--base-url",
|
|
default=None,
|
|
help=(
|
|
"Override the API base URL (e.g. http://127.0.0.1:8088). "
|
|
"If omitted, uses global --host and --port from config."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--agent-id",
|
|
default="default",
|
|
help="Agent ID (defaults to 'default')",
|
|
)
|
|
@click.pass_context
|
|
def list_jobs(
|
|
ctx: click.Context,
|
|
base_url: Optional[str],
|
|
agent_id: str,
|
|
) -> None:
|
|
"""List all cron jobs. Output is JSON from GET /cron/jobs."""
|
|
base_url = _base_url(ctx, base_url)
|
|
with client(base_url) as c:
|
|
headers = {"X-Agent-Id": agent_id}
|
|
r = c.get("/cron/jobs", headers=headers)
|
|
r.raise_for_status()
|
|
print_json(r.json())
|
|
|
|
|
|
@cron_group.command("get")
|
|
@click.argument("job_id", metavar="JOB_ID")
|
|
@click.option(
|
|
"--base-url",
|
|
default=None,
|
|
help="Override the API base URL. Defaults to global --host/--port.",
|
|
)
|
|
@click.option(
|
|
"--agent-id",
|
|
default="default",
|
|
help="Agent ID (defaults to 'default')",
|
|
)
|
|
@click.pass_context
|
|
def get_job(
|
|
ctx: click.Context,
|
|
job_id: str,
|
|
base_url: Optional[str],
|
|
agent_id: str,
|
|
) -> None:
|
|
"""Fetch a cron job by ID. Returns JSON from GET /cron/jobs/<id>."""
|
|
base_url = _base_url(ctx, base_url)
|
|
with client(base_url) as c:
|
|
headers = {"X-Agent-Id": agent_id}
|
|
r = c.get(f"/cron/jobs/{job_id}", headers=headers)
|
|
if r.status_code == 404:
|
|
raise click.ClickException("Job not found.")
|
|
r.raise_for_status()
|
|
print_json(r.json())
|
|
|
|
|
|
@cron_group.command("state")
|
|
@click.argument("job_id", metavar="JOB_ID")
|
|
@click.option(
|
|
"--base-url",
|
|
default=None,
|
|
help="Override the API base URL. Defaults to global --host/--port.",
|
|
)
|
|
@click.option(
|
|
"--agent-id",
|
|
default="default",
|
|
help="Agent ID (defaults to 'default')",
|
|
)
|
|
@click.pass_context
|
|
def job_state(
|
|
ctx: click.Context,
|
|
job_id: str,
|
|
base_url: Optional[str],
|
|
agent_id: str,
|
|
) -> None:
|
|
"""Get the runtime state of a cron job (e.g. next run time, paused)."""
|
|
base_url = _base_url(ctx, base_url)
|
|
with client(base_url) as c:
|
|
headers = {"X-Agent-Id": agent_id}
|
|
r = c.get(f"/cron/jobs/{job_id}/state", headers=headers)
|
|
if r.status_code == 404:
|
|
raise click.ClickException("Job not found.")
|
|
r.raise_for_status()
|
|
print_json(r.json())
|
|
|
|
|
|
def _validate_and_apply_scheduled_repeat(
|
|
schedule: dict,
|
|
repeat_every_days: Optional[int],
|
|
repeat_end_type: Optional[str],
|
|
repeat_until: Optional[str],
|
|
repeat_count: Optional[int],
|
|
) -> None:
|
|
if repeat_end_type and repeat_every_days is None:
|
|
raise click.UsageError(
|
|
"--repeat-end-type requires --repeat-every-days",
|
|
)
|
|
if repeat_until and (
|
|
repeat_end_type != "until" or repeat_every_days is None
|
|
):
|
|
raise click.UsageError(
|
|
"--repeat-until requires --repeat-every-days and "
|
|
"--repeat-end-type until",
|
|
)
|
|
if repeat_count is not None and (
|
|
repeat_end_type != "count" or repeat_every_days is None
|
|
):
|
|
raise click.UsageError(
|
|
"--repeat-count requires --repeat-every-days and "
|
|
"--repeat-end-type count",
|
|
)
|
|
if repeat_every_days is None:
|
|
return
|
|
|
|
schedule["repeat_every_days"] = repeat_every_days
|
|
end_type = repeat_end_type or "never"
|
|
schedule["repeat_end_type"] = end_type
|
|
if end_type == "until":
|
|
if not (repeat_until and repeat_until.strip()):
|
|
raise click.UsageError(
|
|
"--repeat-until is required when --repeat-end-type is 'until'",
|
|
)
|
|
schedule["repeat_until"] = repeat_until.strip()
|
|
elif end_type == "count":
|
|
if repeat_count is None:
|
|
raise click.UsageError(
|
|
"--repeat-count is required when --repeat-end-type is 'count'",
|
|
)
|
|
schedule["repeat_count"] = repeat_count
|
|
|
|
|
|
def _build_schedule_from_cli(
|
|
schedule_type: str,
|
|
cron: str,
|
|
run_at: Optional[str],
|
|
timezone: str,
|
|
repeat_every_days: Optional[int],
|
|
repeat_end_type: Optional[str],
|
|
repeat_until: Optional[str],
|
|
repeat_count: Optional[int],
|
|
) -> dict:
|
|
if schedule_type == "scheduled":
|
|
if not (run_at and run_at.strip()):
|
|
raise click.UsageError(
|
|
"--run-at is required when schedule type is 'scheduled'",
|
|
)
|
|
schedule = {
|
|
"type": "once",
|
|
"run_at": run_at.strip(),
|
|
"timezone": timezone,
|
|
}
|
|
_validate_and_apply_scheduled_repeat(
|
|
schedule=schedule,
|
|
repeat_every_days=repeat_every_days,
|
|
repeat_end_type=repeat_end_type,
|
|
repeat_until=repeat_until,
|
|
repeat_count=repeat_count,
|
|
)
|
|
return schedule
|
|
|
|
if not (cron and cron.strip()):
|
|
raise click.UsageError(
|
|
"--cron is required when schedule type is 'cron'",
|
|
)
|
|
if (
|
|
repeat_every_days is not None
|
|
or repeat_end_type is not None
|
|
or repeat_until is not None
|
|
or repeat_count is not None
|
|
):
|
|
raise click.UsageError(
|
|
"--repeat-* options are only supported when "
|
|
"--schedule-type is 'scheduled'",
|
|
)
|
|
return {"type": "cron", "cron": cron, "timezone": timezone}
|
|
|
|
|
|
def _build_spec_from_cli(
|
|
task_type: str,
|
|
schedule_type: str,
|
|
name: str,
|
|
cron: str,
|
|
run_at: Optional[str],
|
|
repeat_every_days: Optional[int],
|
|
repeat_end_type: Optional[str],
|
|
repeat_until: Optional[str],
|
|
repeat_count: Optional[int],
|
|
channel: str,
|
|
target_user: str,
|
|
target_session: str,
|
|
text: Optional[str],
|
|
timezone: str,
|
|
enabled: bool,
|
|
mode: str,
|
|
silent: bool,
|
|
save_result_to_inbox: Optional[bool] = None,
|
|
share_session: bool = True,
|
|
timeout_seconds: int = 120,
|
|
tool_safety: bool = False,
|
|
) -> dict:
|
|
"""Build CronJobSpec JSON payload from CLI args (no id)."""
|
|
schedule = _build_schedule_from_cli(
|
|
schedule_type=schedule_type,
|
|
cron=cron,
|
|
run_at=run_at,
|
|
timezone=timezone,
|
|
repeat_every_days=repeat_every_days,
|
|
repeat_end_type=repeat_end_type,
|
|
repeat_until=repeat_until,
|
|
repeat_count=repeat_count,
|
|
)
|
|
dispatch = {
|
|
"type": "channel",
|
|
"channel": channel,
|
|
"target": {"user_id": target_user, "session_id": target_session},
|
|
"mode": mode,
|
|
"silent": silent,
|
|
"meta": {},
|
|
}
|
|
runtime = {
|
|
"share_session": share_session,
|
|
"max_concurrency": 1,
|
|
"timeout_seconds": timeout_seconds,
|
|
"misfire_grace_seconds": 600,
|
|
"tool_safety": tool_safety,
|
|
}
|
|
if task_type == "text":
|
|
if silent:
|
|
raise click.UsageError(
|
|
"--silent is only supported when task type is 'agent'",
|
|
)
|
|
if not (text and text.strip()):
|
|
raise click.UsageError(
|
|
"--text is required when task type is 'text'",
|
|
)
|
|
payload = {
|
|
"id": "",
|
|
"name": name,
|
|
"enabled": enabled,
|
|
"schedule": schedule,
|
|
"task_type": "text",
|
|
"text": text.strip(),
|
|
"dispatch": dispatch,
|
|
"runtime": runtime,
|
|
"meta": {},
|
|
}
|
|
if save_result_to_inbox is not None:
|
|
payload["save_result_to_inbox"] = save_result_to_inbox
|
|
return payload
|
|
if task_type == "agent":
|
|
if not (text and text.strip()):
|
|
raise click.UsageError(
|
|
"--text is required when task type is 'agent' "
|
|
"(the question/prompt sent to the agent)",
|
|
)
|
|
payload = {
|
|
"id": "",
|
|
"name": name,
|
|
"enabled": enabled,
|
|
"schedule": schedule,
|
|
"task_type": "agent",
|
|
"request": {
|
|
"input": [
|
|
{
|
|
"role": "user",
|
|
"type": "message",
|
|
"content": [{"type": "text", "text": text.strip()}],
|
|
},
|
|
],
|
|
},
|
|
"dispatch": dispatch,
|
|
"runtime": runtime,
|
|
"meta": {},
|
|
}
|
|
if save_result_to_inbox is not None:
|
|
payload["save_result_to_inbox"] = save_result_to_inbox
|
|
return payload
|
|
raise click.UsageError(f"Unsupported task type: {task_type}")
|
|
|
|
|
|
@cron_group.command("create")
|
|
@click.option(
|
|
"-f",
|
|
"--file",
|
|
"file_",
|
|
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
default=None,
|
|
help=(
|
|
"Path to a JSON file containing the full cron job spec. "
|
|
"Mutually exclusive with inline options (--type, --name, etc.)."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--type",
|
|
"task_type",
|
|
type=click.Choice(["text", "agent"], case_sensitive=False),
|
|
default=None,
|
|
help=(
|
|
"Task type: 'text' sends fixed content to the channel; "
|
|
"'agent' sends a question to the agent and delivers the reply to the "
|
|
"channel. Required when not using -f/--file."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--schedule-type",
|
|
type=click.Choice(["cron", "scheduled"], case_sensitive=False),
|
|
default="cron",
|
|
show_default=True,
|
|
help=(
|
|
"Schedule type: 'cron' for recurring jobs, "
|
|
"'scheduled' for calendar-style jobs."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--name",
|
|
default=None,
|
|
help="Display name for the job. Required when not using -f/--file.",
|
|
)
|
|
@click.option(
|
|
"--cron",
|
|
default=None,
|
|
help=(
|
|
"Cron expression (5 fields: minute hour day month weekday). "
|
|
"Example: '0 9 * * *' for daily at 09:00. "
|
|
"Required when --schedule-type is cron."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--run-at",
|
|
default=None,
|
|
help=(
|
|
"Run time for one-time jobs in ISO 8601 format, e.g. "
|
|
"'2026-04-21T15:30:00+08:00'. "
|
|
"Required when --schedule-type is scheduled."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--repeat-every-days",
|
|
type=click.IntRange(min=1),
|
|
default=None,
|
|
help=(
|
|
"For --schedule-type scheduled only. "
|
|
"Repeat every N days (>=1). "
|
|
"If omitted, the job runs once."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--repeat-end-type",
|
|
type=click.Choice(["never", "until", "count"], case_sensitive=False),
|
|
default=None,
|
|
help=(
|
|
"For repeated scheduled jobs only. End condition: "
|
|
"'never', 'until', or 'count'. Defaults to 'never' "
|
|
"when --repeat-every-days is set."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--repeat-until",
|
|
default=None,
|
|
help=(
|
|
"For repeated scheduled jobs. End date-time in ISO 8601 format. "
|
|
"Required when --repeat-end-type is until."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--repeat-count",
|
|
type=click.IntRange(min=1),
|
|
default=None,
|
|
help=(
|
|
"For repeated scheduled jobs. Max run count (>=1). "
|
|
"Required when --repeat-end-type is count."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--channel",
|
|
default=None,
|
|
help=(
|
|
"Delivery channel: e.g. imessage, dingtalk, discord, qq, console. "
|
|
"Required when not using -f/--file."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--target-user",
|
|
default=None,
|
|
help=(
|
|
"Target user_id for the channel (recipient identifier). "
|
|
"Required when not using -f/--file."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--target-session",
|
|
default=None,
|
|
help=(
|
|
"Target session_id for the channel. "
|
|
"Required when not using -f/--file."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--text",
|
|
default=None,
|
|
help=(
|
|
"Content: for 'text' tasks this is the message sent to the channel; "
|
|
"for 'agent' tasks this is the prompt/question sent to the agent. "
|
|
"Required for both task types."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--timezone",
|
|
default=None,
|
|
help=(
|
|
"Timezone for the cron schedule (e.g. UTC, America/New_York). "
|
|
"Defaults to the user timezone from config."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--enabled/--no-enabled",
|
|
default=True,
|
|
help="Create the job as enabled (--enabled) or disabled (--no-enabled).",
|
|
)
|
|
@click.option(
|
|
"--mode",
|
|
type=click.Choice(["stream", "final"], case_sensitive=False),
|
|
default="stream",
|
|
help=(
|
|
"Delivery mode: 'stream' sends incremental updates; "
|
|
"'final' sends only the final result."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--silent/--no-silent",
|
|
default=False,
|
|
help=(
|
|
"Run an agent task without delivering its response to the channel. "
|
|
"Session, trace, and optional Inbox records are still preserved."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--save-result-to-inbox/--no-save-result-to-inbox",
|
|
default=None,
|
|
help=(
|
|
"Whether to save execution results to Inbox. "
|
|
"If omitted, server-side defaults are applied."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--share-session/--no-share-session",
|
|
default=True,
|
|
help=(
|
|
"Share session with target user. "
|
|
"When disabled, creates isolated context for each run."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--timeout",
|
|
"timeout_seconds",
|
|
type=click.IntRange(min=1),
|
|
default=120,
|
|
show_default=True,
|
|
help=(
|
|
"Maximum execution time in seconds for agent tasks. "
|
|
"If the task takes longer, it will be cancelled. "
|
|
"Increase for complex tasks (e.g. --timeout 1800)."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--tool-safety/--no-tool-safety",
|
|
default=False,
|
|
show_default=True,
|
|
help=(
|
|
"Tool execution safety check. When enabled, risky tool calls "
|
|
"require approval (may block unattended jobs). "
|
|
"When disabled, all tools execute without approval."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--base-url",
|
|
default=None,
|
|
help="Override the API base URL. Defaults to global --host/--port.",
|
|
)
|
|
@click.option(
|
|
"--agent-id",
|
|
default="default",
|
|
help="Agent ID (defaults to 'default')",
|
|
)
|
|
@click.pass_context
|
|
def create_job(
|
|
ctx: click.Context,
|
|
file_: Optional[Path],
|
|
task_type: Optional[str],
|
|
schedule_type: str,
|
|
name: Optional[str],
|
|
cron: Optional[str],
|
|
run_at: Optional[str],
|
|
repeat_every_days: Optional[int],
|
|
repeat_end_type: Optional[str],
|
|
repeat_until: Optional[str],
|
|
repeat_count: Optional[int],
|
|
channel: Optional[str],
|
|
target_user: Optional[str],
|
|
target_session: Optional[str],
|
|
text: Optional[str],
|
|
timezone: Optional[str],
|
|
enabled: bool,
|
|
mode: str,
|
|
silent: bool,
|
|
save_result_to_inbox: Optional[bool],
|
|
share_session: bool,
|
|
timeout_seconds: int,
|
|
tool_safety: bool,
|
|
base_url: Optional[str],
|
|
agent_id: str,
|
|
) -> None:
|
|
"""Create a cron job.
|
|
|
|
Either pass -f/--file with a JSON spec, or use --type, --name, --cron,
|
|
--channel, --target-user, --target-session and --text to define the job
|
|
inline.
|
|
"""
|
|
if timezone is None:
|
|
from ..config import load_config
|
|
|
|
timezone = load_config().user_timezone or "UTC"
|
|
base_url = _base_url(ctx, base_url)
|
|
if file_ is not None:
|
|
payload = json.loads(file_.read_text(encoding="utf-8"))
|
|
else:
|
|
for value, label in [
|
|
(task_type, "--type"),
|
|
(name, "--name"),
|
|
(channel, "--channel"),
|
|
(target_user, "--target-user"),
|
|
(target_session, "--target-session"),
|
|
]:
|
|
if not value or (isinstance(value, str) and not value.strip()):
|
|
raise click.UsageError(
|
|
f"When creating without -f/--file, {label} is required",
|
|
)
|
|
if schedule_type == "cron":
|
|
if not (cron and cron.strip()):
|
|
raise click.UsageError(
|
|
"When --schedule-type is cron, --cron is required",
|
|
)
|
|
elif not (run_at and run_at.strip()):
|
|
raise click.UsageError(
|
|
"When --schedule-type is scheduled, --run-at is required",
|
|
)
|
|
payload = _build_spec_from_cli(
|
|
task_type=task_type or "agent",
|
|
schedule_type=schedule_type,
|
|
name=name or "",
|
|
cron=cron or "",
|
|
run_at=run_at,
|
|
repeat_every_days=repeat_every_days,
|
|
repeat_end_type=repeat_end_type,
|
|
repeat_until=repeat_until,
|
|
repeat_count=repeat_count,
|
|
channel=channel or DEFAULT_CHANNEL,
|
|
target_user=target_user or "",
|
|
target_session=target_session or "",
|
|
text=text,
|
|
timezone=timezone,
|
|
enabled=enabled,
|
|
mode=mode,
|
|
silent=silent,
|
|
save_result_to_inbox=save_result_to_inbox,
|
|
share_session=share_session,
|
|
timeout_seconds=timeout_seconds,
|
|
tool_safety=tool_safety,
|
|
)
|
|
with client(base_url) as c:
|
|
headers = {"X-Agent-Id": agent_id}
|
|
r = c.post("/cron/jobs", json=payload, headers=headers)
|
|
r.raise_for_status()
|
|
print_json(r.json())
|
|
|
|
|
|
def _resolve_update_spec(
|
|
spec: Dict[str, Any],
|
|
task_type: Optional[str],
|
|
schedule_type: Optional[str],
|
|
name: Optional[str],
|
|
cron: Optional[str],
|
|
run_at: Optional[str],
|
|
repeat_every_days: Optional[int],
|
|
repeat_end_type: Optional[str],
|
|
repeat_until: Optional[str],
|
|
repeat_count: Optional[int],
|
|
channel: Optional[str],
|
|
target_user: Optional[str],
|
|
target_session: Optional[str],
|
|
text: Optional[str],
|
|
timezone: Optional[str],
|
|
enabled: Optional[bool],
|
|
mode: Optional[str],
|
|
silent: Optional[bool],
|
|
save_result_to_inbox: Optional[bool],
|
|
share_session: Optional[bool],
|
|
timeout_seconds: Optional[int],
|
|
tool_safety: Optional[bool] = None,
|
|
) -> Dict[str, Any]:
|
|
# pylint: disable=too-many-branches,too-many-statements
|
|
"""Merge CLI overrides with an existing cron-job spec.
|
|
|
|
Deep-copies the existing spec and only patches fields explicitly
|
|
provided by the CLI. Unspecified fields — including advanced
|
|
runtime settings (``max_concurrency``, ``misfire_grace_seconds``)
|
|
and request extensions (``model``, ``request_context``, …) — are
|
|
preserved as-is. Returns a payload dict suitable for
|
|
PUT /cron/jobs/{id}.
|
|
"""
|
|
payload = copy.deepcopy(spec)
|
|
|
|
if name is not None:
|
|
payload["name"] = name
|
|
if enabled is not None:
|
|
payload["enabled"] = enabled
|
|
if task_type is not None:
|
|
payload["task_type"] = task_type
|
|
|
|
# --- schedule ---
|
|
sch = payload.setdefault("schedule", {})
|
|
if schedule_type is not None:
|
|
# CLI exposes "scheduled"; the API model stores it as "once".
|
|
sch["type"] = (
|
|
"once" if schedule_type in ("scheduled", "once") else schedule_type
|
|
)
|
|
if cron is not None:
|
|
sch["cron"] = cron
|
|
if run_at is not None:
|
|
sch["run_at"] = run_at
|
|
if timezone is not None:
|
|
sch["timezone"] = timezone
|
|
if repeat_every_days is not None:
|
|
sch["repeat_every_days"] = repeat_every_days
|
|
if repeat_end_type is not None:
|
|
sch["repeat_end_type"] = repeat_end_type
|
|
if repeat_until is not None:
|
|
sch["repeat_until"] = repeat_until
|
|
if repeat_count is not None:
|
|
sch["repeat_count"] = repeat_count
|
|
|
|
# --- dispatch ---
|
|
dsp = payload.setdefault("dispatch", {})
|
|
if channel is not None:
|
|
dsp["channel"] = channel
|
|
if mode is not None:
|
|
dsp["mode"] = mode
|
|
if silent is not None:
|
|
dsp["silent"] = silent
|
|
target = dsp.setdefault("target", {})
|
|
if target_user is not None:
|
|
target["user_id"] = target_user
|
|
if target_session is not None:
|
|
target["session_id"] = target_session
|
|
|
|
# --- runtime ---
|
|
run = payload.setdefault("runtime", {})
|
|
if share_session is not None:
|
|
run["share_session"] = share_session
|
|
if timeout_seconds is not None:
|
|
run["timeout_seconds"] = timeout_seconds
|
|
if tool_safety is not None:
|
|
run["tool_safety"] = tool_safety
|
|
|
|
# --- text / request ---
|
|
if text is not None:
|
|
if payload.get("task_type") == "agent":
|
|
req = payload.setdefault("request", {})
|
|
try:
|
|
req["input"][0]["content"][0]["text"] = text.strip()
|
|
except (KeyError, IndexError, TypeError):
|
|
req["input"] = [
|
|
{
|
|
"role": "user",
|
|
"type": "message",
|
|
"content": [{"type": "text", "text": text.strip()}],
|
|
},
|
|
]
|
|
else:
|
|
payload["text"] = text.strip()
|
|
|
|
if save_result_to_inbox is not None:
|
|
payload["save_result_to_inbox"] = save_result_to_inbox
|
|
|
|
return payload
|
|
|
|
|
|
@cron_group.command("update")
|
|
@click.argument("job_id", metavar="JOB_ID")
|
|
@click.option(
|
|
"-f",
|
|
"--file",
|
|
"file_",
|
|
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
default=None,
|
|
help=(
|
|
"Path to a JSON file containing the full cron job spec. "
|
|
"Mutually exclusive with inline options (--type, --name, etc.)."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--type",
|
|
"task_type",
|
|
type=click.Choice(["text", "agent"], case_sensitive=False),
|
|
default=None,
|
|
help="Task type: 'text' or 'agent'.",
|
|
)
|
|
@click.option(
|
|
"--schedule-type",
|
|
type=click.Choice(["cron", "scheduled"], case_sensitive=False),
|
|
default=None,
|
|
help="Schedule type: 'cron' or 'scheduled'.",
|
|
)
|
|
@click.option(
|
|
"--name",
|
|
default=None,
|
|
help="Display name for the job.",
|
|
)
|
|
@click.option(
|
|
"--cron",
|
|
default=None,
|
|
help="Cron expression (5 fields). Example: '0 9 * * *'.",
|
|
)
|
|
@click.option(
|
|
"--run-at",
|
|
default=None,
|
|
help="Run time for scheduled jobs in ISO 8601 format.",
|
|
)
|
|
@click.option(
|
|
"--repeat-every-days",
|
|
type=click.IntRange(min=1),
|
|
default=None,
|
|
help="For scheduled: repeat every N days.",
|
|
)
|
|
@click.option(
|
|
"--repeat-end-type",
|
|
type=click.Choice(["never", "until", "count"], case_sensitive=False),
|
|
default=None,
|
|
help="For scheduled: end condition.",
|
|
)
|
|
@click.option(
|
|
"--repeat-until",
|
|
default=None,
|
|
help="For scheduled: end date-time (ISO 8601).",
|
|
)
|
|
@click.option(
|
|
"--repeat-count",
|
|
type=click.IntRange(min=1),
|
|
default=None,
|
|
help="For scheduled: max run count.",
|
|
)
|
|
@click.option(
|
|
"--channel",
|
|
default=None,
|
|
help="Delivery channel.",
|
|
)
|
|
@click.option(
|
|
"--target-user",
|
|
default=None,
|
|
help="Target user_id.",
|
|
)
|
|
@click.option(
|
|
"--target-session",
|
|
default=None,
|
|
help="Target session_id.",
|
|
)
|
|
@click.option(
|
|
"--text",
|
|
default=None,
|
|
help="Text content or agent prompt.",
|
|
)
|
|
@click.option(
|
|
"--timezone",
|
|
default=None,
|
|
help="Timezone for the schedule.",
|
|
)
|
|
@click.option(
|
|
"--enabled/--no-enabled",
|
|
default=None,
|
|
help="Enable or disable the job.",
|
|
)
|
|
@click.option(
|
|
"--mode",
|
|
type=click.Choice(["stream", "final"], case_sensitive=False),
|
|
default=None,
|
|
help="Delivery mode: 'stream' or 'final'.",
|
|
)
|
|
@click.option(
|
|
"--silent/--no-silent",
|
|
default=None,
|
|
help="Run an agent task without channel delivery.",
|
|
)
|
|
@click.option(
|
|
"--save-result-to-inbox/--no-save-result-to-inbox",
|
|
default=None,
|
|
help="Save execution results to Inbox.",
|
|
)
|
|
@click.option(
|
|
"--share-session/--no-share-session",
|
|
default=None,
|
|
help="Share session with target user.",
|
|
)
|
|
@click.option(
|
|
"--timeout",
|
|
"timeout_seconds",
|
|
type=click.IntRange(min=1),
|
|
default=None,
|
|
help="Maximum execution time in seconds.",
|
|
)
|
|
@click.option(
|
|
"--tool-safety/--no-tool-safety",
|
|
default=None,
|
|
help=(
|
|
"Tool execution safety check. When enabled, risky tool calls "
|
|
"require approval. When disabled, all tools execute without approval."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--base-url",
|
|
default=None,
|
|
help="Override the API base URL.",
|
|
)
|
|
@click.option(
|
|
"--agent-id",
|
|
default="default",
|
|
help="Agent ID (defaults to 'default')",
|
|
)
|
|
@click.pass_context
|
|
def update_job(
|
|
ctx: click.Context,
|
|
job_id: str,
|
|
file_: Optional[Path],
|
|
task_type: Optional[str],
|
|
schedule_type: Optional[str],
|
|
name: Optional[str],
|
|
cron: Optional[str],
|
|
run_at: Optional[str],
|
|
repeat_every_days: Optional[int],
|
|
repeat_end_type: Optional[str],
|
|
repeat_until: Optional[str],
|
|
repeat_count: Optional[int],
|
|
channel: Optional[str],
|
|
target_user: Optional[str],
|
|
target_session: Optional[str],
|
|
text: Optional[str],
|
|
timezone: Optional[str],
|
|
enabled: Optional[bool],
|
|
mode: Optional[str],
|
|
silent: Optional[bool],
|
|
save_result_to_inbox: Optional[bool],
|
|
share_session: Optional[bool],
|
|
timeout_seconds: Optional[int],
|
|
tool_safety: Optional[bool],
|
|
base_url: Optional[str],
|
|
agent_id: str,
|
|
) -> None:
|
|
"""Update an existing cron job.
|
|
|
|
Either pass -f/--file with a complete JSON spec to replace the
|
|
job entirely, or specify individual options to override specific
|
|
fields. Unspecified options keep their current values.
|
|
"""
|
|
base_url = _base_url(ctx, base_url)
|
|
|
|
# Fetch the existing job first so we can merge partial updates
|
|
with client(base_url) as c:
|
|
headers = {"X-Agent-Id": agent_id}
|
|
r = c.get(f"/cron/jobs/{job_id}", headers=headers)
|
|
if r.status_code == 404:
|
|
raise click.ClickException("Job not found.")
|
|
r.raise_for_status()
|
|
existing = r.json()
|
|
|
|
if file_ is not None:
|
|
payload = json.loads(file_.read_text(encoding="utf-8"))
|
|
else:
|
|
payload = _resolve_update_spec(
|
|
spec=existing.get("spec", existing),
|
|
task_type=task_type,
|
|
schedule_type=schedule_type,
|
|
name=name,
|
|
cron=cron,
|
|
run_at=run_at,
|
|
repeat_every_days=repeat_every_days,
|
|
repeat_end_type=repeat_end_type,
|
|
repeat_until=repeat_until,
|
|
repeat_count=repeat_count,
|
|
channel=channel,
|
|
target_user=target_user,
|
|
target_session=target_session,
|
|
text=text,
|
|
timezone=timezone,
|
|
enabled=enabled,
|
|
mode=mode,
|
|
silent=silent,
|
|
save_result_to_inbox=save_result_to_inbox,
|
|
share_session=share_session,
|
|
timeout_seconds=timeout_seconds,
|
|
tool_safety=tool_safety,
|
|
)
|
|
|
|
payload["id"] = job_id
|
|
|
|
with client(base_url) as c:
|
|
headers = {"X-Agent-Id": agent_id}
|
|
r = c.put(f"/cron/jobs/{job_id}", json=payload, headers=headers)
|
|
r.raise_for_status()
|
|
print_json(r.json())
|
|
|
|
|
|
@cron_group.command("delete")
|
|
@click.argument("job_id", metavar="JOB_ID")
|
|
@click.option(
|
|
"--base-url",
|
|
default=None,
|
|
help="Override the API base URL. Defaults to global --host/--port.",
|
|
)
|
|
@click.option(
|
|
"--agent-id",
|
|
default="default",
|
|
help="Agent ID (defaults to 'default')",
|
|
)
|
|
@click.pass_context
|
|
def delete_job(
|
|
ctx: click.Context,
|
|
job_id: str,
|
|
base_url: Optional[str],
|
|
agent_id: str,
|
|
) -> None:
|
|
"""Permanently delete a cron job. The job is removed from the server."""
|
|
base_url = _base_url(ctx, base_url)
|
|
with client(base_url) as c:
|
|
headers = {"X-Agent-Id": agent_id}
|
|
r = c.delete(f"/cron/jobs/{job_id}", headers=headers)
|
|
if r.status_code == 404:
|
|
raise click.ClickException("Job not found.")
|
|
r.raise_for_status()
|
|
print_json(r.json())
|
|
|
|
|
|
@cron_group.command("pause")
|
|
@click.argument("job_id", metavar="JOB_ID")
|
|
@click.option(
|
|
"--base-url",
|
|
default=None,
|
|
help="Override the API base URL. Defaults to global --host/--port.",
|
|
)
|
|
@click.option(
|
|
"--agent-id",
|
|
default="default",
|
|
help="Agent ID (defaults to 'default')",
|
|
)
|
|
@click.pass_context
|
|
def pause_job(
|
|
ctx: click.Context,
|
|
job_id: str,
|
|
base_url: Optional[str],
|
|
agent_id: str,
|
|
) -> None:
|
|
"""Pause a cron job so it no longer runs on schedule.
|
|
Use 'resume' to re-enable.
|
|
"""
|
|
base_url = _base_url(ctx, base_url)
|
|
with client(base_url) as c:
|
|
headers = {"X-Agent-Id": agent_id}
|
|
r = c.post(f"/cron/jobs/{job_id}/pause", headers=headers)
|
|
if r.status_code == 404:
|
|
raise click.ClickException("Job not found.")
|
|
r.raise_for_status()
|
|
print_json(r.json())
|
|
|
|
|
|
@cron_group.command("resume")
|
|
@click.argument("job_id", metavar="JOB_ID")
|
|
@click.option(
|
|
"--base-url",
|
|
default=None,
|
|
help="Override the API base URL. Defaults to global --host/--port.",
|
|
)
|
|
@click.option(
|
|
"--agent-id",
|
|
default="default",
|
|
help="Agent ID (defaults to 'default')",
|
|
)
|
|
@click.pass_context
|
|
def resume_job(
|
|
ctx: click.Context,
|
|
job_id: str,
|
|
base_url: Optional[str],
|
|
agent_id: str,
|
|
) -> None:
|
|
"""Resume a paused cron job so it runs again on its schedule."""
|
|
base_url = _base_url(ctx, base_url)
|
|
with client(base_url) as c:
|
|
headers = {"X-Agent-Id": agent_id}
|
|
r = c.post(f"/cron/jobs/{job_id}/resume", headers=headers)
|
|
if r.status_code == 404:
|
|
raise click.ClickException("Job not found.")
|
|
r.raise_for_status()
|
|
print_json(r.json())
|
|
|
|
|
|
@cron_group.command("run")
|
|
@click.argument("job_id", metavar="JOB_ID")
|
|
@click.option(
|
|
"--base-url",
|
|
default=None,
|
|
help="Override the API base URL. Defaults to global --host/--port.",
|
|
)
|
|
@click.option(
|
|
"--agent-id",
|
|
default="default",
|
|
help="Agent ID (defaults to 'default')",
|
|
)
|
|
@click.pass_context
|
|
def run_job(
|
|
ctx: click.Context,
|
|
job_id: str,
|
|
base_url: Optional[str],
|
|
agent_id: str,
|
|
) -> None:
|
|
"""Trigger a one-off run of a cron job immediately (ignores schedule)."""
|
|
base_url = _base_url(ctx, base_url)
|
|
with client(base_url) as c:
|
|
headers = {"X-Agent-Id": agent_id}
|
|
r = c.post(f"/cron/jobs/{job_id}/run", headers=headers)
|
|
if r.status_code == 404:
|
|
raise click.ClickException("Job not found.")
|
|
r.raise_for_status()
|
|
print_json(r.json())
|