summaryrefslogtreecommitdiff
path: root/skills/pi-subagent/run-agent.sh
diff options
context:
space:
mode:
Diffstat (limited to 'skills/pi-subagent/run-agent.sh')
-rwxr-xr-xskills/pi-subagent/run-agent.sh189
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