Adds everything needed to run a real multi-Gemini-model reactive agent
loop end-to-end on SynapBus.
internal/harness/subprocess/config.go:
* AgentConfig.GeminiMD — content of workdir/GEMINI.md
* MaterialiseAgentConfig writes GEMINI.md AND workdir/.gemini/settings.json
when gemini_md is set. The settings file carries the same mcp_servers
list as .mcp.json (so a Gemini child running from the workdir gets
the exact MCP surface the operator configured, not the user's
~/.gemini/settings.json).
* 2 new config_test cases: GEMINI.md + .gemini/settings.json round
trip, GEMINI.md with empty mcp_servers still writes the settings
file (explicitly clearing any inherited home config).
internal/harness/registry.go — BUG FIX:
Resolve() now honours agent.HarnessName (explicit selection) BEFORE
the inference chain, matching the reactor's own agentBackendKind
policy. Previously, when multiple backends were registered,
Resolve would pick "webhook" for every non-K8s agent — even when the
agent's HarnessName was "subprocess" — because the original fallback
chain put webhook first. This is why the first cold-topic-explainer
run failed with "webhook: agent has no webhook config". Discovered
during e2e testing.
internal/admin/socket.go + cmd/synapbus/admin.go:
New `messages.send` admin command (socket + CLI). Sends a DM as any
agent through the messaging service, bypassing the REST/MCP auth
layers. Local-only via the admin Unix socket, so the threat model is
"whoever can reach the socket is already admin".
CLI:
synapbus messages send --from X --to Y --body "..." [--priority N]
synapbus messages send --from X --to Y --body-file path
echo "..." | synapbus messages send --from X --to Y
Used by the harness shell wrappers (so Gemini subprocess agents can
DM each other) and by run_task.sh (to kick off a chain as a human
user without implementing the REST session flow).
examples/cold-topic-explainer/ (NEW):
Runnable 3-agent Gemini demo that exercises the subprocess harness,
reactive triggers, recursive update, and all the preconditions (depth,
budget, cooldown) end-to-end on a separate isolated synapbus instance.
Layout:
README.md — usage + troubleshooting + cost notes
start.sh — builds synapbus, launches on port 18088 with
./data, creates user + agents + harness configs,
marks agents reactive via sqlite3
run_task.sh — sends initial DM algis → decomposer-pro, polls
reactive_runs + messages for the FINAL: reply,
prints the result or dumps reactive_runs on
timeout for debugging
stop.sh — SIGTERM + 5s grace + SIGKILL fallback
wrapper.sh — shared subprocess local_command: reads
message.json + GEMINI.md, calls gemini headless
with --approval-mode yolo, strips the
"MCP issues detected" noise prefix, routes the
cleaned response to the next agent via
`synapbus messages send` over the admin socket
configs/
decomposer-pro.json — gemini-3.1-pro-preview
(gemini-2.5-pro is currently capacity-
exhausted on Google's side)
writer-flash.json — gemini-2.5-flash
critic-lite.json — gemini-2.5-flash-lite
.gitignore — data/, bin/, synapbus.log, .synapbus.pid
The wrapper does NOT rely on gemini's MCP tool-calling (which was
unreliable in testing). Gemini is used as a pure text generator; the
shell decides routing based on AGENT_ROLE:
- decomposer → NEXT_AGENT (writer)
- writer → NEXT_AGENT (critic)
- critic → OWNER_AGENT if response starts with FINAL:,
REVISE_AGENT otherwise
E2E VERIFICATION (real run, real Gemini, not a mock):
Topic: "how does SynapBus unify message delivery, reactive agent
triggers, and harness runs on a single SQLite database?"
Result (from data/synapbus.db after one successful run):
harness_runs:
#1 decomposer-pro subprocess success 106s
#2 writer-flash subprocess success 155s
#3 critic-lite subprocess success 10s
reactive_runs: 3 rows, all succeeded, trigger_from chain:
algis → decomposer-pro → writer-flash → critic-lite
messages:
#1 algis → decomposer-pro (topic)
#2 decomposer-pro → writer-flash (Q1/Q2/Q3 breakdown)
#3 writer-flash → critic-lite (3-paragraph draft)
#4 critic-lite → algis (FINAL: + polished 3-paragraph explainer)
Critic converged in one pass (all scores ≥ 8), so the writer↔critic
refinement loop didn't need to recurse — but the plumbing for it
(REVISE: branch in wrapper.sh, depth limit in reactor) is wired and
ready. Flipping the critic's acceptance bar exercises the recursion.
Full `go test ./...` remained green through all changes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
148 lines
5.0 KiB
Bash
Executable File
148 lines
5.0 KiB
Bash
Executable File
#!/bin/bash
|
|
# start.sh — launch an isolated synapbus instance and configure the
|
|
# cold-topic-explainer 3-agent chain end-to-end.
|
|
#
|
|
# Idempotent where possible: wipes ./data, rebuilds the binary,
|
|
# creates a fresh user + agents + channel + harness configs.
|
|
#
|
|
# Exit codes:
|
|
# 0 everything came up
|
|
# 1 synapbus failed to start
|
|
# 2 admin socket never appeared
|
|
# 3 CLI preflight failed
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
|
|
PORT="${SYNAPBUS_PORT:-18088}"
|
|
DATA_DIR="$SCRIPT_DIR/data"
|
|
BIN_DIR="$SCRIPT_DIR/bin"
|
|
BIN="$BIN_DIR/synapbus"
|
|
SOCKET="$DATA_DIR/synapbus.sock"
|
|
PID_FILE="$SCRIPT_DIR/.synapbus.pid"
|
|
LOG_FILE="$SCRIPT_DIR/synapbus.log"
|
|
|
|
cd "$SCRIPT_DIR"
|
|
|
|
say() { printf '\033[1;36m[start]\033[0m %s\n' "$*"; }
|
|
die() { printf '\033[1;31m[start][FAIL]\033[0m %s\n' "$*" >&2; exit "${2:-1}"; }
|
|
|
|
# --- preflight ---------------------------------------------------------
|
|
for cmd in go gemini jq sqlite3 curl; do
|
|
command -v "$cmd" >/dev/null || die "missing required CLI: $cmd" 3
|
|
done
|
|
|
|
# Refuse to run on top of an existing pid that's still alive.
|
|
if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
|
die "synapbus already running (pid $(cat "$PID_FILE")); run ./stop.sh first"
|
|
fi
|
|
|
|
# --- build -------------------------------------------------------------
|
|
say "building synapbus binary..."
|
|
mkdir -p "$BIN_DIR"
|
|
(cd "$REPO_ROOT" && go build -o "$BIN" ./cmd/synapbus)
|
|
|
|
# --- fresh data dir ----------------------------------------------------
|
|
say "wiping data dir $DATA_DIR"
|
|
rm -rf "$DATA_DIR"
|
|
mkdir -p "$DATA_DIR"
|
|
|
|
# --- launch synapbus ---------------------------------------------------
|
|
say "starting synapbus on port $PORT"
|
|
nohup "$BIN" serve \
|
|
--port "$PORT" \
|
|
--data "$DATA_DIR" \
|
|
> "$LOG_FILE" 2>&1 &
|
|
echo $! > "$PID_FILE"
|
|
say "pid $(cat "$PID_FILE") → $LOG_FILE"
|
|
|
|
# Wait for the admin socket to appear.
|
|
for i in $(seq 1 100); do
|
|
if [ -S "$SOCKET" ]; then break; fi
|
|
if ! kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
|
die "synapbus crashed during boot — see $LOG_FILE" 1
|
|
fi
|
|
sleep 0.1
|
|
done
|
|
if [ ! -S "$SOCKET" ]; then
|
|
die "admin socket $SOCKET never appeared after 10s" 2
|
|
fi
|
|
|
|
# Wait for HTTP to be ready too.
|
|
for i in $(seq 1 100); do
|
|
if curl -fsS "http://localhost:$PORT/health" >/dev/null 2>&1; then break; fi
|
|
sleep 0.1
|
|
done
|
|
|
|
say "synapbus is up"
|
|
|
|
# --- shorthand for admin calls -----------------------------------------
|
|
admin() { "$BIN" --socket "$SOCKET" "$@"; }
|
|
|
|
# --- user + human agent ------------------------------------------------
|
|
say "creating user algis / algis-demo-pw"
|
|
admin user create --username algis --password 'algis-demo-pw' --display-name Algis >/dev/null
|
|
|
|
say "creating type=human agent for algis"
|
|
admin agent create --name algis --display-name "Algis (human)" --type human --owner 1 >/dev/null
|
|
|
|
# --- three AI agents ---------------------------------------------------
|
|
for name in decomposer-pro writer-flash critic-lite; do
|
|
say "creating agent $name"
|
|
admin agent create --name "$name" --display-name "$name" --type ai --owner 1 >/dev/null
|
|
done
|
|
|
|
# --- reactive config ---------------------------------------------------
|
|
# No CLI command for trigger_mode yet; use sqlite3 directly. This also
|
|
# lets us set harness_name / local_command / harness_config_json for all
|
|
# three agents in one batch.
|
|
say "configuring reactive trigger mode via sqlite"
|
|
sqlite3 "$DATA_DIR/synapbus.db" <<SQL
|
|
UPDATE agents SET
|
|
trigger_mode = 'reactive',
|
|
cooldown_seconds = 0,
|
|
daily_trigger_budget = 30,
|
|
max_trigger_depth = 8
|
|
WHERE name IN ('decomposer-pro','writer-flash','critic-lite');
|
|
SQL
|
|
|
|
# --- per-agent harness config -----------------------------------------
|
|
# Each agent's harness_config_json carries GEMINI.md, an empty
|
|
# mcp_servers block (explicitly clearing any home-level config so the
|
|
# gemini CLI doesn't warn), and the role env map the wrapper reads.
|
|
apply_config() {
|
|
local agent="$1"
|
|
local config_path="$2"
|
|
# Template replacement: the configs reference the literal strings
|
|
# __SOCKET__, __BIN__, and __SYNAPBUS_URL__ so the same files work
|
|
# regardless of where the user clones the repo.
|
|
local tmp
|
|
tmp=$(mktemp)
|
|
sed \
|
|
-e "s|__SOCKET__|${SOCKET//|/\\|}|g" \
|
|
-e "s|__BIN__|${BIN//|/\\|}|g" \
|
|
-e "s|__SYNAPBUS_URL__|http://localhost:$PORT|g" \
|
|
"$config_path" > "$tmp"
|
|
admin harness config set \
|
|
--agent "$agent" \
|
|
--harness-name subprocess \
|
|
--local-command "[\"$SCRIPT_DIR/wrapper.sh\"]" \
|
|
--file "$tmp" >/dev/null
|
|
rm -f "$tmp"
|
|
}
|
|
|
|
say "applying harness configs"
|
|
apply_config decomposer-pro "$SCRIPT_DIR/configs/decomposer-pro.json"
|
|
apply_config writer-flash "$SCRIPT_DIR/configs/writer-flash.json"
|
|
apply_config critic-lite "$SCRIPT_DIR/configs/critic-lite.json"
|
|
|
|
say "ready"
|
|
echo
|
|
echo " Web UI: http://localhost:$PORT (login: algis / algis-demo-pw)"
|
|
echo " Log: tail -f $LOG_FILE"
|
|
echo " Messages: $BIN --socket $SOCKET messages list --limit 20"
|
|
echo
|
|
echo "Next: ./run_task.sh \"your topic here\""
|