feat(tess): add configurable Pi interaction service (#728)
This commit was merged in pull request #728.
This commit is contained in:
40
docs/scratchpads/tess-m2-001-pi-service.md
Normal file
40
docs/scratchpads/tess-m2-001-pi-service.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# TESS-M2-001 — Pi Interaction Service
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Add a generic rostered/systemd Pi operator-interaction service in
|
||||||
|
`packages/mosaic/framework`.
|
||||||
|
- Pin the service to `openai/gpt-5.6-sol`, high reasoning, and the
|
||||||
|
`operator-interaction` tool policy.
|
||||||
|
- Keep identity as provisioning data; the product name appears only in the
|
||||||
|
committed example roster.
|
||||||
|
|
||||||
|
## Security and Configuration Invariants
|
||||||
|
|
||||||
|
1. The chosen display/roster name is supplied as data and must exactly match the
|
||||||
|
generic systemd instance.
|
||||||
|
2. The service fails before launch if runtime, model, reasoning, or tool policy
|
||||||
|
differs from the pinned policy.
|
||||||
|
3. Effective-policy output includes only name, runtime, model, reasoning, and
|
||||||
|
tool policy; it does not inspect or output credential variables.
|
||||||
|
4. The default example is replaceable without a source change; the TDD suite
|
||||||
|
provisions `Nova` from the same profile.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- `src/fleet/tess-service-profile.test.ts` proves a `Nova` provisioning path,
|
||||||
|
roster parser/env serialization, effective-policy output, fail-fast drift
|
||||||
|
rejection, and absence of the product name from generic source/profile.
|
||||||
|
- `test-fleet-units.sh` validates the generic interaction systemd unit requires
|
||||||
|
per-agent config and invokes fail-fast startup validation.
|
||||||
|
- `test-start-agent-session.sh` proves the tool-policy value is exported into
|
||||||
|
the Pi pane; `compose-contract.spec.ts` proves it becomes an explicit
|
||||||
|
runtime contract block.
|
||||||
|
- Fresh-worktree dependency install plus root `pnpm typecheck`, `pnpm lint`,
|
||||||
|
`pnpm format:check`, and `pnpm test` passed; package/full fleet suites passed.
|
||||||
|
- Independent Codex code and security reviews passed with no remaining findings.
|
||||||
|
|
||||||
|
## Delivery Notes
|
||||||
|
|
||||||
|
- Branch starts from fresh `origin/main` at `86a50138`.
|
||||||
|
- PR targets `main` and references issue `#708`.
|
||||||
@@ -81,3 +81,5 @@ The provider advertises list, tree, read-only attach, send, and terminate. List/
|
|||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Tess runs as a rostered, systemd-supervised Pi agent using GPT-5.6 Sol and high reasoning. Secrets are supplied through approved runtime secret mechanisms. Startup fails when required model, gateway identity, Discord binding, or durable-state dependencies are missing. Health reports effective model/reasoning/tool policy without credential material.
|
Tess runs as a rostered, systemd-supervised Pi agent using GPT-5.6 Sol and high reasoning. Secrets are supplied through approved runtime secret mechanisms. Startup fails when required model, gateway identity, Discord binding, or durable-state dependencies are missing. Health reports effective model/reasoning/tool policy without credential material.
|
||||||
|
|
||||||
|
The interaction-service identity is provisioning data, not a source identifier: the roster and per-agent environment carry the chosen display/roster name into a generic systemd instance. The service rejects a name mismatch or any drift from its pinned Pi/GPT-5.6 Sol/high/operator-interaction effective policy before launch. Its policy printer exposes only those resolved safe fields.
|
||||||
|
|||||||
@@ -15,6 +15,21 @@ default tmux server.
|
|||||||
|
|
||||||
- `examples/minimal.yaml` starts one local canary slot.
|
- `examples/minimal.yaml` starts one local canary slot.
|
||||||
- `examples/local-canary.yaml` starts a small generic dogfood fleet.
|
- `examples/local-canary.yaml` starts a small generic dogfood fleet.
|
||||||
|
- `examples/operator-interaction.yaml` is an example Pi operator-interaction
|
||||||
|
service; replace its example agent name before provisioning.
|
||||||
|
|
||||||
|
## Operator interaction service
|
||||||
|
|
||||||
|
`services/operator-interaction.yaml` pins the Pi runtime, GPT-5.6 Sol model,
|
||||||
|
high reasoning, and the `operator-interaction` tool policy. The agent identity
|
||||||
|
is provisioning data: choose a roster name, generate its per-agent environment
|
||||||
|
file, then start the matching generic systemd instance. The service fails before
|
||||||
|
launch if the configured identity does not match the instance or any pinned
|
||||||
|
policy field drifts.
|
||||||
|
|
||||||
|
The installed `tools/fleet/print-interaction-effective-policy.sh` prints only
|
||||||
|
the resolved name, runtime, model, reasoning, and tool policy. It never reads
|
||||||
|
or prints credential variables.
|
||||||
|
|
||||||
Initialize a roster:
|
Initialize a roster:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Example instance only. Replace `Tess` with the chosen provisioned identity.
|
||||||
|
version: 1
|
||||||
|
transport: tmux
|
||||||
|
tmux:
|
||||||
|
socket_name: mosaic-fleet
|
||||||
|
holder_session: _holder
|
||||||
|
defaults:
|
||||||
|
working_directory: ~/src
|
||||||
|
runtimes:
|
||||||
|
pi:
|
||||||
|
reset_command: /new
|
||||||
|
agents:
|
||||||
|
- name: Tess
|
||||||
|
runtime: pi
|
||||||
|
class: operator-interaction
|
||||||
|
model_hint: openai/gpt-5.6-sol
|
||||||
|
reasoning_level: high
|
||||||
|
tool_policy: operator-interaction
|
||||||
|
persistent_persona: true
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Operator Interaction — fleet role definition
|
||||||
|
|
||||||
|
The **operator-interaction** role is the authorized human interaction plane for
|
||||||
|
Mosaic. It presents runtime and fleet state, mediates approved actions, and
|
||||||
|
hands coding or general orchestration work to Mos.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- It does not claim Mos-owned coding or general orchestration work.
|
||||||
|
- It exposes only the configured, observable tool policy.
|
||||||
|
- It does not receive or surface credentials in its effective policy.
|
||||||
@@ -105,6 +105,18 @@
|
|||||||
"modelHint": {
|
"modelHint": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"reasoning_level": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"reasoningLevel": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tool_policy": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"toolPolicy": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"persistent_persona": {
|
"persistent_persona": {
|
||||||
"oneOf": [{ "type": "boolean" }, { "type": "string" }]
|
"oneOf": [{ "type": "boolean" }, { "type": "string" }]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Generic service policy. Provisioning supplies the agent name as data.
|
||||||
|
runtime: pi
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
reasoning: high
|
||||||
|
tool_policy: operator-interaction
|
||||||
@@ -12,6 +12,8 @@ exact-match session.
|
|||||||
|
|
||||||
- `mosaic-tmux-holder.service` — user-mode holder that owns the named tmux server.
|
- `mosaic-tmux-holder.service` — user-mode holder that owns the named tmux server.
|
||||||
- `mosaic-agent@.service` — user-mode template for one reusable agent session.
|
- `mosaic-agent@.service` — user-mode template for one reusable agent session.
|
||||||
|
- `mosaic-interaction-agent@.service` — generic Pi operator-interaction template
|
||||||
|
that fails fast when its pinned runtime policy is incomplete or changed.
|
||||||
- `test-fleet-units.sh` — validates unit syntax and required relationships.
|
- `test-fleet-units.sh` — validates unit syntax and required relationships.
|
||||||
|
|
||||||
The agent template calls:
|
The agent template calls:
|
||||||
@@ -45,12 +47,22 @@ MOSAIC_AGENT_WORKDIR=$HOME/src/your-project
|
|||||||
```bash
|
```bash
|
||||||
mkdir -p ~/.config/systemd/user ~/.config/mosaic/tools/fleet ~/.config/mosaic/fleet/agents
|
mkdir -p ~/.config/systemd/user ~/.config/mosaic/tools/fleet ~/.config/mosaic/fleet/agents
|
||||||
cp packages/mosaic/framework/systemd/user/mosaic-*.service ~/.config/systemd/user/
|
cp packages/mosaic/framework/systemd/user/mosaic-*.service ~/.config/systemd/user/
|
||||||
cp packages/mosaic/framework/tools/fleet/start-agent-session.sh ~/.config/mosaic/tools/fleet/
|
cp packages/mosaic/framework/tools/fleet/*.sh ~/.config/mosaic/tools/fleet/
|
||||||
chmod +x ~/.config/mosaic/tools/fleet/start-agent-session.sh
|
chmod +x ~/.config/mosaic/tools/fleet/*.sh
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
systemctl --user start mosaic-tmux-holder.service
|
systemctl --user start mosaic-tmux-holder.service
|
||||||
systemctl --user start mosaic-agent@canary.service
|
systemctl --user start mosaic-agent@canary.service
|
||||||
tmux -L mosaic-fleet ls
|
tmux -L mosaic-fleet ls
|
||||||
|
|
||||||
|
# For an operator-interaction service, the roster/env identity selects the
|
||||||
|
# generic unit instance; no service source is renamed for an instance.
|
||||||
|
systemctl --user start mosaic-interaction-agent@<agent-name>.service
|
||||||
|
MOSAIC_AGENT_NAME=<agent-name> \
|
||||||
|
MOSAIC_AGENT_RUNTIME=pi \
|
||||||
|
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol \
|
||||||
|
MOSAIC_AGENT_REASONING=high \
|
||||||
|
MOSAIC_AGENT_TOOL_POLICY=operator-interaction \
|
||||||
|
~/.config/mosaic/tools/fleet/print-interaction-effective-policy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not use `tmux kill-server` without `-L mosaic-fleet`; this pattern is meant
|
Do not use `tmux kill-server` without `-L mosaic-fleet`; this pattern is meant
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Mosaic operator interaction agent %i
|
||||||
|
Documentation=https://git.mosaicstack.dev/mosaicstack/stack
|
||||||
|
Requires=mosaic-tmux-holder.service
|
||||||
|
After=mosaic-tmux-holder.service
|
||||||
|
PartOf=mosaic-tmux-holder.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
Environment=MOSAIC_AGENT_NAME=%i
|
||||||
|
EnvironmentFile=%h/.config/mosaic/fleet/agents/%i.env
|
||||||
|
ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-interaction-service.sh %i
|
||||||
|
ExecStop=-/bin/bash -lc 'if [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then tmux -L "$MOSAIC_TMUX_SOCKET" kill-session -t "=%i"; else tmux kill-session -t "=%i"; fi'
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -4,6 +4,7 @@ set -euo pipefail
|
|||||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||||
HOLDER="$SCRIPT_DIR/mosaic-tmux-holder.service"
|
HOLDER="$SCRIPT_DIR/mosaic-tmux-holder.service"
|
||||||
AGENT="$SCRIPT_DIR/mosaic-agent@.service"
|
AGENT="$SCRIPT_DIR/mosaic-agent@.service"
|
||||||
|
INTERACTION="$SCRIPT_DIR/mosaic-interaction-agent@.service"
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
echo "FAIL: $*" >&2
|
echo "FAIL: $*" >&2
|
||||||
@@ -12,6 +13,7 @@ fail() {
|
|||||||
|
|
||||||
[ -f "$HOLDER" ] || fail "missing mosaic-tmux-holder.service"
|
[ -f "$HOLDER" ] || fail "missing mosaic-tmux-holder.service"
|
||||||
[ -f "$AGENT" ] || fail "missing mosaic-agent@.service"
|
[ -f "$AGENT" ] || fail "missing mosaic-agent@.service"
|
||||||
|
[ -f "$INTERACTION" ] || fail "missing mosaic-interaction-agent@.service"
|
||||||
|
|
||||||
grep -qF 'ExecStart=' "$HOLDER" || fail "holder has no ExecStart"
|
grep -qF 'ExecStart=' "$HOLDER" || fail "holder has no ExecStart"
|
||||||
grep -qF 'tmux -L' "$HOLDER" || fail "holder does not use named tmux socket"
|
grep -qF 'tmux -L' "$HOLDER" || fail "holder does not use named tmux socket"
|
||||||
@@ -19,9 +21,12 @@ grep -qF '_holder' "$HOLDER" || fail "holder session is not explicit"
|
|||||||
grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder"
|
grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder"
|
||||||
grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh"
|
grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh"
|
||||||
grep -qF 'kill-session -t "=%i"' "$AGENT" || fail "agent stop does not exact-match its session"
|
grep -qF 'kill-session -t "=%i"' "$AGENT" || fail "agent stop does not exact-match its session"
|
||||||
|
grep -qF 'Requires=mosaic-tmux-holder.service' "$INTERACTION" || fail "interaction service does not require holder"
|
||||||
|
grep -qF 'EnvironmentFile=%h/.config/mosaic/fleet/agents/%i.env' "$INTERACTION" || fail "interaction service does not require per-agent config"
|
||||||
|
grep -qF 'start-interaction-service.sh %i' "$INTERACTION" || fail "interaction service does not validate before startup"
|
||||||
|
|
||||||
if command -v systemd-analyze >/dev/null 2>&1; then
|
if command -v systemd-analyze >/dev/null 2>&1; then
|
||||||
systemd-analyze verify --user "$HOLDER" "$AGENT" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || {
|
systemd-analyze verify --user "$HOLDER" "$AGENT" "$INTERACTION" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || {
|
||||||
cat /tmp/mosaic-fleet-systemd-verify.log >&2
|
cat /tmp/mosaic-fleet-systemd-verify.log >&2
|
||||||
fail "systemd-analyze verify failed"
|
fail "systemd-analyze verify failed"
|
||||||
}
|
}
|
||||||
|
|||||||
18
packages/mosaic/framework/tools/fleet/print-interaction-effective-policy.sh
Executable file
18
packages/mosaic/framework/tools/fleet/print-interaction-effective-policy.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
AGENT_NAME=${MOSAIC_AGENT_NAME:-}
|
||||||
|
RUNTIME=${MOSAIC_AGENT_RUNTIME:-}
|
||||||
|
MODEL=${MOSAIC_AGENT_MODEL:-}
|
||||||
|
REASONING=${MOSAIC_AGENT_REASONING:-}
|
||||||
|
TOOL_POLICY=${MOSAIC_AGENT_TOOL_POLICY:-}
|
||||||
|
|
||||||
|
[ -n "$AGENT_NAME" ] || { echo 'ERROR: MOSAIC_AGENT_NAME is required' >&2; exit 64; }
|
||||||
|
[[ "$AGENT_NAME" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo 'ERROR: invalid agent name' >&2; exit 64; }
|
||||||
|
[ "$RUNTIME" = 'pi' ] || { echo 'ERROR: invalid runtime policy' >&2; exit 64; }
|
||||||
|
[ "$MODEL" = 'openai/gpt-5.6-sol' ] || { echo 'ERROR: invalid model policy' >&2; exit 64; }
|
||||||
|
[ "$REASONING" = 'high' ] || { echo 'ERROR: invalid reasoning policy' >&2; exit 64; }
|
||||||
|
[ "$TOOL_POLICY" = 'operator-interaction' ] || { echo 'ERROR: invalid tool policy' >&2; exit 64; }
|
||||||
|
|
||||||
|
printf '{"agentName":"%s","runtime":"%s","model":"%s","reasoning":"%s","toolPolicy":"%s"}\n' \
|
||||||
|
"$AGENT_NAME" "$RUNTIME" "$MODEL" "$REASONING" "$TOOL_POLICY"
|
||||||
@@ -8,6 +8,7 @@ AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}}
|
|||||||
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-}
|
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-}
|
||||||
MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi}
|
MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi}
|
||||||
MOSAIC_AGENT_MODEL=${MOSAIC_AGENT_MODEL:-}
|
MOSAIC_AGENT_MODEL=${MOSAIC_AGENT_MODEL:-}
|
||||||
|
MOSAIC_AGENT_REASONING=${MOSAIC_AGENT_REASONING:-}
|
||||||
MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME}
|
MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME}
|
||||||
MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-}
|
MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-}
|
||||||
MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run}
|
MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run}
|
||||||
@@ -18,6 +19,14 @@ if [ -z "$AGENT_NAME" ]; then
|
|||||||
exit 64
|
exit 64
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
case "$MOSAIC_AGENT_REASONING" in
|
||||||
|
''|low|medium|high) ;;
|
||||||
|
*)
|
||||||
|
echo "ERROR: MOSAIC_AGENT_REASONING must be low, medium, or high" >&2
|
||||||
|
exit 64
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
if ! command -v tmux >/dev/null 2>&1; then
|
if ! command -v tmux >/dev/null 2>&1; then
|
||||||
echo "ERROR: tmux is required" >&2
|
echo "ERROR: tmux is required" >&2
|
||||||
exit 69
|
exit 69
|
||||||
@@ -41,7 +50,7 @@ fi
|
|||||||
if [ -z "$MOSAIC_AGENT_COMMAND" ]; then
|
if [ -z "$MOSAIC_AGENT_COMMAND" ]; then
|
||||||
# Map the roster's per-agent model_hint to `--model` so workers launch on the
|
# Map the roster's per-agent model_hint to `--model` so workers launch on the
|
||||||
# configured model (e.g. pi on openai-codex/gpt-5.5:high). Omitted when unset.
|
# configured model (e.g. pi on openai-codex/gpt-5.5:high). Omitted when unset.
|
||||||
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}"
|
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}${MOSAIC_AGENT_REASONING:+ --thinking $MOSAIC_AGENT_REASONING}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Derive a runtime-bin PATH prefix ─────────────────────────────────────────
|
# ── Derive a runtime-bin PATH prefix ─────────────────────────────────────────
|
||||||
@@ -124,11 +133,12 @@ AGENT_NAME_Q=$(printf '%q' "$AGENT_NAME")
|
|||||||
# safe single bash token; an empty/unset class %q-quotes to '' and is a harmless
|
# safe single bash token; an empty/unset class %q-quotes to '' and is a harmless
|
||||||
# no-op downstream (readPersonaContractBlock returns '' for an empty class).
|
# no-op downstream (readPersonaContractBlock returns '' for an empty class).
|
||||||
AGENT_CLASS_Q=$(printf '%q' "${MOSAIC_AGENT_CLASS:-}")
|
AGENT_CLASS_Q=$(printf '%q' "${MOSAIC_AGENT_CLASS:-}")
|
||||||
|
AGENT_TOOL_POLICY_Q=$(printf '%q' "${MOSAIC_AGENT_TOOL_POLICY:-}")
|
||||||
|
|
||||||
if [ -n "$MOSAIC_RUNTIME_BIN_PREFIX" ]; then
|
if [ -n "$MOSAIC_RUNTIME_BIN_PREFIX" ]; then
|
||||||
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}"
|
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export MOSAIC_AGENT_TOOL_POLICY=${AGENT_TOOL_POLICY_Q}; export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}"
|
||||||
else
|
else
|
||||||
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; exec ${MOSAIC_AGENT_COMMAND}"
|
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export MOSAIC_AGENT_TOOL_POLICY=${AGENT_TOOL_POLICY_Q}; exec ${MOSAIC_AGENT_COMMAND}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "$MOSAIC_AGENT_WORKDIR"
|
mkdir -p "$MOSAIC_AGENT_WORKDIR"
|
||||||
|
|||||||
19
packages/mosaic/framework/tools/fleet/start-interaction-service.sh
Executable file
19
packages/mosaic/framework/tools/fleet/start-interaction-service.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
AGENT_NAME=${1:-}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "ERROR: $*" >&2
|
||||||
|
exit 64
|
||||||
|
}
|
||||||
|
|
||||||
|
[ -n "$AGENT_NAME" ] || fail "agent name argument is required"
|
||||||
|
[[ "$AGENT_NAME" =~ ^[A-Za-z0-9_.-]+$ ]] || fail "agent name contains unsupported characters"
|
||||||
|
[ "${MOSAIC_AGENT_NAME:-}" = "$AGENT_NAME" ] || fail "configured agent name must exactly match the service instance"
|
||||||
|
[ "${MOSAIC_AGENT_RUNTIME:-}" = "pi" ] || fail "operator interaction service requires runtime pi"
|
||||||
|
[ "${MOSAIC_AGENT_MODEL:-}" = "openai/gpt-5.6-sol" ] || fail "operator interaction service requires the pinned model"
|
||||||
|
[ "${MOSAIC_AGENT_REASONING:-}" = "high" ] || fail "operator interaction service requires high reasoning"
|
||||||
|
[ "${MOSAIC_AGENT_TOOL_POLICY:-}" = "operator-interaction" ] || fail "operator interaction service requires the operator-interaction tool policy"
|
||||||
|
|
||||||
|
exec "$(cd -- "$(dirname -- "$0")" && pwd)/start-agent-session.sh" "$AGENT_NAME"
|
||||||
@@ -105,6 +105,7 @@ MOSAIC_TMUX_SOCKET="$SOCKET3" \
|
|||||||
MOSAIC_AGENT_WORKDIR="$WORKDIR3" \
|
MOSAIC_AGENT_WORKDIR="$WORKDIR3" \
|
||||||
MOSAIC_AGENT_RUNTIME="pi" \
|
MOSAIC_AGENT_RUNTIME="pi" \
|
||||||
MOSAIC_AGENT_CLASS="code" \
|
MOSAIC_AGENT_CLASS="code" \
|
||||||
|
MOSAIC_AGENT_TOOL_POLICY="operator-interaction" \
|
||||||
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN" \
|
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN" \
|
||||||
MOSAIC_AGENT_COMMAND="mosaic yolo pi --model openai-codex/gpt-5.5:high" \
|
MOSAIC_AGENT_COMMAND="mosaic yolo pi --model openai-codex/gpt-5.5:high" \
|
||||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR3" \
|
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR3" \
|
||||||
@@ -139,6 +140,8 @@ echo "$all_args" | grep -qF "export MOSAIC_AGENT_NAME=" || \
|
|||||||
fail "pane command does not export MOSAIC_AGENT_NAME into the pane"
|
fail "pane command does not export MOSAIC_AGENT_NAME into the pane"
|
||||||
echo "$all_args" | grep -qF "export MOSAIC_AGENT_CLASS=code" || \
|
echo "$all_args" | grep -qF "export MOSAIC_AGENT_CLASS=code" || \
|
||||||
fail "pane command does not export MOSAIC_AGENT_CLASS into the pane (persona would silently drop)"
|
fail "pane command does not export MOSAIC_AGENT_CLASS into the pane (persona would silently drop)"
|
||||||
|
echo "$all_args" | grep -qF "export MOSAIC_AGENT_TOOL_POLICY=operator-interaction" || \
|
||||||
|
fail "pane command does not export MOSAIC_AGENT_TOOL_POLICY into the pane"
|
||||||
|
|
||||||
# ── Test 4: when no extra runtime-bin dirs exist, exec still appears ───────────
|
# ── Test 4: when no extra runtime-bin dirs exist, exec still appears ───────────
|
||||||
TMUX_ARGS_FILE2=$(mktemp)
|
TMUX_ARGS_FILE2=$(mktemp)
|
||||||
|
|||||||
@@ -165,6 +165,19 @@ describe('composeContract — overlay composer', () => {
|
|||||||
expect(composeContract('codex', fixture.home)).not.toContain('# pi runtime contract');
|
expect(composeContract('codex', fixture.home)).not.toContain('# pi runtime contract');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('injects the configured operator-interaction tool policy into the runtime contract', () => {
|
||||||
|
const previous = process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||||
|
try {
|
||||||
|
process.env['MOSAIC_AGENT_TOOL_POLICY'] = 'operator-interaction';
|
||||||
|
const out = composeContract('pi', fixture.home);
|
||||||
|
expect(out).toContain('# Fleet Tool Policy (operator-interaction)');
|
||||||
|
expect(out).toContain('Denied by default: coding/general orchestration claims');
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||||
|
else process.env['MOSAIC_AGENT_TOOL_POLICY'] = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Persona contract injection (A3b) ──────────────────────────────────────
|
// ── Persona contract injection (A3b) ──────────────────────────────────────
|
||||||
// composeContract reads MOSAIC_AGENT_CLASS and injects the resolved persona
|
// composeContract reads MOSAIC_AGENT_CLASS and injects the resolved persona
|
||||||
// (override-aware). Save/restore the env so these tests don't leak state.
|
// (override-aware). Save/restore the env so these tests don't leak state.
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ interface RawFleetRoster {
|
|||||||
workingDirectory?: unknown;
|
workingDirectory?: unknown;
|
||||||
model_hint?: unknown;
|
model_hint?: unknown;
|
||||||
modelHint?: unknown;
|
modelHint?: unknown;
|
||||||
|
reasoning_level?: unknown;
|
||||||
|
reasoningLevel?: unknown;
|
||||||
|
tool_policy?: unknown;
|
||||||
|
toolPolicy?: unknown;
|
||||||
persistent_persona?: unknown;
|
persistent_persona?: unknown;
|
||||||
persistentPersona?: unknown;
|
persistentPersona?: unknown;
|
||||||
reset_between_tasks?: unknown;
|
reset_between_tasks?: unknown;
|
||||||
@@ -102,6 +106,8 @@ export interface FleetAgent {
|
|||||||
className: string;
|
className: string;
|
||||||
workingDirectory?: string;
|
workingDirectory?: string;
|
||||||
modelHint?: string;
|
modelHint?: string;
|
||||||
|
reasoningLevel?: string;
|
||||||
|
toolPolicy?: string;
|
||||||
persistentPersona?: boolean | string;
|
persistentPersona?: boolean | string;
|
||||||
resetBetweenTasks?: boolean;
|
resetBetweenTasks?: boolean;
|
||||||
kickstartTemplate?: string;
|
kickstartTemplate?: string;
|
||||||
@@ -510,6 +516,12 @@ export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string
|
|||||||
// the `mosaic yolo` launch so workers run on the roster's model (e.g. pi on
|
// the `mosaic yolo` launch so workers run on the roster's model (e.g. pi on
|
||||||
// openai-codex/gpt-5.5:high). Empty when the agent declares no model_hint.
|
// openai-codex/gpt-5.5:high). Empty when the agent declares no model_hint.
|
||||||
`MOSAIC_AGENT_MODEL=${shellEnvValue(agent.modelHint ?? '')}`,
|
`MOSAIC_AGENT_MODEL=${shellEnvValue(agent.modelHint ?? '')}`,
|
||||||
|
...(agent.reasoningLevel !== undefined
|
||||||
|
? [`MOSAIC_AGENT_REASONING=${shellEnvValue(agent.reasoningLevel)}`]
|
||||||
|
: []),
|
||||||
|
...(agent.toolPolicy !== undefined
|
||||||
|
? [`MOSAIC_AGENT_TOOL_POLICY=${shellEnvValue(agent.toolPolicy)}`]
|
||||||
|
: []),
|
||||||
`MOSAIC_AGENT_WORKDIR=${shellEnvValue(expandHome(workingDirectory))}`,
|
`MOSAIC_AGENT_WORKDIR=${shellEnvValue(expandHome(workingDirectory))}`,
|
||||||
`MOSAIC_TMUX_SOCKET=${shellEnvValue(roster.tmux.socketName)}`,
|
`MOSAIC_TMUX_SOCKET=${shellEnvValue(roster.tmux.socketName)}`,
|
||||||
'',
|
'',
|
||||||
@@ -2316,13 +2328,35 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
await mkdir(activePaths.agentEnvDir, { recursive: true });
|
await mkdir(activePaths.agentEnvDir, { recursive: true });
|
||||||
|
|
||||||
const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh');
|
const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh');
|
||||||
|
const startInteractionServicePath = join(
|
||||||
|
activePaths.fleetToolsDir,
|
||||||
|
'start-interaction-service.sh',
|
||||||
|
);
|
||||||
|
const printInteractionPolicyPath = join(
|
||||||
|
activePaths.fleetToolsDir,
|
||||||
|
'print-interaction-effective-policy.sh',
|
||||||
|
);
|
||||||
const sendMessagePath = join(activePaths.tmuxToolsDir, 'send-message.sh');
|
const sendMessagePath = join(activePaths.tmuxToolsDir, 'send-message.sh');
|
||||||
const agentSendPath = join(activePaths.tmuxToolsDir, 'agent-send.sh');
|
const agentSendPath = join(activePaths.tmuxToolsDir, 'agent-send.sh');
|
||||||
const executableToolPaths = [startAgentSessionPath, sendMessagePath, agentSendPath];
|
const executableToolPaths = [
|
||||||
|
startAgentSessionPath,
|
||||||
|
startInteractionServicePath,
|
||||||
|
printInteractionPolicyPath,
|
||||||
|
sendMessagePath,
|
||||||
|
agentSendPath,
|
||||||
|
];
|
||||||
await copyFile(
|
await copyFile(
|
||||||
join(frameworkRoot, 'tools', 'fleet', 'start-agent-session.sh'),
|
join(frameworkRoot, 'tools', 'fleet', 'start-agent-session.sh'),
|
||||||
startAgentSessionPath,
|
startAgentSessionPath,
|
||||||
);
|
);
|
||||||
|
await copyFile(
|
||||||
|
join(frameworkRoot, 'tools', 'fleet', 'start-interaction-service.sh'),
|
||||||
|
startInteractionServicePath,
|
||||||
|
);
|
||||||
|
await copyFile(
|
||||||
|
join(frameworkRoot, 'tools', 'fleet', 'print-interaction-effective-policy.sh'),
|
||||||
|
printInteractionPolicyPath,
|
||||||
|
);
|
||||||
await copyFile(join(frameworkRoot, 'tools', 'tmux', 'send-message.sh'), sendMessagePath);
|
await copyFile(join(frameworkRoot, 'tools', 'tmux', 'send-message.sh'), sendMessagePath);
|
||||||
await copyFile(join(frameworkRoot, 'tools', 'tmux', 'agent-send.sh'), agentSendPath);
|
await copyFile(join(frameworkRoot, 'tools', 'tmux', 'agent-send.sh'), agentSendPath);
|
||||||
for (const toolPath of executableToolPaths) {
|
for (const toolPath of executableToolPaths) {
|
||||||
@@ -2336,6 +2370,10 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
join(frameworkRoot, 'systemd', 'user', 'mosaic-agent@.service'),
|
join(frameworkRoot, 'systemd', 'user', 'mosaic-agent@.service'),
|
||||||
join(activePaths.systemdUserDir, 'mosaic-agent@.service'),
|
join(activePaths.systemdUserDir, 'mosaic-agent@.service'),
|
||||||
);
|
);
|
||||||
|
await copyFile(
|
||||||
|
join(frameworkRoot, 'systemd', 'user', 'mosaic-interaction-agent@.service'),
|
||||||
|
join(activePaths.systemdUserDir, 'mosaic-interaction-agent@.service'),
|
||||||
|
);
|
||||||
|
|
||||||
for (const agent of roster.agents) {
|
for (const agent of roster.agents) {
|
||||||
const envPath = join(activePaths.agentEnvDir, `${agent.name}.env`);
|
const envPath = join(activePaths.agentEnvDir, `${agent.name}.env`);
|
||||||
@@ -2462,6 +2500,10 @@ function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): Fle
|
|||||||
'workingDirectory',
|
'workingDirectory',
|
||||||
'model_hint',
|
'model_hint',
|
||||||
'modelHint',
|
'modelHint',
|
||||||
|
'reasoning_level',
|
||||||
|
'reasoningLevel',
|
||||||
|
'tool_policy',
|
||||||
|
'toolPolicy',
|
||||||
'persistent_persona',
|
'persistent_persona',
|
||||||
'persistentPersona',
|
'persistentPersona',
|
||||||
'reset_between_tasks',
|
'reset_between_tasks',
|
||||||
@@ -2493,6 +2535,14 @@ function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): Fle
|
|||||||
raw.model_hint ?? raw.modelHint,
|
raw.model_hint ?? raw.modelHint,
|
||||||
`Fleet roster agent "${name}" model_hint`,
|
`Fleet roster agent "${name}" model_hint`,
|
||||||
),
|
),
|
||||||
|
reasoningLevel: optionalString(
|
||||||
|
raw.reasoning_level ?? raw.reasoningLevel,
|
||||||
|
`Fleet roster agent "${name}" reasoning_level`,
|
||||||
|
),
|
||||||
|
toolPolicy: optionalString(
|
||||||
|
raw.tool_policy ?? raw.toolPolicy,
|
||||||
|
`Fleet roster agent "${name}" tool_policy`,
|
||||||
|
),
|
||||||
persistentPersona: optionalBooleanOrString(
|
persistentPersona: optionalBooleanOrString(
|
||||||
raw.persistent_persona ?? raw.persistentPersona,
|
raw.persistent_persona ?? raw.persistentPersona,
|
||||||
`Fleet roster agent "${name}" persistent_persona`,
|
`Fleet roster agent "${name}" persistent_persona`,
|
||||||
@@ -2783,6 +2833,12 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
|
|||||||
if (agent.modelHint !== undefined) {
|
if (agent.modelHint !== undefined) {
|
||||||
raw['model_hint'] = agent.modelHint;
|
raw['model_hint'] = agent.modelHint;
|
||||||
}
|
}
|
||||||
|
if (agent.reasoningLevel !== undefined) {
|
||||||
|
raw['reasoning_level'] = agent.reasoningLevel;
|
||||||
|
}
|
||||||
|
if (agent.toolPolicy !== undefined) {
|
||||||
|
raw['tool_policy'] = agent.toolPolicy;
|
||||||
|
}
|
||||||
if (agent.persistentPersona !== undefined) {
|
if (agent.persistentPersona !== undefined) {
|
||||||
raw['persistent_persona'] = agent.persistentPersona;
|
raw['persistent_persona'] = agent.persistentPersona;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,6 +395,9 @@ For required push/merge/issue-close/release actions, execute without routine con
|
|||||||
const persona = readPersonaContractBlock(mosaicHome, process.env['MOSAIC_AGENT_CLASS']);
|
const persona = readPersonaContractBlock(mosaicHome, process.env['MOSAIC_AGENT_CLASS']);
|
||||||
if (persona) parts.push('\n\n' + persona);
|
if (persona) parts.push('\n\n' + persona);
|
||||||
|
|
||||||
|
const toolPolicy = readFleetToolPolicyBlock(process.env['MOSAIC_AGENT_TOOL_POLICY']);
|
||||||
|
if (toolPolicy) parts.push('\n\n' + toolPolicy);
|
||||||
|
|
||||||
// Fleet onboarding: when this is a spawned fleet agent (MOSAIC_AGENT_NAME set
|
// Fleet onboarding: when this is a spawned fleet agent (MOSAIC_AGENT_NAME set
|
||||||
// and present in the roster), inject a comms cheat-sheet + peer roster so it
|
// and present in the roster), inject a comms cheat-sheet + peer roster so it
|
||||||
// knows how to reach the orchestrator and its peers from its first turn.
|
// knows how to reach the orchestrator and its peers from its first turn.
|
||||||
@@ -404,6 +407,17 @@ For required push/merge/issue-close/release actions, execute without routine con
|
|||||||
return parts.join('\n');
|
return parts.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readFleetToolPolicyBlock(policy: string | undefined): string {
|
||||||
|
if (policy !== 'operator-interaction') return '';
|
||||||
|
return [
|
||||||
|
'# Fleet Tool Policy (operator-interaction)',
|
||||||
|
'',
|
||||||
|
'Permitted: authorized conversation, status, retrieval, and safe diagnostics.',
|
||||||
|
'Denied by default: coding/general orchestration claims, direct fleet control, destructive actions, and credential access.',
|
||||||
|
'Delegate Mos-owned work through the authorized handoff boundary.',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
/** @deprecated internal alias — use composeContract. Retained for call-site clarity. */
|
/** @deprecated internal alias — use composeContract. Retained for call-site clarity. */
|
||||||
function buildRuntimePrompt(runtime: RuntimeName): string {
|
function buildRuntimePrompt(runtime: RuntimeName): string {
|
||||||
return composeContract(runtime);
|
return composeContract(runtime);
|
||||||
|
|||||||
110
packages/mosaic/src/fleet/interaction-service-profile.ts
Normal file
110
packages/mosaic/src/fleet/interaction-service-profile.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import YAML from 'yaml';
|
||||||
|
import type { FleetAgent } from '../commands/fleet.js';
|
||||||
|
|
||||||
|
const REQUIRED_RUNTIME = 'pi';
|
||||||
|
const REQUIRED_MODEL = 'openai/gpt-5.6-sol';
|
||||||
|
const REQUIRED_REASONING = 'high';
|
||||||
|
const REQUIRED_TOOL_POLICY = 'operator-interaction';
|
||||||
|
const AGENT_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
||||||
|
|
||||||
|
export interface InteractionServiceProfile {
|
||||||
|
runtime: string;
|
||||||
|
model: string;
|
||||||
|
reasoning: string;
|
||||||
|
toolPolicy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EffectiveInteractionPolicy {
|
||||||
|
agentName: string;
|
||||||
|
runtime: string;
|
||||||
|
model: string;
|
||||||
|
reasoning: string;
|
||||||
|
toolPolicy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InteractionServiceProfileError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: 'invalid_profile' | 'invalid_request',
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = InteractionServiceProfileError.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readInteractionServiceProfile(
|
||||||
|
path: string,
|
||||||
|
overrides: Partial<InteractionServiceProfile> = {},
|
||||||
|
): Promise<InteractionServiceProfile> {
|
||||||
|
const parsed: unknown = YAML.parse(await readFile(path, 'utf8'));
|
||||||
|
if (!isRecord(parsed)) {
|
||||||
|
throw new InteractionServiceProfileError(
|
||||||
|
'invalid_profile',
|
||||||
|
'Service profile must be an object',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const profile: InteractionServiceProfile = {
|
||||||
|
runtime: valueOrUndefined(overrides.runtime, parsed['runtime']),
|
||||||
|
model: valueOrUndefined(overrides.model, parsed['model']),
|
||||||
|
reasoning: valueOrUndefined(overrides.reasoning, parsed['reasoning']),
|
||||||
|
toolPolicy: valueOrUndefined(overrides.toolPolicy, parsed['tool_policy']),
|
||||||
|
};
|
||||||
|
assertPinnedPolicy(profile);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function provisionInteractionService(
|
||||||
|
profile: InteractionServiceProfile,
|
||||||
|
input: { agentName: string },
|
||||||
|
): { rosterAgent: FleetAgent; effectivePolicy: EffectiveInteractionPolicy } {
|
||||||
|
assertPinnedPolicy(profile);
|
||||||
|
if (!AGENT_NAME_PATTERN.test(input.agentName)) {
|
||||||
|
throw new InteractionServiceProfileError(
|
||||||
|
'invalid_request',
|
||||||
|
'Agent name must contain only letters, numbers, dots, underscores, or hyphens',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const effectivePolicy: EffectiveInteractionPolicy = {
|
||||||
|
agentName: input.agentName,
|
||||||
|
runtime: profile.runtime,
|
||||||
|
model: profile.model,
|
||||||
|
reasoning: profile.reasoning,
|
||||||
|
toolPolicy: profile.toolPolicy,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
rosterAgent: {
|
||||||
|
name: input.agentName,
|
||||||
|
runtime: profile.runtime,
|
||||||
|
className: profile.toolPolicy,
|
||||||
|
modelHint: profile.model,
|
||||||
|
reasoningLevel: profile.reasoning,
|
||||||
|
toolPolicy: profile.toolPolicy,
|
||||||
|
persistentPersona: true,
|
||||||
|
},
|
||||||
|
effectivePolicy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPinnedPolicy(profile: InteractionServiceProfile): void {
|
||||||
|
const invalid =
|
||||||
|
profile.runtime !== REQUIRED_RUNTIME ||
|
||||||
|
profile.model !== REQUIRED_MODEL ||
|
||||||
|
profile.reasoning !== REQUIRED_REASONING ||
|
||||||
|
profile.toolPolicy !== REQUIRED_TOOL_POLICY;
|
||||||
|
if (invalid) {
|
||||||
|
throw new InteractionServiceProfileError(
|
||||||
|
'invalid_profile',
|
||||||
|
`Service profile must pin ${REQUIRED_RUNTIME}, ${REQUIRED_MODEL}, ${REQUIRED_REASONING}, and ${REQUIRED_TOOL_POLICY}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueOrUndefined(override: string | undefined, value: unknown): string {
|
||||||
|
if (override !== undefined) return override;
|
||||||
|
return typeof value === 'string' ? value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
161
packages/mosaic/src/fleet/tess-service-profile.test.ts
Normal file
161
packages/mosaic/src/fleet/tess-service-profile.test.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { generateAgentEnv, loadFleetRoster } from '../commands/fleet.js';
|
||||||
|
import {
|
||||||
|
provisionInteractionService,
|
||||||
|
readInteractionServiceProfile,
|
||||||
|
} from './interaction-service-profile.js';
|
||||||
|
import type { InteractionServiceProfileError } from './interaction-service-profile.js';
|
||||||
|
|
||||||
|
const frameworkFleet = resolve(
|
||||||
|
dirname(fileURLToPath(import.meta.url)),
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'framework',
|
||||||
|
'fleet',
|
||||||
|
);
|
||||||
|
const profilePath = join(frameworkFleet, 'services', 'operator-interaction.yaml');
|
||||||
|
const examplePath = join(frameworkFleet, 'examples', 'operator-interaction.yaml');
|
||||||
|
const policyScript = join(
|
||||||
|
frameworkFleet,
|
||||||
|
'..',
|
||||||
|
'tools',
|
||||||
|
'fleet',
|
||||||
|
'print-interaction-effective-policy.sh',
|
||||||
|
);
|
||||||
|
const interactionStartScript = join(
|
||||||
|
frameworkFleet,
|
||||||
|
'..',
|
||||||
|
'tools',
|
||||||
|
'fleet',
|
||||||
|
'start-interaction-service.sh',
|
||||||
|
);
|
||||||
|
const agentStartScript = join(frameworkFleet, '..', 'tools', 'fleet', 'start-agent-session.sh');
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
describe('operator interaction service profile', (): void => {
|
||||||
|
it('provisions a user-supplied Nova identity as data with the required effective policy', async (): Promise<void> => {
|
||||||
|
const profile = await readInteractionServiceProfile(profilePath);
|
||||||
|
const provisioned = provisionInteractionService(profile, { agentName: 'Nova' });
|
||||||
|
|
||||||
|
expect(provisioned.rosterAgent).toEqual({
|
||||||
|
name: 'Nova',
|
||||||
|
runtime: 'pi',
|
||||||
|
className: 'operator-interaction',
|
||||||
|
modelHint: 'openai/gpt-5.6-sol',
|
||||||
|
reasoningLevel: 'high',
|
||||||
|
toolPolicy: 'operator-interaction',
|
||||||
|
persistentPersona: true,
|
||||||
|
});
|
||||||
|
expect(provisioned.effectivePolicy).toEqual({
|
||||||
|
agentName: 'Nova',
|
||||||
|
runtime: 'pi',
|
||||||
|
model: 'openai/gpt-5.6-sol',
|
||||||
|
reasoning: 'high',
|
||||||
|
toolPolicy: 'operator-interaction',
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(provisioned.effectivePolicy)).not.toMatch(
|
||||||
|
/token|secret|credential|api.?key/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a renamed example roster and surfaces only its effective policy', async (): Promise<void> => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'mosaic-interaction-profile-'));
|
||||||
|
const rosterPath = join(directory, 'roster.yaml');
|
||||||
|
try {
|
||||||
|
const example = await readFile(examplePath, 'utf8');
|
||||||
|
await writeFile(rosterPath, example.replace('name: Tess', 'name: Nova'), 'utf8');
|
||||||
|
const roster = await loadFleetRoster(rosterPath);
|
||||||
|
const agent = roster.agents[0]!;
|
||||||
|
|
||||||
|
expect(agent.name).toBe('Nova');
|
||||||
|
expect(agent.reasoningLevel).toBe('high');
|
||||||
|
expect(agent.toolPolicy).toBe('operator-interaction');
|
||||||
|
expect(generateAgentEnv(roster, agent)).toContain('MOSAIC_AGENT_NAME=Nova');
|
||||||
|
expect(generateAgentEnv(roster, agent)).toContain('MOSAIC_AGENT_REASONING=high');
|
||||||
|
|
||||||
|
const { stdout } = await execFileAsync(policyScript, [], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
MOSAIC_AGENT_NAME: agent.name,
|
||||||
|
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||||
|
MOSAIC_AGENT_MODEL: agent.modelHint,
|
||||||
|
MOSAIC_AGENT_REASONING: agent.reasoningLevel,
|
||||||
|
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(JSON.parse(stdout)).toEqual({
|
||||||
|
agentName: 'Nova',
|
||||||
|
runtime: 'pi',
|
||||||
|
model: 'openai/gpt-5.6-sol',
|
||||||
|
reasoning: 'high',
|
||||||
|
toolPolicy: 'operator-interaction',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails before launch when service environment drifts from the pinned policy', async (): Promise<void> => {
|
||||||
|
await expect(
|
||||||
|
execFileAsync(interactionStartScript, ['Nova'], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
MOSAIC_AGENT_NAME: 'Nova',
|
||||||
|
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||||
|
MOSAIC_AGENT_MODEL: 'other/model',
|
||||||
|
MOSAIC_AGENT_REASONING: 'high',
|
||||||
|
MOSAIC_AGENT_TOOL_POLICY: 'operator-interaction',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 64 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsafe names from the policy printer and reasoning before tmux launch', async (): Promise<void> => {
|
||||||
|
await expect(
|
||||||
|
execFileAsync(policyScript, [], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
MOSAIC_AGENT_NAME: 'Nova"bad',
|
||||||
|
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||||
|
MOSAIC_AGENT_MODEL: 'openai/gpt-5.6-sol',
|
||||||
|
MOSAIC_AGENT_REASONING: 'high',
|
||||||
|
MOSAIC_AGENT_TOOL_POLICY: 'operator-interaction',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 64 });
|
||||||
|
await expect(
|
||||||
|
execFileAsync(agentStartScript, ['Nova'], {
|
||||||
|
env: { ...process.env, MOSAIC_AGENT_REASONING: 'high; id' },
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 64 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the product name confined to an example instance, not service source or defaults', async (): Promise<void> => {
|
||||||
|
const [profile, source, example] = await Promise.all([
|
||||||
|
readFile(profilePath, 'utf8'),
|
||||||
|
readFile(new URL('./interaction-service-profile.ts', import.meta.url), 'utf8'),
|
||||||
|
readFile(examplePath, 'utf8'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(profile).not.toMatch(/tess/i);
|
||||||
|
expect(source).not.toMatch(/tess/i);
|
||||||
|
expect(example).toMatch(/name: Tess/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails fast when a required policy field is absent or changed from the pinned policy', async (): Promise<void> => {
|
||||||
|
await expect(readInteractionServiceProfile(profilePath, { model: '' })).rejects.toMatchObject({
|
||||||
|
code: 'invalid_profile',
|
||||||
|
} satisfies Partial<InteractionServiceProfileError>);
|
||||||
|
await expect(
|
||||||
|
readInteractionServiceProfile(profilePath, { reasoning: 'medium' }),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'invalid_profile',
|
||||||
|
} satisfies Partial<InteractionServiceProfileError>);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export const VERSION = '0.0.0';
|
export const VERSION = '0.0.0';
|
||||||
|
|
||||||
|
export * from './fleet/interaction-service-profile.js';
|
||||||
export * from './fleet/tmux-runtime-transport.js';
|
export * from './fleet/tmux-runtime-transport.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
Reference in New Issue
Block a user