#!/usr/bin/env bash # bg-model-call — Submit a prompt to a third-party model via the agent-runtimes # control plane, wait for completion, and write the result to a file. # # Designed for use with the ScheduleWakeup polling pattern: run this script in # the background; your wakeup handler checks for the .done sentinel. # # See specs/bg-model-call.spec.md for the full spec. set -uo pipefail # --------------------------------------------------------------------------- # Model → template mapping (override with --template) # --------------------------------------------------------------------------- declare -A MODEL_TEMPLATES=( [minimax]="minimax-planning" [sonnet]="sonnet-code-repo" [haiku]="haiku-code-repo" [opus]="opus-code-repo" ) # --------------------------------------------------------------------------- # Colour output helpers # --------------------------------------------------------------------------- RED='\033[0;31m' YELLOW='\033[0;33m' CYAN='\033[0;36m' NC='\033[0m' info() { printf "${CYAN}[bg-model-call]${NC} %s\n" "$*" >&2; } warn() { printf "${YELLOW}[bg-model-call] WARNING:${NC} %s\n" "$*" >&2; } error() { printf "${RED}[bg-model-call] ERROR:${NC} %s\n" "$*" >&2; } # --------------------------------------------------------------------------- # Usage # --------------------------------------------------------------------------- usage() { cat <<'EOF' bg-model-call — Submit a prompt to a model via the agent-runtimes control plane Usage: bg-model-call --model --prompt --output [OPTIONS] Required: --model Model alias: minimax, sonnet, haiku, opus --prompt Path to file containing the prompt text --output Path where the result will be written Optional: --template Override the task template (without .yaml) --project Project ID for CP grouping (default: bg-model-call) --task-name Task name shown in agent-monitor (default: bg-model-call-) --url Control plane URL (default: $CP_URL or http://localhost:8100) --timeout Max seconds to wait for task (default: 1800) --poll-interval Seconds between polls (default: 10) --dryrun, -n Print what would be submitted without calling the CP --help, -h Show this help and exit Model-to-template mapping: minimax → minimax-planning sonnet → sonnet-code-repo haiku → haiku-code-repo opus → opus-code-repo The .done sentinel (.done) is written on success — poll for this file from a ScheduleWakeup handler. EOF } # --------------------------------------------------------------------------- # Argument defaults # --------------------------------------------------------------------------- ARG_MODEL="" ARG_TEMPLATE="" ARG_PROMPT="" ARG_OUTPUT="" ARG_PROJECT="bg-model-call" ARG_TASK_NAME="" ARG_URL="" ARG_TIMEOUT="1800" ARG_POLL_INTERVAL="10" DRYRUN=0 # --------------------------------------------------------------------------- # Parse arguments # --------------------------------------------------------------------------- while [[ $# -gt 0 ]]; do case "$1" in --model) ARG_MODEL="${2:?--model requires a value}" shift 2 ;; --template) ARG_TEMPLATE="${2:?--template requires a value}" shift 2 ;; --prompt) ARG_PROMPT="${2:?--prompt requires a value}" shift 2 ;; --output) ARG_OUTPUT="${2:?--output requires a value}" shift 2 ;; --project) ARG_PROJECT="${2:?--project requires a value}" shift 2 ;; --task-name) ARG_TASK_NAME="${2:?--task-name requires a value}" shift 2 ;; --url) ARG_URL="${2:?--url requires a value}" shift 2 ;; --timeout) ARG_TIMEOUT="${2:?--timeout requires a value}" shift 2 ;; --poll-interval) ARG_POLL_INTERVAL="${2:?--poll-interval requires a value}" shift 2 ;; --dryrun|-n) DRYRUN=1 shift ;; --help|-h) usage exit 0 ;; *) error "Unknown flag: $1" usage exit 1 ;; esac done # --------------------------------------------------------------------------- # Validate required args # --------------------------------------------------------------------------- if [[ -z "$ARG_PROMPT" ]]; then error "--prompt is required" exit 1 fi if [[ -z "$ARG_OUTPUT" ]]; then error "--output is required" exit 1 fi if [[ -z "$ARG_MODEL" && -z "$ARG_TEMPLATE" ]]; then error "--model is required (or use --template to specify a template directly)" exit 1 fi # Validate numeric args if ! [[ "$ARG_TIMEOUT" =~ ^[1-9][0-9]*$ ]]; then error "--timeout must be a positive integer, got: $ARG_TIMEOUT" exit 1 fi if ! [[ "$ARG_POLL_INTERVAL" =~ ^[1-9][0-9]*$ ]]; then error "--poll-interval must be a positive integer, got: $ARG_POLL_INTERVAL" exit 1 fi # --------------------------------------------------------------------------- # Resolve values # --------------------------------------------------------------------------- # Resolve CP URL: --url flag → $CP_URL → default if [[ -n "$ARG_URL" ]]; then CP_URL_RESOLVED="$ARG_URL" elif [[ -n "${CP_URL:-}" ]]; then CP_URL_RESOLVED="$CP_URL" else CP_URL_RESOLVED="http://localhost:8100" fi # Resolve template if [[ -n "$ARG_TEMPLATE" ]]; then RESOLVED_TEMPLATE="$ARG_TEMPLATE" elif [[ -n "$ARG_MODEL" ]]; then if [[ -z "${MODEL_TEMPLATES[$ARG_MODEL]:-}" ]]; then error "Unknown model alias '$ARG_MODEL'. Valid values: ${!MODEL_TEMPLATES[*]}" exit 1 fi RESOLVED_TEMPLATE="${MODEL_TEMPLATES[$ARG_MODEL]}" fi # Resolve task name if [[ -n "$ARG_TASK_NAME" ]]; then RESOLVED_TASK_NAME="$ARG_TASK_NAME" elif [[ -n "$ARG_MODEL" ]]; then RESOLVED_TASK_NAME="bg-model-call-${ARG_MODEL}" else RESOLVED_TASK_NAME="bg-model-call-${RESOLVED_TEMPLATE}" fi # Validate prompt file (must be readable even in dryrun) if [[ ! -f "$ARG_PROMPT" ]]; then error "Cannot read prompt file: $ARG_PROMPT" exit 1 fi PROMPT_SIZE=$(wc -c < "$ARG_PROMPT") # Validate output directory exists OUTPUT_DIR="$(dirname "$ARG_OUTPUT")" if [[ ! -d "$OUTPUT_DIR" ]]; then error "Output directory does not exist: $OUTPUT_DIR" exit 1 fi # --------------------------------------------------------------------------- # Locate dispatch-task and wait-for-tasks CLIs # --------------------------------------------------------------------------- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" AR_SCRIPTS="${SCRIPT_DIR}/../../../projects/agent-runtimes/scripts" find_cli() { local name="$1" if command -v "$name" &>/dev/null; then command -v "$name" elif [[ -x "${AR_SCRIPTS}/${name}" ]]; then echo "${AR_SCRIPTS}/${name}" else echo "" fi } DISPATCH_TASK_BIN="$(find_cli dispatch-task)" WAIT_FOR_TASKS_BIN="$(find_cli wait-for-tasks)" # --------------------------------------------------------------------------- # Dryrun: print resolved values and exit # --------------------------------------------------------------------------- if [[ "$DRYRUN" -eq 1 ]]; then echo "[dryrun] bg-model-call — no changes will be made" echo " CP URL: ${CP_URL_RESOLVED}" if [[ -n "$ARG_MODEL" ]]; then echo " Model: ${ARG_MODEL}" fi echo " Template: ${RESOLVED_TEMPLATE}" echo " Task name: ${RESOLVED_TASK_NAME}" echo " Project ID: ${ARG_PROJECT}" echo " Prompt file: ${ARG_PROMPT}" echo " Prompt size: ${PROMPT_SIZE} bytes" echo " Output file: ${ARG_OUTPUT}" echo " Sentinel: ${ARG_OUTPUT}.done" echo " Timeout: ${ARG_TIMEOUT}s" echo " Poll interval: ${ARG_POLL_INTERVAL}s" if [[ -n "$DISPATCH_TASK_BIN" ]]; then echo " dispatch-task: ${DISPATCH_TASK_BIN}" else echo " dispatch-task: not found (will use curl fallback)" fi if [[ -n "$WAIT_FOR_TASKS_BIN" ]]; then echo " wait-for-tasks: ${WAIT_FOR_TASKS_BIN}" else echo " wait-for-tasks: not found (will use curl polling fallback)" fi exit 0 fi # --------------------------------------------------------------------------- # Helper: write failure sentinel and exit 1 # --------------------------------------------------------------------------- write_failure() { local task_id="${1:-unknown}" state="${2:-failed}" detail="${3:-}" { echo "task_id=${task_id}" echo "state=${state}" if [[ -n "$detail" ]]; then echo "error=${detail}" fi } > "${ARG_OUTPUT}.failed" # Remove tracking file if it exists if [[ -n "${task_id}" && "${task_id}" != "unknown" ]]; then rm -f "${OUTPUT_DIR}/.bg-model-call-${task_id}" fi error "Task ${task_id} ${state}. See ${ARG_OUTPUT}.failed" exit 1 } # --------------------------------------------------------------------------- # Submit task # --------------------------------------------------------------------------- info "Submitting task (model=${ARG_MODEL:-via-template}, template=${RESOLVED_TEMPLATE})" submit_via_dispatch_task() { "$DISPATCH_TASK_BIN" \ --name "$RESOLVED_TASK_NAME" \ --project-id "$ARG_PROJECT" \ --prompt-file "$ARG_PROMPT" \ --template "$RESOLVED_TEMPLATE" \ --cp-url "$CP_URL_RESOLVED" \ --format json \ --skip-tag-check 2>/dev/null } submit_via_curl() { local prompt_text prompt_text="$(cat "$ARG_PROMPT")" local payload payload="$(printf '{"name":"%s","project_id":"%s","prompt":%s,"runtime":{"model":"%s","timeout":%s}}' \ "$RESOLVED_TASK_NAME" \ "$ARG_PROJECT" \ "$(printf '%s' "$prompt_text" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" \ "${ARG_MODEL:-${RESOLVED_TEMPLATE}}" \ "$ARG_TIMEOUT")" curl -s -X POST "${CP_URL_RESOLVED}/tasks" \ -H "Content-Type: application/json" \ -d "$payload" } SUBMIT_RESPONSE="" if [[ -n "$DISPATCH_TASK_BIN" ]]; then SUBMIT_RESPONSE="$(submit_via_dispatch_task 2>&1)" || true else warn "dispatch-task not found; using curl fallback" SUBMIT_RESPONSE="$(submit_via_curl 2>&1)" || true fi # Extract task_id from JSON response TASK_ID="$(printf '%s' "$SUBMIT_RESPONSE" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("task_id",""))' 2>/dev/null || true)" if [[ -z "$TASK_ID" ]]; then error "Failed to submit task. Response: ${SUBMIT_RESPONSE}" write_failure "unknown" "failed" "submission failed: ${SUBMIT_RESPONSE}" fi info "Task submitted: ${TASK_ID}" # Write tracking file { echo "task_id=${TASK_ID}" echo "model=${ARG_MODEL:-}" echo "template=${RESOLVED_TEMPLATE}" echo "submitted_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" } > "${OUTPUT_DIR}/.bg-model-call-${TASK_ID}" # --------------------------------------------------------------------------- # Wait for task completion # --------------------------------------------------------------------------- info "Waiting for task ${TASK_ID} (timeout=${ARG_TIMEOUT}s, poll=${ARG_POLL_INTERVAL}s)" FINAL_STATE="" wait_via_wait_for_tasks() { "$WAIT_FOR_TASKS_BIN" \ --url "$CP_URL_RESOLVED" \ --timeout "$ARG_TIMEOUT" \ --interval "$ARG_POLL_INTERVAL" \ --format text \ "$TASK_ID" >/dev/null 2>&1 # After wait-for-tasks exits, fetch the final state local state_json state_json="$(curl -s "${CP_URL_RESOLVED}/tasks/${TASK_ID}")" printf '%s' "$state_json" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("state","unknown"))' 2>/dev/null || echo "unknown" } wait_via_curl_poll() { local elapsed=0 local state="pending" local terminal_states="succeeded failed timed_out cancelled" while true; do local response response="$(curl -s "${CP_URL_RESOLVED}/tasks/${TASK_ID}" 2>/dev/null)" || true state="$(printf '%s' "$response" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("state","unknown"))' 2>/dev/null || echo "unknown")" info " state=${state} (${elapsed}s elapsed)" # Check if terminal for ts in $terminal_states; do if [[ "$state" == "$ts" ]]; then echo "$state" return 0 fi done # Check timeout if [[ "$elapsed" -ge "$ARG_TIMEOUT" ]]; then echo "timed_out" return 0 fi sleep "$ARG_POLL_INTERVAL" elapsed=$((elapsed + ARG_POLL_INTERVAL)) done } if [[ -n "$WAIT_FOR_TASKS_BIN" ]]; then FINAL_STATE="$(wait_via_wait_for_tasks)" else warn "wait-for-tasks not found; using curl polling fallback" FINAL_STATE="$(wait_via_curl_poll)" fi info "Task ${TASK_ID} reached state: ${FINAL_STATE}" # --------------------------------------------------------------------------- # Handle terminal state # --------------------------------------------------------------------------- if [[ "$FINAL_STATE" != "succeeded" ]]; then write_failure "$TASK_ID" "$FINAL_STATE" "task did not succeed" fi # --------------------------------------------------------------------------- # Fetch logs and write output # --------------------------------------------------------------------------- info "Fetching task logs..." LOGS_RESPONSE="$(curl -s "${CP_URL_RESOLVED}/tasks/${TASK_ID}/logs" 2>/dev/null)" || true if [[ -z "$LOGS_RESPONSE" ]]; then warn "Empty logs response from CP; output file will be empty" fi printf '%s\n' "$LOGS_RESPONSE" > "$ARG_OUTPUT" # Write .done sentinel { echo "task_id=${TASK_ID}" echo "completed_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" } > "${ARG_OUTPUT}.done" # Remove tracking file rm -f "${OUTPUT_DIR}/.bg-model-call-${TASK_ID}" info "Done. Result written to ${ARG_OUTPUT}" info "Sentinel written to ${ARG_OUTPUT}.done" exit 0