diff options
Diffstat (limited to 'skills/pi-subagent/run-agent.sh')
| -rwxr-xr-x | skills/pi-subagent/run-agent.sh | 189 |
1 files changed, 189 insertions, 0 deletions
diff --git a/skills/pi-subagent/run-agent.sh b/skills/pi-subagent/run-agent.sh new file mode 100755 index 0000000..6312eb3 --- /dev/null +++ b/skills/pi-subagent/run-agent.sh @@ -0,0 +1,189 @@ +#!/bin/bash +# Run one pi subagent, headless, to completion. +# +# run-agent.sh [options] <workdir> <provider> <model> <name> +# +# Options: +# --brief FILE task brief to hand the subagent (default: <workdir>/brief.md) +# --timeout SECS hard wall-clock limit (default: 1800) +# --thinking LEVEL off|minimal|low|medium|high|xhigh|max (default: high) +# --tools LIST pi tool allowlist, e.g. read,grep,find,ls for a subagent +# that must not run or change anything (default: pi's own) +# --cwd DIR directory the subagent runs in (default: the current one) +# --context-files let pi load AGENTS.md/CLAUDE.md from --cwd (default: off) +# --deliver TEXT what the final answer must be (default: "your complete +# result, as markdown") +# +# Writes, under <workdir>/out/: +# <name>.md the subagent's final answer: text of its last message +# <name>.cost.json tokens, seconds, exit status, USD cost from the provider +# <name>.jsonl the event stream without per-token deltas, for debugging +# <name>.err pi's stderr +# <name>.running exists only while the run is in flight; holds the PID +# +# A previous run under the same name is never overwritten: its files are moved +# to <name>.attempt<N>.* first, so a retry keeps the bill of the failure. +# +# Exit status is pi's, except that a run pi calls a success is turned into 1 +# when its answer is empty or its last message ended in a provider error: pi +# exits 0 for both (an invalid API key, for one), and the cost file records +# the stop reason and error. 124 means the timeout fired -- the run is a +# write-off, but it still cost money; the cost file is written either way. +# +# Six mechanics here were each established the hard way. Do not "simplify" +# them away: +# +# 1. `--offline` is required. pi's startup model-catalog fetch intermittently +# hangs forever; --offline skips it. Symptom without it: the process +# produces no output at all, not even the session line, and never exits. +# +# 2. pi's stdout must be redirected to a FILE, never piped. A pipeline +# deadlocks: `timeout` kills pi, but the downstream reader never sees EOF +# and hangs past its own timeout. This is why filtering happens after the +# run, not inline. +# +# 3. The raw stream is filtered and deleted at once. Older pi re-emitted the +# entire message on every token delta, so it grew quadratically -- 790 MB +# and 3.1 GB on real runs. pi 1.x documents message_update as delta-only, +# but the stream is still mostly deltas nobody reads. Budget generous +# scratch per concurrent subagent until that is confirmed on the version +# in use. +# +# 4. Costs must be summed over every assistant message, not read off the last +# one. Each message carries only its own usage. +# +# 5. pi's stdin must be /dev/null. In -p mode pi also reads stdin when it is +# not a terminal, and waits for EOF. Started from a shell whose stdin never +# closes -- an agent's tool shell, a backgrounded job -- it hangs forever +# with no output at all, which looks exactly like the hang in (1). +# +# 6. Byte counts are stripped of whitespace before jq reads them. macOS `wc -c` +# pads its output (" 6"), jq's tonumber rejects that, and the whole +# cost file silently came out empty. +# +# `timeout` is GNU coreutils and macOS has none unless coreutils is installed, +# where it may be `gtimeout`. Without either, perl's alarm does the same job: it +# survives exec, and SIGALRM ends pi. That exit (142) is reported as 124. +# +# Extensions and skills are always off (-ne -ns): an extension can block on UI +# that a headless run has no way to answer, and the brief should say everything +# the subagent needs to know. + +set -u + +brief=""; limit=1800; thinking=high; tools=""; cwd=""; context="" +deliver="your complete result, as markdown" +while [[ $# -gt 0 ]] +do + case "$1" in + --brief) brief="$2"; shift 2 ;; + --timeout) limit="$2"; shift 2 ;; + --thinking) thinking="$2"; shift 2 ;; + --tools) tools="$2"; shift 2 ;; + --cwd) cwd="$2"; shift 2 ;; + --context-files) context=y; shift ;; + --deliver) deliver="$2"; shift 2 ;; + -h|--help) sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + --) shift; break ;; + -*) echo "run-agent.sh: unknown option $1" >&2; exit 2 ;; + *) break ;; + esac +done + +P="${1:?workdir}"; provider="${2:?provider}"; model="${3:?model}"; name="${4:?name}" +P="$(cd "$P" && pwd)" || exit 2 +brief="${brief:-$P/brief.md}" +[[ "$brief" == /* ]] || brief="$PWD/$brief" +[[ -s "$brief" ]] || { echo "run-agent.sh: no brief at $brief" >&2; exit 2; } + +out="$P/out" +mkdir -p "$out" + +# Keep any earlier attempt, cost file included. +if [[ -e "$out/$name.cost.json" || -e "$out/$name.md" || -e "$out/$name.err" ]] +then + n=1 + while [[ -e "$out/$name.attempt$n.cost.json" || -e "$out/$name.attempt$n.err" ]] + do + n=$((n + 1)) + done + for ext in md cost.json jsonl err + do + [[ -e "$out/$name.$ext" ]] && mv "$out/$name.$ext" "$out/$name.attempt$n.$ext" + done + if [[ -e "$out/$name.attempt$n.cost.json" ]] + then + tmp="$(mktemp)" + jq --arg a "$name.attempt$n" '.name = $a' "$out/$name.attempt$n.cost.json" > "$tmp" \ + && mv "$tmp" "$out/$name.attempt$n.cost.json" + fi +fi + +args=(--provider "$provider" --model "$model" + -p --no-session -ne -ns --offline + --thinking "$thinking" --mode json) +[[ -n "$context" ]] || args+=(-nc) +[[ -n "$tools" ]] && args+=(--tools "$tools") + +if command -v timeout > /dev/null; then limiter=(timeout "$limit") +elif command -v gtimeout > /dev/null; then limiter=(gtimeout "$limit") +else limiter=(perl -e 'alarm shift; exec @ARGV or die "exec: $!\n"' "$limit") +fi + +echo $$ > "$out/$name.running" +trap 'rm -f "$out/$name.running"' EXIT +start=$SECONDS + +( + if [[ -n "$cwd" ]]; then cd "$cwd" || exit 2; fi + exec "${limiter[@]}" pi "${args[@]}" \ + "Read the file $brief and do exactly what it says. Deliver $deliver as your final answer." \ + < /dev/null > "$out/$name.raw" 2> "$out/$name.err" +) +rc=$? +[[ $rc -eq 142 && "${limiter[0]}" == perl ]] && rc=124 +elapsed=$((SECONDS - start)) + +raw_bytes=$(wc -c < "$out/$name.raw" | tr -d '[:space:]') +grep -v '"type":"message_update"' "$out/$name.raw" > "$out/$name.jsonl" +rm -f "$out/$name.raw" + +# The deliverable: text blocks of the last assistant message. +jq -rs ' + [.[] | select(.type=="agent_end")] | last | .messages + | [.[] | select(.role=="assistant")] | last + | [.content[] | select(.type=="text") | .text] | join("") +' "$out/$name.jsonl" > "$out/$name.md" 2>/dev/null + +# pi exits 0 when the provider refused the request, or the model said nothing. +stop=$(jq -rs '[.[] | select(.type=="message_end" and .message.role=="assistant")] + | last | .message.stopReason // "none"' "$out/$name.jsonl" 2>/dev/null) +if [[ $rc -eq 0 ]] && { [[ "$stop" == error || "$stop" == none ]] || ! grep -q '[^[:space:]]' "$out/$name.md"; } +then + rc=1 +fi + +jq -rs --arg name "$name" --arg model "$model" --arg provider "$provider" \ + --arg rc "$rc" --arg elapsed "$elapsed" --arg raw "$raw_bytes" ' + [.[] | select(.type=="message_end" and .message.role=="assistant") | .message] as $msgs + | ($msgs | last) as $last + | ($msgs | map(.usage)) + | {name: $name, provider: $provider, model: $model, exit: ($rc|tonumber), + stopReason: ($last.stopReason // null), + error: ($last.errorMessage // null | if . then .[0:500] else . end), + seconds: ($elapsed|tonumber), turns: length, + input: (map(.input) | add // 0), output: (map(.output) | add // 0), + cacheRead: (map(.cacheRead) | add // 0), + cacheWrite: (map(.cacheWrite) | add // 0), + rawStreamBytes: ($raw|tonumber), + cost: (map(.cost.total) | add // 0)} +' "$out/$name.jsonl" > "$out/$name.cost.json" 2>/dev/null + +printf '%-20s exit:%-4s stop:%-8s %5ss answer:%7s bytes cost:$%s\n' \ + "$name" "$rc" "$stop" "$elapsed" "$(wc -c < "$out/$name.md" | tr -d '[:space:]')" \ + "$(jq -r '.cost' "$out/$name.cost.json" 2>/dev/null)" +if [[ $rc -ne 0 ]] +then + jq -r '.error // empty' "$out/$name.cost.json" 2>/dev/null | head -c 300; echo +fi +exit $rc |
