Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile.advisor
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ ENV PATH="/opt/senpai-venv/bin:/home/senpai/.local/bin:${PATH}" \

RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates curl gettext-base git gnupg procps tmux && \
ca-certificates curl git gnupg procps tmux && \
groupadd --gid 10001 senpai && \
useradd --uid 10001 --gid 10001 --create-home senpai && \
rm -rf /var/lib/apt/lists/*
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile.student
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ ENV PATH="/opt/senpai-venv/bin:/home/senpai/.local/bin:${PATH}" \

RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates curl gettext-base git gnupg procps && \
ca-certificates curl git gnupg procps && \
groupadd --gid 10001 senpai && \
useradd --uid 10001 --gid 10001 --create-home senpai && \
rm -rf /var/lib/apt/lists/*
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ Pod startup and liveness probes read the supervisor lease. Container restarts re

GitHub coordination works across Docker, cloud VMs, or local hosts without private networking. The current repository does not yet provide a Compose or direct-host launcher: the Kubernetes manifests perform the source clone, environment assembly, skill installation, token handoff, mounts, and entrypoint selection.

To build another launcher, reproduce [entrypoint-advisor.sh](k8s/entrypoint-advisor.sh) or [entrypoint-student.sh](k8s/entrypoint-student.sh), render `SENPAI-LAUNCH-CONTEXT.md` with `render_launch_context`, and provide it as base64 in `SENPAI_LAUNCH_CONTEXT_B64`; keep optional operator guidance in `EXTRA_INSTRUCTIONS_B64`. Persist `/var/lib/senpai/<tag>/advisor` for the advisor and use the container healthcheck with a restart policy. Student execution requires Linux, an NVIDIA runtime, and compatible CUDA hardware; Docker Desktop on macOS cannot run the GPU student image.
To build another launcher, reproduce [entrypoint-advisor.sh](k8s/entrypoint-advisor.sh) or [entrypoint-student.sh](k8s/entrypoint-student.sh), render `SENPAI-LAUNCH-CONTEXT.md` with `render_launch_context`, and provide it as base64 in `SENPAI_LAUNCH_CONTEXT_B64`. Pass the built-in role template and its explicit identity values to the Python supervisor, which renders the non-secret identity once and persists that role snapshot. Keep optional operator guidance in `EXTRA_INSTRUCTIONS_B64`. Persist `/var/lib/senpai/<tag>/advisor` for the advisor and use the container healthcheck with a restart policy. Student execution requires Linux, an NVIDIA runtime, and compatible CUDA hardware; Docker Desktop on macOS cannot run the GPU student image.

## Development and reference

Expand Down
35 changes: 29 additions & 6 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,13 +184,36 @@ The model receives:
1. OpenHands' native base system prompt and tool schemas.
2. One stable system suffix assembled from:
- `system_instructions/SENPAI-HARNESS.md`; and
- the rendered advisor or student role charter; and
- the selected target-repository `program.md` under `# program.md - <path>`; and
- the rendered `system_instructions/SENPAI-LAUNCH-CONTEXT.md`, containing authoritative runtime and isolation rules after `program.md`. A blank `program_path` searches root `program.md` and one-level `*/program.md` paths and requires exactly one total match.
- the rendered advisor or student role charter, including its non-secret
runtime identity; and
- the selected target-repository `program.md` under
`# program.md - <path>`; and
- the rendered `system_instructions/SENPAI-LAUNCH-CONTEXT.md`, containing
authoritative runtime and isolation rules after `program.md`. A blank
`program_path` searches root
`program.md` and one-level `*/program.md` paths and requires exactly one
total match.
3. Explicit project and Senpai skills through OpenHands skill context. Agent Skills bodies are loaded only when invoked. Repository `AGENTS.md`, `AGENT.md`, and `CLAUDE.md` instruction files are not loaded as project context.
4. User turns containing optional human operator instructions, runtime identity, current state, and current UTC time.

At process startup, the runner loads the harness, selected role, `program.md`, and authoritative launch context once into one immutable `SenpaiSystemInstructions` value. Its prompt is the stable system suffix for that process and is never reread, monitored, or refreshed during the agent session. Before constructing a model worker, the supervisor resolves the configured program path or fails with the missing or ambiguous candidates. Delegated children inherit the resolved repository-relative path and exact launch context, then build their own immutable value when their process starts. Optional operator instructions remain user context. Use GitHub Issues for live human direction. OpenHands includes the system suffix on every inference, and current time is rendered for every controller wake.
4. User turns containing optional human operator instructions, current state, and current UTC time.

Before constructing a model worker, the supervisor resolves the configured
program path and renders the role's `{{VARIABLE}}` placeholders once from an
explicit non-secret allowlist. A missing referenced value fails the launch;
unrelated environment variables and credentials are never considered. The
rendered role is persisted in role state and reused across worker restarts.

At process startup, the runner loads the harness, rendered role, `program.md`,
and authoritative launch context into one immutable
`SenpaiSystemInstructions` value. Its prompt is the stable system suffix for
that process and is never reread, monitored, or refreshed during the agent
session. Delegated children inherit the rendered role snapshot, resolved
repository-relative program path, and exact launch context, then build their
own immutable value. Runtime identity and `program.md` are not duplicated in
ordinary user messages. Optional operator instructions remain user context;
use GitHub Issues for live human direction. OpenHands includes the system
suffix on every inference, and current time is rendered for every controller
wake. Operators must start fresh role state to apply a changed identity,
program, or role charter.

File-based subagents are discovered from `.agents/agents`. Skill bodies are not
concatenated into agent definitions. The OpenHands fork's `main` branch applies
Expand Down
5 changes: 1 addition & 4 deletions k8s/entrypoint-advisor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,7 @@ if [ -z "${GITHUB_TOKEN:-}" ] && [ -n "${SENPAI_GITHUB_TOKEN_FILE:-}" ]; then
fi
: "${GITHUB_TOKEN:?GitHub bootstrap token is required}"
export SENPAI_OPENHANDS_STATE_DIR="$LOGDIR/openhands_state"
export SENPAI_OPENHANDS_ROLE_FILE="$LOGDIR/ADVISOR.md"
envsubst '$PROBLEM_DIR $TARGET_REPO_URL $GH_REPO $ADVISOR_BRANCH $RESEARCH_TAG $GPUS_PER_STUDENT $WANDB_ENTITY $WANDB_PROJECT' \
< "$WORKDIR/system_instructions/ADVISOR.md" \
> "$SENPAI_OPENHANDS_ROLE_FILE"
export SENPAI_OPENHANDS_ROLE_FILE="$WORKDIR/system_instructions/ADVISOR.md"

echo "=== Senpai Advisor ==="
echo "Runner repo: $SENPAI_REPO_URL (revision: $SENPAI_REPO_REVISION)"
Expand Down
5 changes: 1 addition & 4 deletions k8s/entrypoint-student.sh
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,7 @@ ls \
export IS_SANDBOX=1

export SENPAI_OPENHANDS_STATE_DIR="$LOGDIR/openhands_state"
export SENPAI_OPENHANDS_ROLE_FILE="$LOGDIR/STUDENT.md"
envsubst '$PROBLEM_DIR $TARGET_REPO_URL $GH_REPO $ADVISOR_BRANCH $RESEARCH_TAG $STUDENT_NAME $GPUS_PER_STUDENT $WANDB_ENTITY $WANDB_PROJECT' \
< "$WORKDIR/system_instructions/STUDENT.md" \
> "$SENPAI_OPENHANDS_ROLE_FILE"
export SENPAI_OPENHANDS_ROLE_FILE="$WORKDIR/system_instructions/STUDENT.md"
export SENPAI_OPENHANDS_WORKSPACE="$TARGET_WORKDIR"
export SENPAI_OPENHANDS_HARNESS_FILE="$WORKDIR/system_instructions/SENPAI-HARNESS.md"
export SENPAI_OPENHANDS_TIMEOUT_SECONDS="${SENPAI_OPENHANDS_TIMEOUT_SECONDS:-3600}"
Expand Down
8 changes: 0 additions & 8 deletions senpai_agent/PROMPTS.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,6 @@

{{INSTRUCTIONS}}"""

ADVISOR_RUNTIME_IDENTITY_PROMPT = """# Runtime identity

Role: advisor; repository: {{REPOSITORY}}; advisor branch: {{ADVISOR_BRANCH}}; W&B: {{WANDB_ENTITY}}/{{WANDB_PROJECT}}. Students: {{STUDENT_NAMES}}."""

STUDENT_RUNTIME_IDENTITY_PROMPT = """# Runtime identity

Role: student; repository: {{REPOSITORY}}; advisor branch: {{ADVISOR_BRANCH}}; W&B: {{WANDB_ENTITY}}/{{WANDB_PROJECT}}. Student: {{STUDENT_NAME}}."""

SENPAI_SYSTEM_INSTRUCTIONS_PROMPT = """# Senpai harness

{{HARNESS}}
Expand Down
57 changes: 16 additions & 41 deletions senpai_agent/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,7 @@
from uuid import UUID

from senpai_agent.agent_markdown import strip_spdx_header
from senpai_agent.advisor import (
AdvisorEvent,
AdvisorEventStore,
)
from senpai_agent.advisor import AdvisorEvent, AdvisorEventStore
from senpai_agent.github.mailbox import ActiveGitHubWatcher, GitHubMailbox
from senpai_agent.inbox import (
DeliveryState,
Expand All @@ -41,12 +38,10 @@
WandbMetricSource,
)
from senpai_agent.PROMPTS import (
ADVISOR_RUNTIME_IDENTITY_PROMPT,
CONTEXT_RECOVERY_PROMPT,
CONTINUATION_CONTROLLER_PROMPT,
INITIAL_CONTROLLER_PROMPT,
OPERATOR_INSTRUCTIONS_PROMPT,
STUDENT_RUNTIME_IDENTITY_PROMPT,
render_prompt,
)
from senpai_agent.state import (
Expand Down Expand Up @@ -110,7 +105,11 @@ def _is_context_history_failure(error: Exception) -> bool:


def _context_recovery_prompt(full_prompt: str, current_prompt: str) -> str:
initial_context = "" if full_prompt in current_prompt else f"{full_prompt}\n\n"
initial_context = (
""
if not full_prompt or full_prompt in current_prompt
else f"{full_prompt}\n\n"
)
return initial_context + render_prompt(
CONTEXT_RECOVERY_PROMPT,
CURRENT_PROMPT=current_prompt,
Expand All @@ -128,8 +127,6 @@ def __init__(
):
self.config = config
self.full_prompt = full_prompt.strip()
if not self.full_prompt:
raise ValueError("full prompt must not be empty")
self.github_mailbox = github_mailbox
self.active_poll_interval_seconds = active_poll_interval_seconds

Expand Down Expand Up @@ -706,45 +703,23 @@ def _prompt(
INITIAL_CONTROLLER_PROMPT,
FULL_PROMPT=self.full_prompt,
CURRENT_TIME=now,
)
).lstrip()
return render_prompt(
CONTINUATION_CONTROLLER_PROMPT,
ROLE=self.role,
CURRENT_TIME=now,
)


def _full_prompt(role: Literal["advisor", "student"], env: Mapping[str, str]) -> str:
sections = []
def _full_prompt(env: Mapping[str, str]) -> str:
encoded_extra = env.get("EXTRA_INSTRUCTIONS_B64")
if encoded_extra:
extra = b64decode(encoded_extra, validate=True).decode()
sections.append(
render_prompt(
OPERATOR_INSTRUCTIONS_PROMPT,
INSTRUCTIONS=strip_spdx_header(extra).strip(),
)
)
if role == "advisor":
identity = render_prompt(
ADVISOR_RUNTIME_IDENTITY_PROMPT,
REPOSITORY=env["GH_REPO"],
ADVISOR_BRANCH=env["ADVISOR_BRANCH"],
WANDB_ENTITY=env["WANDB_ENTITY"],
WANDB_PROJECT=env["WANDB_PROJECT"],
STUDENT_NAMES=env.get("STUDENT_NAMES", ""),
)
else:
identity = render_prompt(
STUDENT_RUNTIME_IDENTITY_PROMPT,
REPOSITORY=env["GH_REPO"],
ADVISOR_BRANCH=env["ADVISOR_BRANCH"],
WANDB_ENTITY=env["WANDB_ENTITY"],
WANDB_PROJECT=env["WANDB_PROJECT"],
STUDENT_NAME=env["STUDENT_NAME"],
)
sections.append(identity)
return "\n\n".join(sections)
if not encoded_extra:
return ""
extra = b64decode(encoded_extra, validate=True).decode()
return render_prompt(
OPERATOR_INSTRUCTIONS_PROMPT,
INSTRUCTIONS=strip_spdx_header(extra).strip(),
)


def _role_interval(
Expand Down Expand Up @@ -862,7 +837,7 @@ def controller_main(
token=runner_config.github_token,
)

full_prompt = _full_prompt(role, env)
full_prompt = _full_prompt(env)
inbox = PersistentInbox(
runner_config.state_dir / "delivery-inbox.sqlite3",
legacy_path=runner_config.state_dir / "pending-message-deliveries.json",
Expand Down
44 changes: 39 additions & 5 deletions senpai_agent/launch_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,59 @@

import binascii
import re
from collections.abc import Mapping
from base64 import b64decode
from pathlib import Path
from typing import Literal

from senpai_agent.agent_markdown import read_agent_markdown, strip_spdx_header

INSTRUCTIONS_ROOT = Path(__file__).resolve().parent.parent / "system_instructions"
LAUNCH_CONTEXT_TEMPLATE = INSTRUCTIONS_ROOT / "SENPAI-LAUNCH-CONTEXT.md"
LAUNCH_CONTEXT_ENV = "SENPAI_LAUNCH_CONTEXT_B64"
PLACEHOLDER = re.compile(r"{{([A-Z_]+)}}")
PLACEHOLDER = re.compile(r"{{([A-Z_][A-Z0-9_]*)}}")
ROLE_TEMPLATE_VALUES = {
"advisor": (
"GH_REPO",
"ADVISOR_BRANCH",
"WANDB_ENTITY",
"WANDB_PROJECT",
"STUDENT_NAMES",
),
"student": (
"GH_REPO",
"ADVISOR_BRANCH",
"WANDB_ENTITY",
"WANDB_PROJECT",
"STUDENT_NAME",
),
}


def _render(path: Path, values: dict[str, str]) -> str:
def _render(path: Path, values: Mapping[str, str]) -> str:
template = read_agent_markdown(path)
missing = sorted(set(PLACEHOLDER.findall(template)) - values.keys())
if missing:
raise ValueError(f"Missing {path.name} values: {', '.join(missing)}")
for key, value in values.items():
template = template.replace(f"{{{{{key}}}}}", value)
return template.strip()
return PLACEHOLDER.sub(lambda match: values[match.group(1)], template).strip()


def render_role_prompt(
path: Path,
role: Literal["advisor", "student"],
env: Mapping[str, str],
) -> str:
"""Render one role charter from explicitly allowlisted non-secret values."""

values = {
"ROLE": role,
**{
name: env[name]
for name in ROLE_TEMPLATE_VALUES[role]
if env.get(name)
},
}
return _render(path, values)


def render_launch_context(
Expand Down
36 changes: 33 additions & 3 deletions senpai_agent/supervisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Literal

import psutil
from pydantic import SecretStr

from senpai_agent.launch_context import render_role_prompt
from senpai_agent.processes import terminate_process_group
from senpai_agent.program_context import (
PROGRAM_PATH_ENV,
Expand Down Expand Up @@ -346,7 +348,11 @@ def supervisor_main(
return 0 if lease_is_healthy(args.lease_path) else 1

state_dir = Path(env["SENPAI_OPENHANDS_STATE_DIR"]).resolve()
worker_environment = prepare_program_context_environment(env)
worker_environment = prepare_system_context_environment(
args.command,
state_dir,
env,
)
github_token = _consume_github_token(env)
stop = threading.Event()

Expand Down Expand Up @@ -374,21 +380,45 @@ def request_stop(_signum: int, _frame: object) -> None:
signal.signal(signum, handler)


def prepare_program_context_environment(
def prepare_system_context_environment(
role: Literal["advisor", "student"],
state_dir: Path,
env: Mapping[str, str],
) -> dict[str, str]:
"""Resolve program.md before any model process starts."""
"""Snapshot the stable system context before any model process starts."""

environment = dict(env)
program = load_program_system_prompt(
Path(environment["SENPAI_OPENHANDS_WORKSPACE"]),
environment.get(PROGRAM_PATH_ENV, ""),
)
environment[PROGRAM_PATH_ENV] = program.program_path
role_prompt = state_dir / "system-instructions" / f"{role}.md"
if role_prompt.exists():
if not role_prompt.read_text(encoding="utf-8").strip():
raise RuntimeError(f"persisted role prompt is empty: {role_prompt}")
else:
source_value = environment.get("SENPAI_OPENHANDS_ROLE_FILE")
if not source_value:
raise RuntimeError(
"OpenHands role instructions are required; set "
"SENPAI_OPENHANDS_ROLE_FILE"
)
rendered = render_role_prompt(
Path(source_value).resolve(),
role,
environment,
)
role_prompt.parent.mkdir(parents=True, exist_ok=True)
temporary = role_prompt.with_suffix(".tmp")
temporary.write_text(f"{rendered}\n", encoding="utf-8")
temporary.replace(role_prompt)
environment["SENPAI_OPENHANDS_ROLE_FILE"] = str(role_prompt)
print(
f"SENPAI_PROGRAM_CONTEXT path={program.program_path}",
flush=True,
)
print(f"SENPAI_ROLE_PROMPT path={role_prompt}", flush=True)
return environment


Expand Down
8 changes: 8 additions & 0 deletions system_instructions/ADVISOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ You are the senior research lead for autonomous ML research. You develop hypothe

Read the `program.md` identified in your system prompt before acting. It defines the research objective, metric direction, training constraints, protected files, and operating rules.

## Runtime identity

- Role: `{{ROLE}}`
- GitHub repository: `{{GH_REPO}}`
- Advisor branch: `{{ADVISOR_BRANCH}}`
- W&B project: `{{WANDB_ENTITY}}/{{WANDB_PROJECT}}`
- Students: `{{STUDENT_NAMES}}`

## Your Identity

You are a senior researcher at a top ML lab. You oversee students who have access to expensive GPUs, and keeping those GPUs productively occupied is part of your responsibility. An idle GPU represents a missed research opportunity.
Expand Down
8 changes: 8 additions & 0 deletions system_instructions/STUDENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ You implement one assigned experiment, run it safely, and report complete, repro

Read the `program.md` identified in your system prompt, plus the assigned PR body and every PR comment and review before editing. Together they define the hypothesis, allowed files, metric contract, run limits, and any requested revision.

## Runtime identity

- Role: `{{ROLE}}`
- GitHub repository: `{{GH_REPO}}`
- Advisor branch: `{{ADVISOR_BRANCH}}`
- W&B project: `{{WANDB_ENTITY}}/{{WANDB_PROJECT}}`
- Student: `{{STUDENT_NAME}}`

## Boundaries

- Work only on the assigned PR and branch. Do not invent another assignment, branch, or PR.
Expand Down
Loading
Loading