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>
99 lines
3.3 KiB
Bash
Executable File
99 lines
3.3 KiB
Bash
Executable File
#!/bin/sh
|
|
# Subprocess-harness wrapper for cold-topic-explainer Gemini agents.
|
|
#
|
|
# The subprocess harness execs this with cwd = per-run workdir. The
|
|
# workdir already contains GEMINI.md and message.json, written by
|
|
# MaterialiseAgentConfig and the harness itself. Required env vars are
|
|
# supplied by the agent's harness_config_json.env block (see
|
|
# configs/*.json):
|
|
#
|
|
# AGENT_ROLE decomposer | writer | critic
|
|
# AGENT_NAME this agent's synapbus name
|
|
# GEMINI_MODEL e.g. gemini-2.5-pro
|
|
# NEXT_AGENT the agent to DM on the happy path
|
|
# REVISE_AGENT (critic only) the agent to DM when asking for fixes
|
|
# OWNER_AGENT (critic only) the agent to DM with FINAL: results
|
|
# SYNAPBUS_SOCKET full path to the synapbus admin unix socket
|
|
# SYNAPBUS_BIN path to the synapbus CLI (used to send messages)
|
|
#
|
|
# All agents then: read the DM body, call gemini headless with GEMINI.md
|
|
# + the body, post-process, and hand off via `synapbus messages send`
|
|
# over the admin socket.
|
|
|
|
set -eu
|
|
|
|
log() {
|
|
printf '[wrapper %s] %s\n' "${AGENT_NAME:-?}" "$*" >&2
|
|
}
|
|
|
|
# --- read the triggering DM -------------------------------------------
|
|
if [ ! -f message.json ]; then
|
|
log "no message.json in workdir; refusing to fabricate a task"
|
|
exit 2
|
|
fi
|
|
BODY=$(jq -r '.body' < message.json)
|
|
FROM=$(jq -r '.from_agent' < message.json)
|
|
|
|
log "received from=$FROM bytes=$(printf '%s' "$BODY" | wc -c)"
|
|
|
|
# --- call gemini ------------------------------------------------------
|
|
# -y / --approval-mode yolo means "don't prompt" — safe because we're
|
|
# not giving gemini any tools to call in this workflow.
|
|
PROMPT="$(cat GEMINI.md)
|
|
|
|
Incoming DM from @${FROM}:
|
|
${BODY}"
|
|
|
|
# Preserve the exact prompt and any stderr noise for forensics.
|
|
printf '%s' "$PROMPT" > gemini.prompt.txt
|
|
set +e
|
|
RAW=$(gemini -m "$GEMINI_MODEL" --approval-mode yolo -p "$PROMPT" 2>gemini.stderr.log)
|
|
GEMINI_EXIT=$?
|
|
set -e
|
|
|
|
# Gemini prepends "MCP issues detected. Run /mcp list for status." to
|
|
# stdout when its MCP config can't reach a server. Strip it.
|
|
RESPONSE=$(printf '%s' "$RAW" | sed 's|^MCP issues detected\. Run /mcp list for status\.||')
|
|
|
|
# Save the cleaned response for forensics before we decide next steps.
|
|
printf '%s' "$RESPONSE" > gemini.stdout.txt
|
|
|
|
if [ -z "$RESPONSE" ]; then
|
|
log "empty gemini response (exit=$GEMINI_EXIT); last stderr:"
|
|
tail -20 gemini.stderr.log >&2 || true
|
|
exit 3
|
|
fi
|
|
|
|
log "gemini response bytes=$(printf '%s' "$RESPONSE" | wc -c)"
|
|
|
|
# Save full response for forensics.
|
|
printf '%s' "$RESPONSE" > result.md
|
|
printf '%s\n' "$RESPONSE"
|
|
|
|
# --- decide who to DM next --------------------------------------------
|
|
TO="$NEXT_AGENT"
|
|
if [ "$AGENT_ROLE" = "critic" ]; then
|
|
# Critic's prompt tells gemini to prefix FINAL: or REVISE:.
|
|
case "$RESPONSE" in
|
|
FINAL:*|*"FINAL:"*|Final:*|*"Final:"*)
|
|
TO="$OWNER_AGENT"
|
|
log "verdict=FINAL → $TO"
|
|
;;
|
|
*)
|
|
TO="$REVISE_AGENT"
|
|
log "verdict=REVISE → $TO"
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
# --- hand off ----------------------------------------------------------
|
|
printf '%s' "$RESPONSE" | "$SYNAPBUS_BIN" --socket "$SYNAPBUS_SOCKET" messages send \
|
|
--from "$AGENT_NAME" \
|
|
--to "$TO" \
|
|
--priority 5 >&2 || {
|
|
log "admin socket send failed — check $SYNAPBUS_SOCKET"
|
|
exit 4
|
|
}
|
|
|
|
log "handed off to $TO"
|