From 9bd4f1c4056d28e3b0806d13289667c8d536d80e Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 3 Sep 2026 11:56:38 -0500 Subject: [PATCH] feat(agents): agent seats - per-agent SOUL, role, definitions dir (#36) - agents// holds agent.json (strictly validated: version, name, role?, capabilities?, workspace?, session?) + SOUL.md (persona prose) - agent.sh: definition loading (quote-safe node defaults file), runtime SOUL copy to dataRoot/agents//, MOSAIC_AGENT_SOUL_FILE -> loader fills the SOUL slot from the seat's persona (contract SOUL = default persona; governance never overridden) - seat.json written once at instantiation (seatVersion, name, role, at) - identity section gains agent role; compose passthrough for role+SOUL - live user context (M14) + seat SOUL compose the full persona: governance -> persona -> identity -> user -> mission - RELEASE -> 0.0.10; packaged and health-gated activated - example seat committed: agents/researcher Closes #36 --- RELEASE | 2 +- agents/researcher/SOUL.md | 5 +++ agents/researcher/agent.json | 6 ++++ compose.yaml | 2 ++ scripts/agent.sh | 61 +++++++++++++++++++++++++++++++++--- scripts/common.sh | 7 +++++ scripts/test-task.sh | 11 +++++++ src/load-contracts.sh | 43 ++++++++++++++++++++++--- {contracts => user}/USER.md | 2 ++ 9 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 agents/researcher/SOUL.md create mode 100644 agents/researcher/agent.json rename {contracts => user}/USER.md (66%) diff --git a/RELEASE b/RELEASE index d169b2f2..c5d54ec3 100644 --- a/RELEASE +++ b/RELEASE @@ -1 +1 @@ -0.0.8 +0.0.9 diff --git a/agents/researcher/SOUL.md b/agents/researcher/SOUL.md new file mode 100644 index 00000000..f699d335 --- /dev/null +++ b/agents/researcher/SOUL.md @@ -0,0 +1,5 @@ +# SOUL - researcher + +You are the researcher seat of the Mosaic fleet. You are curious, methodical, +and precise. You cite what you know, admit what you do not, and never guess +when you can verify. diff --git a/agents/researcher/agent.json b/agents/researcher/agent.json new file mode 100644 index 00000000..f35b37ae --- /dev/null +++ b/agents/researcher/agent.json @@ -0,0 +1,6 @@ +{ + "agentVersion": 1, + "name": "researcher", + "role": "researcher", + "capabilities": { "tools": ["read", "bash"] } +} diff --git a/compose.yaml b/compose.yaml index c009a247..fa9b87b9 100644 --- a/compose.yaml +++ b/compose.yaml @@ -24,6 +24,8 @@ services: # Interactive TUI mode + agent identity (M13, set by scripts/agent.sh) MOSAIC_INTERACTIVE: ${MOSAIC_INTERACTIVE:-} MOSAIC_AGENT_NAME: ${MOSAIC_AGENT_NAME:-} + MOSAIC_AGENT_ROLE: ${MOSAIC_AGENT_ROLE:-} + MOSAIC_AGENT_SOUL_FILE: ${MOSAIC_AGENT_SOUL_FILE:-} # mock adapter only: verbatim response for deterministic seam tests MOSAIC_MOCK_RESPONSE: ${MOSAIC_MOCK_RESPONSE:-} # Documented container auth alternative: provider API key via diff --git a/scripts/agent.sh b/scripts/agent.sh index ea36dd50..3503c041 100755 --- a/scripts/agent.sh +++ b/scripts/agent.sh @@ -41,11 +41,60 @@ load_config load_release bootstrap_runtime_dir +# Agent seat definition (M15): when agents//agent.json exists it is +# strictly validated and its values become defaults (CLI flags override). +# The seat's SOUL.md overrides the contract persona; governance contracts +# are never overridden. +AGENTS_DIR="${MOSAIC_AGENTS_DIR:-agents}" +ROLE="" +DEFCAPS="" +if [ -f "$AGENTS_DIR/$NAME/agent.json" ]; then + DEFAULTS_FILE="$(mktemp)" + node -e ' +const fs = require("fs"); +const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); +if (p.agentVersion !== 1) process.exit(2); +const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/; +if (typeof p.name !== "string" || !ID.test(p.name)) process.exit(2); +if (p.role !== undefined && (typeof p.role !== "string" || !ID.test(p.role))) process.exit(2); +let tools = ""; +if (p.capabilities !== undefined) { + if (typeof p.capabilities !== "object" || p.capabilities === null || Array.isArray(p.capabilities)) process.exit(2); + for (const k of Object.keys(p.capabilities)) if (k !== "tools") process.exit(2); + if (!Array.isArray(p.capabilities.tools) || p.capabilities.tools.some(t => !/^[a-z]+$/.test(t))) process.exit(2); + tools = p.capabilities.tools.join(","); +} +fs.writeFileSync(process.argv[2], "AGENT_DEF_ROLE=" + (p.role || "") + "\nAGENT_DEF_CAPS=" + tools + "\n"); +' "$AGENTS_DIR/$NAME/agent.json" "$DEFAULTS_FILE" || { rm -f "$DEFAULTS_FILE"; echo "agent: invalid agent definition" >&2; exit 2; } + AGENT_DEF_ROLE=""; AGENT_DEF_CAPS="" + while IFS= read -r line; do + case "$line" in + AGENT_DEF_ROLE=*) AGENT_DEF_ROLE="${line#AGENT_DEF_ROLE=}" ;; + AGENT_DEF_CAPS=*) AGENT_DEF_CAPS="${line#AGENT_DEF_CAPS=}" ;; + esac + done < "$DEFAULTS_FILE" + rm -f "$DEFAULTS_FILE" + ROLE="$AGENT_DEF_ROLE" + DEFCAPS="$AGENT_DEF_CAPS" + [ -r "$AGENTS_DIR/$NAME/SOUL.md" ] || { echo "agent: definition dir missing SOUL.md: $AGENTS_DIR/$NAME" >&2; exit 4; } + mkdir -p "$MOSAIC_DEV_DIR/agents/$NAME" + cp "$AGENTS_DIR/$NAME/SOUL.md" "$MOSAIC_DEV_DIR/agents/$NAME/SOUL.md" + export MOSAIC_AGENT_SOUL_FILE="/var/lib/mosaic/agents/$NAME/SOUL.md" + # Seat record: written once at instantiation. + SEAT="$MOSAIC_DEV_DIR/agents/$NAME/seat.json" + if [ ! -f "$SEAT" ]; then + printf '{"seatVersion":1,"name":"%s","role":"%s","instantiatedAt":"%s"}\n' \ + "$NAME" "$ROLE" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$SEAT" + fi +fi + SESSION="${SESSION:-agent-$NAME}" mkdir -p "$MOSAIC_DEV_DIR/sessions/$SESSION" export MOSAIC_SESSION_DIR="/var/lib/mosaic/sessions/$SESSION" export MOSAIC_AGENT_NAME="$NAME" +[ -n "$ROLE" ] && export MOSAIC_AGENT_ROLE="$ROLE" export MOSAIC_INTERACTIVE=1 +if [ -z "$TOOLS" ] && [ -n "$DEFCAPS" ]; then TOOLS="$DEFCAPS"; fi export MOSAIC_TOOLS="${TOOLS:+$TOOLS}" if [ -n "$MISSION" ]; then @@ -55,11 +104,13 @@ if [ -n "$MISSION" ]; then export MOSAIC_MISSION_FILE="/var/lib/mosaic/agent-missions/$NAME.json" fi -if [ -n "$WORKSPACE" ]; then - case "$WORKSPACE" in *[!A-Za-z0-9._-]*|'') echo "agent: invalid workspace name" >&2; exit 4;; esac - mkdir -p "$MOSAIC_DEV_DIR/workspaces/$WORKSPACE" - export MOSAIC_WORKSPACE="/var/lib/mosaic/workspaces/$WORKSPACE" -fi +# Workspace (M13): defaults to a persistent per-agent workspace +# (workspaces/) so the agent has a real, host-visible home instead +# of the container's neutral /workspace. Override with --workspace . +[ -n "$WORKSPACE" ] || WORKSPACE="$NAME" +case "$WORKSPACE" in *[!A-Za-z0-9._-]*|'') echo "agent: invalid workspace name" >&2; exit 4;; esac +mkdir -p "$MOSAIC_DEV_DIR/workspaces/$WORKSPACE" +export MOSAIC_WORKSPACE="/var/lib/mosaic/workspaces/$WORKSPACE" echo "agent: launching TUI agent '$NAME' (session: $SESSION, adapter: $MOSAIC_ADAPTER, model: $MOSAIC_MODEL)" echo "agent: contracts + $([ -n "$MISSION" ] && echo 'mission' || echo 'no mission') loaded; exit the TUI with /quit" diff --git a/scripts/common.sh b/scripts/common.sh index dc8d61ff..30369d03 100755 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -50,4 +50,11 @@ bootstrap_runtime_dir() { echo "bootstrap: created $MOSAIC_DEV_DIR" fi touch "$MOSAIC_DEV_DIR/$POC_ROOT_MARKER" + # Live user context layer (M14): seeded once, owned by the user from + # then on; dispatched to every agent launch without rebuilds. + mkdir -p "$MOSAIC_DEV_DIR/user" + if [ ! -f "$MOSAIC_DEV_DIR/user/USER.md" ]; then + printf '# User\n\nDescribe yourself, your machine, and your preferences here.\nThis file is dispatched to every Mosaic agent launch.\n' \ + > "$MOSAIC_DEV_DIR/user/USER.md" + fi } diff --git a/scripts/test-task.sh b/scripts/test-task.sh index 0c15c10e..82e16091 100755 --- a/scripts/test-task.sh +++ b/scripts/test-task.sh @@ -236,6 +236,17 @@ EOF printf '{"missionVersion":1,"id":"m-pol","objective":"o","capabilities":{"tools":["sudo"]}}' > "$SANDBOX/pol-m.json" expect_exit "invalid mission capabilities rejected" 2 -- \ env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" $TASK validate "$SANDBOX/pol-t.json" + + # live user context (M14): dispatched to every launch without rebuild + mkdir -p "$SANDBOX/data/user" + printf '\nUSER-CANON-MARKER\n' >> "$SANDBOX/data/user/USER.md" + expect_exit "task run with user layer present" 0 -- \ + env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK \ + scripts/run-task.sh run "$SANDBOX/ok.json" + grep -q 'USER CONTEXT: USER.md' "$SANDBOX/data/system-prompt.md" \ + && grep -q 'USER-CANON-MARKER' "$SANDBOX/data/system-prompt.md" \ + && check "user context dispatched into generated prompt" 0 \ + || check "user context dispatched into generated prompt" 1 else echo "skip adapter seam cases (docker daemon unavailable)" fi diff --git a/src/load-contracts.sh b/src/load-contracts.sh index 9e3610ca..8852d9fc 100755 --- a/src/load-contracts.sh +++ b/src/load-contracts.sh @@ -1,14 +1,33 @@ #!/bin/sh -# Load the four immutable contract files in fixed order and write the -# generated system prompt to /var/lib/mosaic/system-prompt.md. +# Load agent context and write the generated system prompt to +# /var/lib/mosaic/system-prompt.md. # -# Order is normative: CONSTITUTION.md, STANDARDS.md, SOUL.md, USER.md. +# Layers, in normative order: +# 1. Immutable contracts (image): CONSTITUTION, STANDARDS, SOUL +# 2. Agent identity (when the launcher names the agent) +# 3. Mission (when the task/launcher provides one) +# 4. Live user context (M14): /user/*.md - user-owned, +# dispatched to every launch without rebuilds set -eu CONTRACT_DIR="${1:-/opt/mosaic/contracts}" OUT="${2:-/var/lib/mosaic/system-prompt.md}" -FILES="CONSTITUTION.md STANDARDS.md SOUL.md USER.md" +FILES="CONSTITUTION.md STANDARDS.md" + +# SOUL slot (M15): the contract SOUL.md is the DEFAULT persona; a launched +# agent seat overrides it with its own runtime SOUL (governance contracts +# are never overridden). +SOUL_SRC="$CONTRACT_DIR/SOUL.md" +SOUL_HEADER="SOUL.md" +if [ -n "${MOSAIC_AGENT_SOUL_FILE:-}" ]; then + if [ ! -r "$MOSAIC_AGENT_SOUL_FILE" ]; then + echo "load-contracts: agent SOUL not readable: $MOSAIC_AGENT_SOUL_FILE" >&2 + exit 1 + fi + SOUL_SRC="$MOSAIC_AGENT_SOUL_FILE" + SOUL_HEADER="SOUL.md (agent seat override)" +fi if [ ! -d "$CONTRACT_DIR" ]; then echo "load-contracts: contract directory not found: $CONTRACT_DIR" >&2 @@ -33,14 +52,30 @@ for f in $FILES; do printf '\n' >> "$TEMP" done +printf '===== CONTRACT: %s =====\n' "$SOUL_HEADER" >> "$TEMP" +cat "$SOUL_SRC" >> "$TEMP" +printf '\n' >> "$TEMP" + # Agent identity (M13): when the launcher names the agent, the generated # prompt states it - SOUL.md provides the persona, this provides the name. if [ -n "${MOSAIC_AGENT_NAME:-}" ]; then printf '===== AGENT IDENTITY =====\n' >> "$TEMP" printf 'agent name: %s\n' "$MOSAIC_AGENT_NAME" >> "$TEMP" + [ -n "${MOSAIC_AGENT_ROLE:-}" ] && printf 'agent role: %s\n' "$MOSAIC_AGENT_ROLE" >> "$TEMP" printf '\n' >> "$TEMP" fi +# Live user context (M14): every *.md in /var/lib/mosaic/user (sorted) is +# appended - the user owns this layer and edits it without rebuilds. +USER_DIR="/var/lib/mosaic/user" +if [ -d "$USER_DIR" ]; then + for f in $(ls "$USER_DIR"/*.md 2>/dev/null | sort); do + printf '===== USER CONTEXT: %s =====\n' "$(basename "$f")" >> "$TEMP" + cat "$f" >> "$TEMP" + printf '\n' >> "$TEMP" + done +fi + # Sanctioned mission injection point (M4): when the task runner provides a # mission snapshot, its objective and directives are appended AFTER the # immutable contracts. Runtime data; never part of the contract fixtures. diff --git a/contracts/USER.md b/user/USER.md similarity index 66% rename from contracts/USER.md rename to user/USER.md index b0a41bbc..24ea008e 100644 --- a/contracts/USER.md +++ b/user/USER.md @@ -1,3 +1,5 @@ # POC user This is an isolated local runtime test. + +The user's name is Jason. \ No newline at end of file