curator-tools.ts registers no schema of its own: it fetches the specs from the
backend bridge, so contracts.py stays the single owner and there is no TypeScript
copy to drift. It refuses to activate without a bridge URL and token, because an
agent that silently loses its tools still answers -- from the model's memory of
what a media library might contain.
deploy-scenario.sh now vendors listed shared/extensions modules into
.pi/extensions/_shared/. A tracked extension importing from shared/ cannot
resolve that path once installed outside the repository, so the deployed tree has
to be self-contained; this overwrites rather than merges, keeping the repository
authoritative. common.sh gains toml_list, using tomllib rather than more awk
because an array can span lines or carry comments.
verify-generated.sh checks that generated regions in tracked prompts match the
backend that generates them, and is wired into the pre-commit hook. This is
needed because of finding 22 below: the tool list has to be copied into the
prompt, and a copy drifts silently.
Two findings recorded in docs/pi-runtime-notes.md, both measured:
21. An extension that fails to import is silent -- exit 0, empty stderr, no
tools. A missing --extension path exits 1 with a clear message, but a
module that throws while loading reports nothing. The agent then invented a
complete library listing with plausible episode counts, quality and size.
A later identical run said it had no data instead, so the failure is both
silent and inconsistent.
22. --system-prompt suppresses the tool list. The customPrompt branch returns
before toolsList and guidelines are built, so promptSnippet and
promptGuidelines are inert. The tools stay callable over the provider API,
so tool use becomes a coin flip: one run in four looked at the library and
three said they had not been given any results.
SYSTEM.phase3.md is staged alongside the deployed SYSTEM.md rather than replacing
it: profile.toml still describes the phase-0 configuration that is actually
running, and the live service is untouched.
216 lines
7.8 KiB
Bash
Executable File
216 lines
7.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# ---------------------------------------------------------------------------
|
|
# Install a scenario's tracked workspace into its live location, and render the
|
|
# launch contract from profile.toml.
|
|
#
|
|
# Dry run by default. Never restarts a service: restarting is a decision about
|
|
# when to interrupt a live conversation, and belongs to the operator.
|
|
#
|
|
# Usage:
|
|
# scripts/deploy-scenario.sh <scenario> # show what would change
|
|
# scripts/deploy-scenario.sh <scenario> --apply
|
|
# scripts/deploy-scenario.sh --list
|
|
# ---------------------------------------------------------------------------
|
|
# shellcheck source=lib/common.sh
|
|
. "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh"
|
|
|
|
if [ "${1:-}" = "--list" ]; then
|
|
list_scenarios
|
|
exit 0
|
|
fi
|
|
|
|
NAME="${1:-}"
|
|
APPLY=0
|
|
[ "${2:-}" = "--apply" ] && APPLY=1
|
|
|
|
DIR="$(require_scenario "$NAME")"
|
|
PROFILE="$DIR/profile.toml"
|
|
[ -f "$PROFILE" ] || die "scenario '$NAME' has no profile.toml"
|
|
|
|
TRACKED="$(toml_get "$PROFILE" scenario tracked)"
|
|
if [ "$TRACKED" = "false" ]; then
|
|
die "scenario '$NAME' is registered but not tracked; nothing to deploy"
|
|
fi
|
|
|
|
# A scenario is either an authoritative source ("managed") or a record of what
|
|
# the live host already does ("mirror"). Applying a mirror would invert the
|
|
# direction of truth and, for a service in daily use, risks a behaviour change
|
|
# that nobody asked for. Mirrors are diffable but not deployable.
|
|
MODE="$(toml_get "$PROFILE" scenario deploy)"
|
|
MODE="${MODE:-managed}"
|
|
if [ "$MODE" = "mirror" ] && [ "$APPLY" -eq 1 ]; then
|
|
die "scenario '$NAME' is a mirror of the live configuration, not an authoritative source.
|
|
Refusing --apply. Use scripts/pi-diff.sh $NAME to compare, and promote the
|
|
scenario to deploy = \"managed\" in profile.toml once it is the source of truth."
|
|
fi
|
|
|
|
WORKSPACE="$(toml_get "$PROFILE" scenario workspace)"
|
|
[ -n "$WORKSPACE" ] || die "scenario '$NAME': [scenario].workspace is not set"
|
|
[ -d "$DIR/workspace" ] || die "scenario '$NAME' has no tracked workspace/"
|
|
|
|
head1 "deploy $NAME -> $WORKSPACE"
|
|
[ "$APPLY" -eq 1 ] || info "${C_DIM}(dry run; pass --apply to write)${C_OFF}"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Workspace files
|
|
# ---------------------------------------------------------------------------
|
|
CHANGES=0
|
|
while IFS= read -r rel; do
|
|
src="$DIR/workspace/$rel"
|
|
dst="$WORKSPACE/$rel"
|
|
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
|
|
continue
|
|
fi
|
|
CHANGES=$((CHANGES + 1))
|
|
if [ -f "$dst" ]; then
|
|
info " update $rel"
|
|
else
|
|
info " create $rel"
|
|
fi
|
|
if [ "$APPLY" -eq 1 ]; then
|
|
install -d -m 700 "$(dirname "$dst")"
|
|
install -m 600 "$src" "$dst"
|
|
fi
|
|
done < <(cd "$DIR/workspace" && find . -type f -printf '%P\n' | sort)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1b. Vendored shared extensions
|
|
#
|
|
# A tracked extension that imports from shared/extensions/ cannot resolve that
|
|
# path once installed outside the repository. The listed modules are copied into
|
|
# .pi/extensions/_shared/ so the deployed tree is self-contained, while the
|
|
# repository stays the single source of truth: this overwrites, never merges.
|
|
# ---------------------------------------------------------------------------
|
|
SHARED="$(toml_list "$PROFILE" resources shared_extensions)"
|
|
if [ -n "$SHARED" ]; then
|
|
while IFS= read -r mod; do
|
|
[ -n "$mod" ] || continue
|
|
src="$REPO_ROOT/shared/extensions/$mod"
|
|
[ -f "$src" ] || die "profile lists shared extension '$mod', which does not exist"
|
|
dst="$WORKSPACE/.pi/extensions/_shared/$mod"
|
|
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
|
|
continue
|
|
fi
|
|
CHANGES=$((CHANGES + 1))
|
|
if [ -f "$dst" ]; then info " update .pi/extensions/_shared/$mod"
|
|
else info " vendor .pi/extensions/_shared/$mod"; fi
|
|
if [ "$APPLY" -eq 1 ]; then
|
|
install -d -m 700 "$(dirname "$dst")"
|
|
install -m 600 "$src" "$dst"
|
|
fi
|
|
done < <(printf '%s\n' "$SHARED")
|
|
fi
|
|
|
|
# Executables under bin/ need the execute bit back.
|
|
if [ "$APPLY" -eq 1 ] && [ -d "$DIR/workspace/bin" ]; then
|
|
while IFS= read -r rel; do
|
|
[ -x "$DIR/workspace/$rel" ] && chmod 700 "$WORKSPACE/$rel"
|
|
done < <(cd "$DIR/workspace" && find bin -type f -printf '%P\n' 2>/dev/null | sed 's|^|bin/|')
|
|
fi
|
|
|
|
[ "$CHANGES" -eq 0 ] && ok "workspace already matches"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Launch contract
|
|
#
|
|
# profile.toml is the single source of truth for launch flags. Rendering it to
|
|
# JSON lets the gateway read the contract instead of hard-coding flags, and lets
|
|
# the gateway fail closed when the file is missing -- silently running without
|
|
# --no-extensions would widen the agent's reach.
|
|
# ---------------------------------------------------------------------------
|
|
if [ "$MODE" = "mirror" ]; then
|
|
head1 "launch contract"
|
|
info " ${C_DIM}skipped: '$NAME' is a mirror; its launch flags live in its own"
|
|
info " gateway, not in profile.toml${C_OFF}"
|
|
printf '\n'
|
|
ok "dry run complete (mirror scenario; nothing to apply)"
|
|
exit 0
|
|
fi
|
|
|
|
LAUNCH="$WORKSPACE/.pi/launch.json"
|
|
head1 "launch contract -> $LAUNCH"
|
|
|
|
RENDERED="$(python3 - "$PROFILE" "$WORKSPACE" <<'PY'
|
|
import json, sys, tomllib, pathlib
|
|
|
|
profile = tomllib.loads(pathlib.Path(sys.argv[1]).read_text())
|
|
workspace = pathlib.Path(sys.argv[2])
|
|
|
|
def rel(paths):
|
|
return [str(workspace / p) for p in paths or []]
|
|
|
|
iso = profile.get("isolation", {})
|
|
res = profile.get("resources", {})
|
|
pers = profile.get("personality", {})
|
|
out = {
|
|
"_generated_from": "profile.toml",
|
|
"_note": "Do not edit. Regenerate with scripts/deploy-scenario.sh.",
|
|
"scenario": profile.get("scenario", {}).get("name"),
|
|
"workspace": str(workspace),
|
|
"session_dir": profile.get("scenario", {}).get("session_dir"),
|
|
"model": profile.get("model", {}),
|
|
"session": profile.get("session", {}),
|
|
"isolation": iso,
|
|
"personality": {
|
|
"system_prompt": str(workspace / pers["system_prompt"]) if pers.get("system_prompt") else "",
|
|
"append_system_prompt": str(workspace / pers["append_system_prompt"]) if pers.get("append_system_prompt") else "",
|
|
"context_files": pers.get("context_files", []),
|
|
},
|
|
"resources": {
|
|
"extensions": rel(res.get("extensions")),
|
|
"skills": rel(res.get("skills")),
|
|
},
|
|
"tools": profile.get("tools", {}),
|
|
"budget": profile.get("budget", {}),
|
|
"env": profile.get("env", {}),
|
|
"bridge": profile.get("bridge", {}),
|
|
}
|
|
print(json.dumps(out, ensure_ascii=False, indent=2))
|
|
PY
|
|
)" || die "cannot render profile.toml"
|
|
|
|
if [ -f "$LAUNCH" ] && [ "$RENDERED" = "$(cat "$LAUNCH")" ]; then
|
|
ok "launch.json already current"
|
|
else
|
|
info " write .pi/launch.json"
|
|
if [ "$APPLY" -eq 1 ]; then
|
|
install -d -m 700 "$WORKSPACE/.pi"
|
|
printf '%s\n' "$RENDERED" > "$LAUNCH"
|
|
chmod 600 "$LAUNCH"
|
|
fi
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Referenced paths must exist
|
|
# ---------------------------------------------------------------------------
|
|
head1 "referenced resources"
|
|
MISSING=0
|
|
while IFS= read -r p; do
|
|
[ -n "$p" ] || continue
|
|
if [ -e "$p" ]; then ok "$p"; else warn "missing: $p"; MISSING=$((MISSING + 1)); fi
|
|
done < <(printf '%s\n' "$RENDERED" | python3 -c '
|
|
import json, sys
|
|
d = json.load(sys.stdin)
|
|
for p in d["resources"]["extensions"] + d["resources"]["skills"]:
|
|
print(p)
|
|
for key in ("system_prompt", "append_system_prompt"):
|
|
if d["personality"].get(key):
|
|
print(d["personality"][key])
|
|
')
|
|
|
|
printf '\n'
|
|
if [ "$APPLY" -eq 1 ]; then
|
|
ok "applied"
|
|
info ""
|
|
SERVICE="$(toml_get "$PROFILE" scenario service)"
|
|
if [ -n "$SERVICE" ]; then
|
|
info "The service was NOT restarted. When you are ready:"
|
|
info " systemctl --user restart $SERVICE"
|
|
info " systemctl --user status $SERVICE"
|
|
fi
|
|
else
|
|
info "${C_DIM}dry run complete; re-run with --apply${C_OFF}"
|
|
fi
|
|
[ "$MISSING" -eq 0 ] || warn "$MISSING referenced path(s) missing; the agent will not start correctly"
|
|
exit 0
|