diff --git a/.agents/agents/search.md b/.agents/agents/search.md index 72d16b0db..491eace99 100644 --- a/.agents/agents/search.md +++ b/.agents/agents/search.md @@ -4,9 +4,6 @@ description: | Use for external research through the explicit search_general_web task form for current public sources, or search_research_publications for scholarly literature through Exa and primary papers. - - Find the current API behavior in official documentation. - Survey publications on conservative neural operators for CFD. model: inherit reasoning_effort: inherit permission_mode: never_confirm diff --git a/.agents/skills/exa-search/SKILL.md b/.agents/skills/exa-search/SKILL.md index f75305bdf..1586db8a9 100644 --- a/.agents/skills/exa-search/SKILL.md +++ b/.agents/skills/exa-search/SKILL.md @@ -18,7 +18,7 @@ python "$HOME/.agents/skills/exa-search/scripts/search_exa.py" \ ```bash python "$HOME/.agents/skills/exa-search/scripts/search_exa.py" \ research-publications \ - "mesh-based neural operators for aerodynamic surrogate modeling" + "uncertainty calibration for neural networks" ``` The script returns Markdown rather than raw JSON. Each result contains a direct diff --git a/.agents/skills/senpai-status-check/SKILL.md b/.agents/skills/senpai-status-check/SKILL.md index cad37d303..2824084b1 100644 --- a/.agents/skills/senpai-status-check/SKILL.md +++ b/.agents/skills/senpai-status-check/SKILL.md @@ -9,9 +9,7 @@ description: Produce a fresh, read-only status report for a Senpai research trac # Senpai status check -Report what the configured research programme and fleet are doing now. Keep -scientific progress separate from infrastructure liveness, and distinguish -observed evidence from inference. +Report progress against the configured `program.md` and fleet activity now. Keep scientific progress separate from infrastructure liveness, and distinguish observed evidence from inference. ## Establish scope @@ -29,9 +27,7 @@ Resolve these values from the environment before collecting evidence: If a required value is absent, record an evidence gap. Never substitute a remembered repository, branch, cluster, project, dataset, or metric. -Read `$TARGET_WORKDIR/program.md` for the programme's goals, metric contracts, -benchmarks, training constraints, and permitted reporting paths. Derive every -metric and benchmark in the report from that file or live evidence. +Read the `program.md` identified in the system prompt for its goals, metric contracts, benchmarks, training constraints, and permitted reporting paths. Derive every metric and benchmark in the report from that file or live evidence. ## Collect bounded evidence diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml deleted file mode 100644 index e2fefa19b..000000000 --- a/.github/workflows/pages.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Deploy GitHub Pages - -on: - push: - branches: - - main - paths: - - "docs/**" - - ".github/workflows/pages.yml" - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: github-pages - cancel-in-progress: false - -jobs: - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - - name: Configure Pages - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 - with: - enablement: true - - - name: Upload artifact - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 - with: - path: docs - - - name: Deploy - id: deployment - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/AGENTS.md b/AGENTS.md index f81703730..8a2b36dbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,15 +4,36 @@ SPDX-License-Identifier: Apache-2.0 SPDX-PackageName: senpai --> -# senpai - Development Context +# senpai - Project Context -Development of a problem-agnostic autonomous ML research loop for target ML -problem repositories. The current research programs are often CFD surrogate -experiments, but the runner should stay target-repo agnostic. +## Senpai users -## User Clarifications +### Creating a target program.md + +When helping a user onboard a target repository, inspect an explicitly configured `program_path` first. When it is blank, look for `program.md` at the root and exactly one directory below it. If there is no usable file, coach the user through creating one. Inspect the repository before interviewing them, establish facts yourself, ask the user to decide the remaining intent and tradeoffs, and wait for shared understanding before drafting the file. Multiple auto-discovered files are ambiguous; do not choose one silently. + +`program.md` is appended to every Senpai model's system prompt, so keep it concise, plain-language, and high-signal. It should clearly define: + +- the project goal and the exact primary metrics, including how each metric is calculated, which direction is better, and which split or benchmark decides success; +- the data paths, shapes, sizes, train/validation/test splits, exclusions, leakage risks, and important footguns; +- operational guardrails such as commands, budgets, allowed edits, protected artifacts, and result-reporting expectations; and +- optional research avenues, papers, models, and libraries that provide useful starting points without forcing a narrow solution path. + +Favor high-level goals and guardrails that let research agents discover the details. Avoid micromanaging methods or over-prompting one idea unless that narrow focus is the user's explicit goal. The [`bootstrap-target`](plugins/senpai/skills/bootstrap-target/SKILL.md) guide and its template can turn the confirmed decisions into the target contract. + +Reference examples: + +- [TandemFoilSet-Balanced](https://github.com/morganmcg1/TandemFoilSet-Balanced/blob/main/program.md) +- [DrivAerML](https://github.com/morganmcg1/DrivAerML/blob/main/program.md) +- [MLXFast challenge](https://github.com/morganmcg1/mlxfast-challenge_senpai/blob/main/senpai/program.md) +- [autoresearch](https://github.com/karpathy/autoresearch/blob/master/program.md) + +## Senpai developers + +Development of a problem-agnostic autonomous ML research loop for target ML problem repositories. The runner and its guidance must stay target-repo agnostic. + +### Clarifying development work -### Interviewing the developer about how to do a task: When asked for a large piece of work that seems vague, consequential, or full of hidden tradeoffs, ask the user detailed clarifying questions about the real implementation choices: technical design, workflow, UX, risks, validation, @@ -20,30 +41,28 @@ operations, and tradeoffs. Prefer non-obvious questions that expose constraints or intent. When the answers change durable project behavior, write the learnings to README.md or SPEC.md as appropriate. - -## Coding guidelines and philosophy +### Coding guidelines and philosophy - You should generate code that is simple and readable. Avoid unnecessary abstractions and complexity. This is a research codebase, so maintainability and clarity matter. - Avoid overly defensive coding. No need for lots of `try`/`except` patterns, fallbacks, or backups. Prefer code that fails clearly when something is wrong so it can be fixed. - Do not add demo-only flags or placeholder CLI options that gate real functionality (e.g., `--run` just to toggle execution); scripts should run their main logic directly. - Adhere to the repository's Python 3.13 runtime. -## Key docs +### Key docs - `README.md` - operator-facing overview, launch examples, and problem-package layout. - `SPEC.md` - target architecture and rewrite contract for the senpai orchestration loop. -- `senpai.yaml` - launch defaults, including the target repo, target branch, advisor branch, and `problem_dir`. -- `$PROBLEM_DIR/program.md` - authoritative target research context, goals, metrics, training constraints, and file boundaries. With the default config this is `target/program.md` after the target repo is cloned. -- `$PROBLEM_DIR/instructions/prompt-advisor.md` - target-specific advisor prompt. -- `$PROBLEM_DIR/instructions/prompt-student.md` - target-specific student prompt. +- `senpai.yaml` - launch defaults for the Senpai runner, target branch, advisor branch, and `problem_dir`; supply the required target repository by CLI or local config. +- `$PROBLEM_DIR/program.md` - conventional authoritative target research context, goals, metrics, training constraints, and file boundaries. A blank `program_path` requires exactly one `program.md` across the repository root and directories one level below; an explicit value selects a target-repository-relative `program.md`. - `system_instructions/SENPAI-HARNESS.md` - shared OpenHands harness contract. - `system_instructions/ADVISOR.md` - advisor role workflow. - `system_instructions/STUDENT.md` - student role workflow. +- `system_instructions/SENPAI-LAUNCH-CONTEXT.md` - authoritative per-launch runtime and isolation rules. -## Architecture +### Architecture - **Runner repo** - this repo. Owns orchestration, Kubernetes launch, role instructions, GitHub helpers, W&B integration, and operational docs. -- **Target repo** - cloned into `$PROBLEM_DIR` from `target_repo_url`. Owns the data code, training code, evaluation code, `program.md`, target prompts, and experiment branches. Agent commits and PRs land in the target repo, not in the runner repo. +- **Target repo** - cloned into `$PROBLEM_DIR` from `target_repo_url`. Owns the data code, training code, evaluation code, `program.md`, project context, and experiment branches. Agent commits and PRs land in the target repo, not in the runner repo. - **Advisor pod** - lightweight, no GPU, keeps one durable OpenHands conversation and uses typed control-plane tools for GitHub and generic child-agent dispatch. @@ -55,22 +74,21 @@ to README.md or SPEC.md as appropriate. - **GitHub Issues** - human-to-agent communication channel. Agents poll for and respond to these alongside their normal PR workflow. - **W&B** - canonical experiment metrics store for training runs, comparisons, and merge decisions. -## k8s layout +### k8s layout - `k8s/advisor-deployment.yaml` / `k8s/student-deployment.yaml` — pod specs - `k8s/entrypoint-advisor.sh` / `k8s/entrypoint-student.sh` — startup scripts - `k8s/launch.py` — helper to template and apply deployments -## system_instructions/ +### system_instructions/ -The OpenHands base prompt is extended with a stable merged suffix from the -shared harness file and one rendered role file: +The OpenHands base prompt is extended with one stable system suffix, assembled in this order: - `system_instructions/SENPAI-HARNESS.md` -- `system_instructions/ADVISOR.md` or - `system_instructions/STUDENT.md` +- `system_instructions/ADVISOR.md` or `system_instructions/STUDENT.md` +- the selected target `program.md`, with its repository-relative path in the header +- the rendered `system_instructions/SENPAI-LAUNCH-CONTEXT.md` + +The runner loads this complete suffix once when the agent process starts and does not refresh it during the session. Optional human operator instructions remain user context. -Target `AGENTS.md`, compatible `CLAUDE.md`, and skills are loaded through -OpenHands project context and progressive disclosure. The checked-in root -`CLAUDE.md` is only a compact pointer to this development context; neither root -file is a pod role instruction. +Target skills are loaded explicitly through OpenHands skill context. Target and runner `AGENTS.md`, `AGENT.md`, or `CLAUDE.md` instruction files are human-facing development context and are not loaded as Senpai project context; the checked-in root `CLAUDE.md` links to this canonical guide. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 923e67b99..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,12 +0,0 @@ - - -# Claude-compatible project context - -Read and follow `AGENTS.md`. It is the canonical development context for this -repository. Runtime advisor and student roles live under `system_instructions/` -and are composed by the OpenHands runner; this compatibility file is not a pod -role prompt. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index e265ea7a7..6505a62df 100644 --- a/README.md +++ b/README.md @@ -76,20 +76,17 @@ The launcher places credentials in a per-launch Kubernetes Secret. During bootst ### 4. Prepare the target repository -The target branch must contain: +The target repository needs one concise `program.md` describing the research goal, metrics, data, constraints, allowed edits, and target-specific guidance. -```text -program.md -instructions/ -├── prompt-advisor.md -└── prompt-student.md -``` +A useful structure is: -- `program.md` defines the research objective, baseline, metrics, benchmark rules, training limits, and allowed edit surface. -- `prompt-advisor.md` adds target-specific experiment-selection and review guidance. -- `prompt-student.md` adds target-specific implementation, training, and reporting guidance. +- `## Mission` — Explain why the research is being run and name the primary target metric or metrics being optimized. +- `## Data` — Describe where the data lives, its type and structure, train/validation/test splits, and important nuances, exclusions, or caveats. +- `## Evaluation` — Define each evaluation metric concretely and, when using W&B, give its exact logged name, such as `val/loss` rather than "validation loss." +- `## Files` — List the key data-loading, preprocessing, training, scoring, and evaluation files, briefly describing each and whether agents may edit it. +- `## Research` — Add useful task or domain background and possible research directions without prescribing a narrow approach that limits the agents' creativity. -Use the [bootstrap-target guide](plugins/senpai/skills/bootstrap-target/SKILL.md) to inspect a new target and create these files. Target `AGENTS.md`, compatible `CLAUDE.md`, and `.agents/skills/` are also loaded through OpenHands project context and progressive disclosure. +Put it at the repository root. If it lives elsewhere, set `program_path` in `senpai.yaml` or pass `--program_path` at launch. Senpai appends the selected file to every agent's system prompt. The target repository must be different from the SENPAI runner repository. @@ -106,6 +103,7 @@ The most important settings are: ```yaml target_repo_branch: main advisor_branch: senpai-research +program_path: "" # auto-discover, or set e.g. senpai/program.md wandb_entity: your-team wandb_project: your-project @@ -141,7 +139,7 @@ uses `WANDB_API_KEY` for auth. The defaults in `senpai.yaml` describe W&B's deployment and should not be copied unchanged into another environment. Every setting can also be overridden on the command line. `--tag` and `--target_repo_url` are required unless your chosen config file supplies them. -Deployments require matching advisor and student image digests, or `sha-<40-character-commit>` tags built from the same SENPAI revision. Digest-pinned images also require the full matching `repo_revision`. The source commit must be fetchable from `repo_url`; PR image checks build but do not publish images. +Deployments require matching advisor and student image digests, or `sha-<40-character-commit>` tags built from the same SENPAI revision. Digest-pinned images also require the full matching `senpai_repo_revision`. The source commit must be fetchable from `senpai_repo_url`; its public default is read-only and needs no PR permission. Override it only when using images built from another SENPAI repository. `target_repo_url` is the separate, required repository where agents create commits and PRs. ### 6. Run preflight @@ -311,8 +309,9 @@ OpenHands receives these as progressively disclosed skills; their bodies are loa | Guide | Purpose | |---|---| -| [Bootstrap a target](plugins/senpai/skills/bootstrap-target/SKILL.md) | Build `program.md` and the advisor/student overlays from a new ML repository. | +| [Bootstrap a target](plugins/senpai/skills/bootstrap-target/SKILL.md) | Build `program.md` from a new ML repository. | | [Assign an experiment](plugins/senpai/skills/assign-experiment/SKILL.md) | Turn a hypothesis into a typed student branch and draft PR. | +| [Delegate subagents](plugins/senpai/skills/delegate-subagents/SKILL.md) | Launch and coordinate bounded parallel research, review, and implementation help. | | [Submit experiment results](plugins/senpai/skills/submit-experiment-results/SKILL.md) | Commit the tested implementation and publish a structured, evidence-backed result. | | [Review an experiment](plugins/senpai/skills/review-experiment/SKILL.md) | Merge a reproducible winner, close a useful negative, or request the missing evidence. | | [Handle human Issues](plugins/senpai/skills/check-human-issues/SKILL.md) | Respond to authenticated human-to-agent messages delivered through GitHub Issues. | @@ -372,12 +371,12 @@ The controller owns cadence, durable events, conversation selection, verified Gi - Still-actionable GitHub state is re-delivered on the configured reminder cadence, which defaults to at least ten minutes even when GitHub is polled more frequently. Immediate post-turn polls deliver changed state but not timed reminders, so a successful research-only turn cannot enter a no-sleep reminder loop. `research_base_changed` is keyed by assignment, revision, PR head, and the exact required/current base pair; each identity or base movement requires a new decision. Merge repeats the live-base check immediately before its mutation, while external base writers still require strict up-to-date branch protection or a merge queue for an atomic guarantee. - Each model request gets one bounded 15-minute attempt. Foreground terminal calls return control within ten minutes for explicit continuation, the whole turn retains its one-hour hard lease, and two consecutive failed turns exit to the supervisor for a clean worker restart. Restart backoff grows across failed workers to a five-minute ceiling; only a successfully acknowledged turn resets that streak, not process uptime or idle sleep. - Every controller prompt, GitHub event, monitor signal, and child result follows one durable `pending -> delivered -> processed` inbox. A provider failure resumes the already-delivered turn without resending it, and a crash after inference performs mailbox acknowledgement without another model call. New events wait behind an unresolved turn in the same conversation; normal drains are FIFO and bounded to 16 events or 64 KiB, while ready conversations take fair turns. -- A newly completed tool observation renews the consecutive retry budget; timeout, error, interruption, state, and delivery events do not. A persisted final response is reconciled even if cancellation left the SDK status paused. After three no-progress attempts or three total hours, Senpai preserves the raw trace and retries one canonical copy on a fresh branch with the complete research brief. If that recovery exhausts the same budget, the turn is durably quarantined, reported as `SENPAI_TURN_QUARANTINED` on every controller start, and excluded from scheduling rather than entering a restart loop. `SENPAI_INBOX_MAX_STALLED_ATTEMPTS`, `SENPAI_INBOX_MAX_TURN_AGE_SECONDS`, and `SENPAI_INBOX_MAX_RECOVERY_GENERATIONS` configure these positive attempt/age limits and the non-negative number of fresh branches. +- A newly completed tool observation renews the consecutive retry budget; timeout, error, interruption, state, and delivery events do not. A persisted final response is reconciled even if cancellation left the SDK status paused. After three no-progress attempts or three total hours, Senpai preserves the raw trace and retries one canonical copy on a fresh branch with the complete initial controller context. If that recovery exhausts the same budget, the turn is durably quarantined, reported as `SENPAI_TURN_QUARANTINED` on every controller start, and excluded from scheduling rather than entering a restart loop. `SENPAI_INBOX_MAX_STALLED_ATTEMPTS`, `SENPAI_INBOX_MAX_TURN_AGE_SECONDS`, and `SENPAI_INBOX_MAX_RECOVERY_GENERATIONS` configure these positive attempt/age limits and the non-negative number of fresh branches. - A typed context-window or malformed-history failure uses the same bounded fresh-branch recovery. The reset and its canonical recovery copy are durable across crashes; transient failures remain unacknowledged and retry after at least ten minutes. - On restart, an incomplete persisted tool action is rejected rather than replayed implicitly. A checked-out assignment branch that was deliberately rebased or extended locally is preserved and surfaced to its existing student conversation for explicit reconciliation. - The complete OpenHands event log remains locally searchable. Senpai does not prune conversation directories; operators own retention. - Student state may be ephemeral because the branch, PR, typed result, W&B runs, and Weave trace are the durable handoff. -- Project `AGENTS.md`, compatible `CLAUDE.md`, and skills are loaded progressively instead of being inlined into every prompt. +- Explicit project skills remain available through OpenHands skill context. Repository `AGENTS.md`, `AGENT.md`, and `CLAUDE.md` instruction files are reserved for human-facing development tools and are not loaded as Senpai project context. The command policy blocks raw GitHub mutations, direct training, `git push`, polling loops, and log streams. Operation-specific typed tools enforce repository, branch, assignment, revision, head-SHA, label, and replay preconditions. This policy keeps routine operations deterministic while leaving high-entropy research work to the agent. @@ -392,7 +391,7 @@ Useful launch controls: - `--timeout_minutes` and `--max_epochs` are hard per-training limits. - `--poll_interval_s` and `--poll_jitter_s` control idle GitHub cadence without teaching the model to poll. - `--gh_history_scope branch` keeps normal advisor-branch memory, `fresh` creates a shallow ablation checkout, and `repo` exposes full repository history. -- `--extra_instructions` accepts a Markdown file or literal operator guidance. +- `--extra_instructions` accepts optional human operator guidance as a Markdown file or literal user context. - `human_issues: false` disables GitHub Issue polling for isolated launches. Advisor and student images are built from the same source revision. The advisor image excludes CUDA and PyTorch; the student image contains the CUDA/PyTorch runtime; the cutoff image contains only the minimal job runtime and pinned `kubectl`. Advisor and student builds install Chromium and execute an OpenHands browser smoke test. @@ -405,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), persist `/var/lib/senpai//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`; keep optional operator guidance in `EXTRA_INSTRUCTIONS_B64`. Persist `/var/lib/senpai//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 diff --git a/SPEC.md b/SPEC.md index 75c54a851..86dcd1b8b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -129,7 +129,7 @@ Advisor state: ├── advisor-conversation-id ├── controller-lease.json ├── advisor-events.sqlite3 -├── conversation-state.json +├── started-conversations.json ├── github/ └── conversations managed by OpenHands ``` @@ -145,7 +145,7 @@ Student state: ├── github-feedback.json ├── student-conversations.json ├── student-events.sqlite3 -├── conversation-state.json +├── started-conversations.json ├── training/ │ ├── .json │ ├── .log @@ -155,34 +155,18 @@ Student state: └── conversations managed by OpenHands ``` -`student-conversations.json` maps one `(assignment_id, revision_id)` to one -UUID. `conversation-state.json` records, per UUID, both successful initial -instruction delivery and the digest of the delivered merged system context. -The controller replaces this one document atomically after a successful turn, -so a restart cannot observe those two facts at different revisions. A -`training_monitor` event carries its original conversation UUID and therefore -resumes, rather than replaces, the student conversation. +`student-conversations.json` maps one `(assignment_id, revision_id)` to one UUID. `started-conversations.json` records the UUIDs that successfully received their initial controller context. A `training_monitor` event carries its original conversation UUID and therefore resumes, rather than replaces, the student conversation. `github-feedback.json` records every immutable PR feedback key's first-seen assignment revision, then marks it acknowledged only after its student turn succeeds. This prevents pending or completed feedback from replaying or rebinding to a later assignment revision after a restart. -When `conversation-state.json` does not yet exist, startup atomically migrates -the previous `started-conversations.json` and -`system-context-revisions.json` files. A conversation caught between those -legacy files' two writes resumes without replaying its initial brief and -receives the current system context once. - OpenHands stores base state and individual events beneath that UUID. A killed worker resumes from the last persisted event. An in-flight response or tool call without a durable event is retried from the preceding event. -The controller marks a conversation's initial instructions delivered and -records its current system-context digest in the same atomic update, only after -the OpenHands turn succeeds. A crash or nonzero first turn therefore retries -the complete programme and assignment prompt instead of incorrectly -continuing from instructions that were never delivered. +The controller marks a conversation's initial controller context delivered only after the OpenHands turn succeeds. A crash or nonzero first turn therefore retries that context instead of incorrectly continuing from information that was never delivered. Role state uses pod-local storage and survives controller or container restarts within the same pod. Replacing or rescheduling a pod starts fresh local state; @@ -200,19 +184,13 @@ 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. -3. Applicable target `AGENTS.md` and compatible `CLAUDE.md` project context. -4. A compact skill catalog whose bodies are loaded only when invoked. -5. User turns containing `program.md`, target role instructions, current state, - and current UTC time. - -Harness and role remain separate source documents because they have different -owners, but are merged into one system suffix so the agent knows both the -OpenHands operating contract and its Senpai role. The complete role is not -periodically duplicated in user messages; OpenHands includes the system suffix -on every inference. A persisted merged-context hash detects a changed deployed -harness or role and injects the current text once into the same conversation -UUID. Current time is rendered for every controller wake. + - the rendered advisor or student role charter; and + - the selected target-repository `program.md` under `# program.md - `; 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. File-based subagents are discovered from `.agents/agents`. Skill bodies are not concatenated into agent definitions. The OpenHands fork's `main` branch applies diff --git a/docs/.nojekyll b/docs/.nojekyll deleted file mode 100644 index 8b1378917..000000000 --- a/docs/.nojekyll +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/assets/architecture.png b/docs/assets/architecture.png deleted file mode 100644 index c757ea35c..000000000 Binary files a/docs/assets/architecture.png and /dev/null differ diff --git a/docs/assets/cfd-problem-gallery.png b/docs/assets/cfd-problem-gallery.png deleted file mode 100644 index d80937936..000000000 Binary files a/docs/assets/cfd-problem-gallery.png and /dev/null differ diff --git a/docs/assets/drivaerml-pareto.png b/docs/assets/drivaerml-pareto.png deleted file mode 100644 index 702e17f54..000000000 Binary files a/docs/assets/drivaerml-pareto.png and /dev/null differ diff --git a/docs/assets/experiment-ledger-sankey.png b/docs/assets/experiment-ledger-sankey.png deleted file mode 100644 index d90643ad9..000000000 Binary files a/docs/assets/experiment-ledger-sankey.png and /dev/null differ diff --git a/docs/assets/pr-ledger-screenshot.png b/docs/assets/pr-ledger-screenshot.png deleted file mode 100644 index 4b0f80fd3..000000000 Binary files a/docs/assets/pr-ledger-screenshot.png and /dev/null differ diff --git a/docs/assets/senpai-cat.svg b/docs/assets/senpai-cat.svg deleted file mode 100644 index aa314c60a..000000000 --- a/docs/assets/senpai-cat.svg +++ /dev/null @@ -1,28 +0,0 @@ - - Cute cat giving a thumbs up - A round white cat with clear ears, whiskers, blush, a curled tail, and a raised paw. - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/assets/senpai-icml2026-paper.pdf b/docs/assets/senpai-icml2026-paper.pdf deleted file mode 100644 index fef077efd..000000000 Binary files a/docs/assets/senpai-icml2026-paper.pdf and /dev/null differ diff --git a/docs/assets/wandb-logo.png b/docs/assets/wandb-logo.png deleted file mode 100644 index f754f2718..000000000 Binary files a/docs/assets/wandb-logo.png and /dev/null differ diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index ec8722f32..000000000 --- a/docs/index.html +++ /dev/null @@ -1,366 +0,0 @@ - - - - - - - - - - - - SENPAI | ICML AI for Science Workshop - - - - - - - - - - -
-
- - -
-

Autonomous ML research, made inspectable

-

SENPAI: Self-ExperimentatioN for Physical AI

-

An observability-based harness that turns agent hypotheses into pull requests, training runs, and reviewable experiment records.

-

Thomas Capelle, Morgan McGuire, Justin Hodges

-
- Paper - Code - PDF -
-
-
- -
-
- 3,000+ - agent-generated PRs -
-
- 11,000+ - tracked training runs -
-
- 59 - peak Student agents -
-
- 9 days - longest DrivAerML deployment -
-
- -
-
-

Big picture

-

CFD surrogate research is a long chain of small, consequential experiments.

-

- A useful physics surrogate is not found by changing one hyperparameter. It requires - many interlocking decisions about architecture, optimization, normalization, - boundary behavior, sampling, losses, and benchmark contracts. -

-

- SENPAI treats that search as a semi-autonomous research programme: agents propose, - implement, train, and report while humans keep a low-bandwidth review and steering - surface. -

-
-
- Representative DrivAerML, AirfRANS, and TandemFoilSet geometries. -
The paper evaluates SENPAI on 3D automotive aerodynamics, 2D airfoil RANS, and tandem-airfoil flow.
-
-
- -
-
-

Design idea

-

The research state lives in the tools researchers already inspect.

-

- SENPAI keeps authoritative state in GitHub pull requests, git history, and - experiment-tracker runs, not in agent memory or local scratchpads. -

-

- The result is an experiment ledger that agents can query and humans can audit: - hypotheses, code diffs, run IDs, metrics, checkpoints, review decisions, and - failures all stay attached to ordinary research artifacts. -

-
-
- SENPAI architecture diagram showing a thin harness, Kubernetes advisor and student pods, and an experiment ledger composed of GitHub PRs, git history, and W&B runs. -
A thin harness deploys Advisor and Student agents while the experiment ledger lives outside the cluster.
-
-
- -
-
-

How it works

-

Each experiment becomes a pull request with a measurable outcome.

-

The loop is deliberately simple so the durable record stays more important than the agent session that created it.

-
-
-
- 1 -

Advisor drafts PR

-

Hypothesis, baseline, metric target, and Student label are written into the pull request.

-
-
- 2 -

Student trains

-

The GPU worker edits the target repo, runs bounded training, and logs runs to W&B.

-
-
- 3 -

Result returns

-

Commands, run IDs, metrics, and interpretation are posted back to the PR.

-
-
- 4 -

Advisor reviews

-

The Advisor merges winners, requests follow-up, or closes dead ends against the ledger.

-
-
-
- -
-
-

Try SENPAI

-

Bring a problem repo; SENPAI provides the research loop around it.

-

The harness is intentionally problem-agnostic. The target repository owns the model, data, training code, evaluation contract, and domain prompts; SENPAI owns orchestration and the experiment ledger.

-
-
-
-

You provide

-
    -
  • A target ML repository with a runnable training script.
  • -
  • A clear program.md: mission, metrics, file boundaries, and benchmark contract.
  • -
  • Dataset access, GPU budget, GitHub token, and experiment-tracker credentials.
  • -
  • Advisor and Student prompt overlays for target-specific constraints.
  • -
-
-
-

SENPAI provides

-
    -
  • Advisor and Student agent loops coordinated through PR labels and comments.
  • -
  • Kubernetes launch templates for CPU Advisor and GPU Student pods.
  • -
  • GitHub helpers for assigning, reviewing, merging, closing, and reporting experiments.
  • -
  • A durable ledger tying hypotheses, diffs, W&B runs, metrics, and decisions together.
  • -
-
-
- -
- -
-
-

Use cases and results

-

SENPAI was tried across three CFD surrogate benchmark families.

-

Lower is better for all listed metrics. These are the headline paper results with provenance in PRs and W&B runs.

-
- -
-
-

DrivAerML

-

Best single-model pressure results among compared references.

-
-
-
Surface pressure rel-L2
-
3.56%
-
-
-
Volume pressure rel-L2
-
3.40%
-
-
-
Wall shear rel-L2
-
6.54%
-
-
-

W&B run k6q4c3on, PR #1344.

-
- -
-

AirfRANS full task

-

Strongest reported surface-MSE in the paper comparison table.

-
-
-
SENPAI surface MSE
-
0.00130
-
-
-
SpiderSolver surface MSE
-
0.0043
-
-
-
SENPAI volume MSE
-
0.00451
-
-
-

Five-seed mean on the official test split.

-
- -
-

TandemFoilSet

-

Full-field MSE improvement plus balanced split recipe search.

-
-
-
Cruise uniform full-field MSE
-
1.7e-3
-
-
-
Reported paper benchmark
-
1.0e-1
-
-
-
Balanced surface-pressure MAE
-
23.45
-
-
-

Balanced result is a five-seed average.

-
-
- -
-
- DrivAerML surface pressure Pareto frontier over experiment count. -
DrivAerML surface-pressure trajectory across two SENPAI campaigns.
-
-
- Sankey chart showing 167 TandemFoilSet-Balanced experiments grouped by trial, hypothesis family, and merged, closed, or open outcome. -
A compact slice of 167 TandemFoilSet-Balanced PRs grouped by trial, hypothesis family, and final outcome.
-
-
-
- -
-
-

What the ledger buys

-

Observability becomes part of the research method.

-
-
- Public GitHub pull request showing a SENPAI experiment record. -
A public experiment PR links the hypothesis, implementation, review thread, and W&B-backed result evidence.
-
-
-
-

Auditability

-

Each hypothesis, code diff, training run, and review decision remains visible in standard research tools.

-
-
-

Recovery

-

If an agent session compacts or restarts, the next cycle can reconstruct the experiment from the PR, git history, and W&B run.

-
-
-

Sparse steering

-

Humans steer through GitHub issues and PR comments instead of sitting inside every agent loop.

-
-
-

Failure analysis

-

The paper audits a 24-hour fleet trace with 53,022 Claude requests and 5.24B tokens.

-
-
-

Beyond CFD

-

The same harness was also stress-tested on modded-NanoGPT Track 3 optimization, producing a 2925-step candidate result pending official ratification.

-
-
-

Open harness

-

The runner code is public in wandb/senpai, including Kubernetes launch code, role prompts, helper scripts, and paper artifacts.

-
-
-
- -
-
-

Extra evidence

-

The dominant failures were infrastructure failures, not missing scientific prose.

-

- The failure analysis points at monitor-driven context bloat, brittle tool - interfaces, result-capture gaps, and state reconciliation. The takeaway is - that long-running research agents need durable external state and executable - handoff protocols more than ever-longer instructions. -

-
-
-
- 53,022 - Claude requests in the audited 24-hour fleet trace -
-
- 5.24B - tokens processed in that trace -
-
- 240 - automatic Student context compactions -
-
- 38 / 39 - completed monitored trainings still produced PR comments -
-
-
- -
-
- -
-
-

Citation

-

Cite the workshop paper.

-
- -
@inproceedings{capelle2026senpai,
-  title = {SENPAI: Self-ExperimentatioN for Physical AI: An Observability-Based Research Harness},
-  author = {Capelle, Thomas and McGuire, Morgan and Hodges, Justin},
-  booktitle = {ICML 2026 AI for Science Workshop},
-  year = {2026},
-  url = {https://openreview.net/forum?id=g0bJFA9gVT}
-}
-
- -
-
-

Run the loop

-

Try SENPAI on your research repo.

-

Bring a bounded ML problem, a clear metric contract, and enough GPU budget to let the experiment ledger become useful. Replication notes, questions, or ideas for adapting SENPAI: morg@wandb.ai.

-
- -
-
- -
- SENPAI - - ICML 2026 AI for Science Workshop -
- - - - diff --git a/docs/script.js b/docs/script.js deleted file mode 100644 index 19b495b57..000000000 --- a/docs/script.js +++ /dev/null @@ -1,186 +0,0 @@ -const progress = document.querySelector(".scroll-progress"); -const milestoneToast = document.querySelector(".milestone-toast"); -const finishCelebration = document.querySelector(".finish-celebration"); -const finishMessage = document.querySelector(".finish-message"); -const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; -let confettiDropped = false; -let finishCelebrated = false; -let milestoneToastTimeout; -let finishCleanupTimeout; - -function updateProgress() { - const max = document.documentElement.scrollHeight - window.innerHeight; - const pct = max > 0 ? (window.scrollY / max) * 100 : 0; - progress.style.width = `${pct}%`; - - if (!prefersReducedMotion && !confettiDropped && pct >= 50) { - confettiDropped = true; - showMilestoneToast(); - dropConfetti(); - } - - if (!prefersReducedMotion && !finishCelebrated && pct >= 98) { - finishCelebrated = true; - showFinishCelebration(); - } -} - -window.addEventListener("scroll", updateProgress, { passive: true }); -window.addEventListener("resize", updateProgress); -updateProgress(); - -const animatedItems = document.querySelectorAll("[data-animate]"); - -if (prefersReducedMotion) { - animatedItems.forEach((item) => item.classList.add("is-visible")); -} else { - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - entry.target.classList.add("is-visible"); - observer.unobserve(entry.target); - } - }); - }, - { threshold: 0.12 } - ); - - animatedItems.forEach((item) => observer.observe(item)); -} - -function showMilestoneToast() { - if (!milestoneToast) return; - - milestoneToast.hidden = false; - milestoneToast.textContent = "50% through the page!"; - milestoneToast.classList.add("is-visible"); - window.clearTimeout(milestoneToastTimeout); - milestoneToastTimeout = window.setTimeout(() => { - milestoneToast.classList.remove("is-visible"); - milestoneToast.textContent = ""; - milestoneToast.hidden = true; - }, 2000); -} - -function showFinishCelebration() { - if (!finishCelebration) return; - - window.clearTimeout(milestoneToastTimeout); - window.clearTimeout(finishCleanupTimeout); - if (milestoneToast) { - milestoneToast.textContent = ""; - milestoneToast.hidden = true; - milestoneToast.classList.remove("is-visible"); - } - if (finishMessage) { - finishMessage.textContent = "You made it!"; - } - finishCelebration.hidden = false; - finishCelebration.classList.remove("is-visible"); - void finishCelebration.offsetWidth; - finishCelebration.classList.add("is-visible"); - finishCleanupTimeout = window.setTimeout(() => { - finishCelebration.classList.remove("is-visible"); - finishCelebration.hidden = true; - if (finishMessage) { - finishMessage.textContent = ""; - } - }, 5600); -} - -function dropConfetti() { - const canvas = document.createElement("canvas"); - const context = canvas.getContext("2d"); - - if (!context) return; - - canvas.className = "confetti-canvas"; - canvas.setAttribute("aria-hidden", "true"); - document.body.appendChild(canvas); - - const colors = ["#0e7c78", "#245bb3", "#c85f13", "#2e7d52", "#b98705", "#17212b"]; - const dpr = Math.min(window.devicePixelRatio || 1, 2); - const pieceCount = window.innerWidth < 620 ? 72 : 132; - const pieces = Array.from({ length: pieceCount }, () => ({ - x: Math.random() * window.innerWidth, - y: -Math.random() * window.innerHeight * 0.45, - width: 5 + Math.random() * 7, - height: 8 + Math.random() * 10, - color: colors[Math.floor(Math.random() * colors.length)], - velocityX: -2.2 + Math.random() * 4.4, - velocityY: 2.5 + Math.random() * 4.2, - rotation: Math.random() * Math.PI, - spin: -0.18 + Math.random() * 0.36, - wobble: Math.random() * Math.PI * 2, - })); - - function resizeCanvas() { - canvas.width = window.innerWidth * dpr; - canvas.height = window.innerHeight * dpr; - context.setTransform(dpr, 0, 0, dpr, 0, 0); - } - - function drawPiece(piece, opacity) { - context.save(); - context.globalAlpha = opacity; - context.translate(piece.x, piece.y); - context.rotate(piece.rotation); - context.fillStyle = piece.color; - context.fillRect(-piece.width / 2, -piece.height / 2, piece.width, piece.height); - context.restore(); - } - - resizeCanvas(); - window.addEventListener("resize", resizeCanvas); - - const duration = 3600; - const startedAt = performance.now(); - - function animate(now) { - const elapsed = now - startedAt; - const fadeStart = duration * 0.72; - const opacity = elapsed > fadeStart ? Math.max(0, 1 - (elapsed - fadeStart) / (duration - fadeStart)) : 1; - - context.clearRect(0, 0, window.innerWidth, window.innerHeight); - - pieces.forEach((piece) => { - piece.x += piece.velocityX + Math.sin(elapsed / 180 + piece.wobble) * 0.45; - piece.y += piece.velocityY; - piece.velocityY += 0.018; - piece.rotation += piece.spin; - drawPiece(piece, opacity); - }); - - if (elapsed < duration) { - window.requestAnimationFrame(animate); - return; - } - - window.removeEventListener("resize", resizeCanvas); - canvas.remove(); - } - - window.requestAnimationFrame(animate); -} - -document.querySelectorAll("[data-copy-target]").forEach((button) => { - button.addEventListener("click", async () => { - const target = document.getElementById(button.dataset.copyTarget); - if (!target) return; - const originalText = button.textContent; - - try { - await navigator.clipboard.writeText(target.innerText.trim()); - button.textContent = "Copied"; - window.setTimeout(() => { - button.textContent = originalText; - }, 1400); - } catch { - button.textContent = "Select BibTeX"; - window.setTimeout(() => { - button.textContent = originalText; - }, 1400); - } - }); -}); diff --git a/docs/styles.css b/docs/styles.css deleted file mode 100644 index 512a4fa7d..000000000 --- a/docs/styles.css +++ /dev/null @@ -1,957 +0,0 @@ -:root { - color-scheme: light; - --bg: #f8fbff; - --paper: #ffffff; - --ink: #17212b; - --muted: #5b6876; - --line: #dce6ee; - --teal: #0e7c78; - --blue: #245bb3; - --orange: #c85f13; - --green: #2e7d52; - --amber: #b98705; - --shadow: 0 18px 50px rgba(21, 36, 52, 0.12); -} - -* { - box-sizing: border-box; -} - -html { - scroll-behavior: smooth; -} - -body { - margin: 0; - background: - linear-gradient(180deg, rgba(232, 244, 255, 0.72), rgba(255, 255, 255, 0.94) 34rem), - var(--bg); - color: var(--ink); - font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - font-size: 16px; - line-height: 1.6; - letter-spacing: 0; - overflow-x: hidden; -} - -img { - display: block; - max-width: 100%; -} - -a { - color: inherit; -} - -code, -pre { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; -} - -.skip-link { - position: absolute; - left: 1rem; - top: -4rem; - z-index: 20; - background: var(--ink); - color: #fff; - padding: 0.6rem 0.85rem; - border-radius: 6px; -} - -.skip-link:focus { - top: 1rem; -} - -.scroll-progress { - position: fixed; - top: 0; - left: 0; - z-index: 30; - height: 3px; - width: 0%; - background: linear-gradient(90deg, var(--teal), var(--blue), var(--orange)); -} - -.confetti-canvas { - position: fixed; - inset: 0; - z-index: 40; - width: 100%; - height: 100%; - pointer-events: none; -} - -.milestone-toast { - position: fixed; - top: 5.25rem; - left: 50%; - z-index: 45; - padding: 0.75rem 1rem; - border: 1px solid rgba(14, 124, 120, 0.28); - border-radius: 8px; - background: rgba(255, 255, 255, 0.94); - box-shadow: var(--shadow); - color: var(--ink); - font-weight: 820; - opacity: 0; - pointer-events: none; - transform: translate(-50%, -0.65rem); - transition: opacity 180ms ease, transform 180ms ease; -} - -.milestone-toast.is-visible { - opacity: 1; - transform: translate(-50%, 0); -} - -.finish-celebration { - position: fixed; - left: 0; - bottom: 1.25rem; - z-index: 45; - display: flex; - align-items: flex-end; - gap: 0.85rem; - pointer-events: none; - opacity: 0; - transform: translateX(-18rem); -} - -.finish-celebration.is-visible { - animation: catWalk 5.2s ease-in-out both; -} - -.finish-message { - margin-bottom: 4.5rem; - padding: 0.65rem 0.85rem; - border: 1px solid rgba(36, 91, 179, 0.24); - border-radius: 8px; - background: #ffffff; - box-shadow: var(--shadow); - color: var(--ink); - font-weight: 850; - white-space: nowrap; -} - -.cat-runner { - display: block; - width: 10rem; - height: auto; - animation: catBounce 620ms ease-in-out infinite alternate; -} - -.site-header { - position: sticky; - top: 0; - z-index: 10; - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - padding: 0.8rem 4rem; - border-bottom: 1px solid rgba(220, 230, 238, 0.72); - background: rgba(248, 251, 255, 0.88); - backdrop-filter: blur(16px); -} - -.brand, -.site-nav, -.hero-actions { - display: flex; - align-items: center; -} - -.brand { - gap: 0.55rem; - font-weight: 800; - text-decoration: none; -} - -.brand-mark { - display: inline-grid; - place-items: center; - width: 2rem; - height: 2rem; - border: 2px solid var(--ink); - border-radius: 6px; - background: var(--paper); -} - -.site-nav { - gap: 1.2rem; -} - -.site-nav a { - color: var(--muted); - font-size: 0.92rem; - font-weight: 650; - text-decoration: none; -} - -.site-nav a:hover { - color: var(--ink); -} - -.hero { - position: relative; - min-height: 32rem; - display: grid; - align-items: center; - overflow: hidden; - border-bottom: 1px solid var(--line); -} - -.hero-bg { - position: absolute; - inset: auto -8rem -2rem auto; - width: min(62rem, 86vw); - opacity: 0.28; - filter: saturate(1.2); -} - -.hero-overlay { - position: absolute; - inset: 0; - background: - linear-gradient(90deg, rgba(248, 251, 255, 0.98) 0%, rgba(248, 251, 255, 0.88) 42%, rgba(248, 251, 255, 0.55) 100%), - linear-gradient(180deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.72)); -} - -.hero-inner { - position: relative; - width: calc(100% - 3rem); - max-width: 72rem; - margin: 0 auto; - padding: 5rem 0 4rem; -} - -.eyebrow, -.section-kicker, -.label { - margin: 0 0 0.8rem; - color: var(--teal); - font-size: 0.82rem; - font-weight: 800; - text-transform: uppercase; -} - -.hero h1 { - max-width: 54rem; - margin: 0; - font-size: 4.6rem; - line-height: 1.02; - letter-spacing: 0; -} - -.subtitle { - max-width: 44rem; - margin: 1.25rem 0 0; - color: #344252; - font-size: 1.34rem; - line-height: 1.42; -} - -.authors { - margin: 1rem 0 0; - color: var(--muted); - font-weight: 650; -} - -.hero-actions { - flex-wrap: wrap; - gap: 0.7rem; - margin-top: 2rem; - max-width: 28rem; -} - -.button { - display: inline-flex; - min-height: 2.85rem; - align-items: center; - justify-content: center; - border: 1px solid #b9c8d8; - border-radius: 8px; - background: rgba(255, 255, 255, 0.86); - color: var(--ink); - padding: 0.72rem 0.95rem; - font-weight: 760; - text-decoration: none; - transition: transform 180ms ease, border-color 180ms ease, background 180ms ease; -} - -.button:hover { - transform: translateY(-2px); - border-color: var(--blue); - background: #fff; -} - -.button.primary { - border-color: var(--ink); - background: var(--ink); - color: #fff; -} - -.metric-strip { - width: calc(100% - 3rem); - max-width: 72rem; - margin: -2rem auto 0; - position: relative; - z-index: 2; - display: grid; - grid-template-columns: repeat(4, 1fr); - overflow: hidden; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--paper); - box-shadow: var(--shadow); -} - -.metric-strip div { - padding: 1.25rem; - border-right: 1px solid var(--line); -} - -.metric-strip div:last-child { - border-right: 0; -} - -.metric-strip strong { - display: block; - font-size: 1.72rem; - line-height: 1.2; -} - -.metric-strip > div > span { - display: block; - margin-top: 0.25rem; - color: var(--muted); - font-size: 0.92rem; -} - -.metric-strip strong { - white-space: nowrap; -} - -.metric-strip strong span { - display: inline; - color: inherit; - font-size: inherit; -} - -.section { - width: calc(100% - 3rem); - max-width: 72rem; - margin: 0 auto; - padding: 5.25rem 0; -} - -.two-column { - display: grid; - grid-template-columns: 0.82fr 1.18fr; - gap: 2rem; - align-items: center; -} - -h2 { - margin: 0; - font-size: 2.45rem; - line-height: 1.12; - letter-spacing: 0; -} - -h3 { - margin: 0; - font-size: 1.08rem; - line-height: 1.3; - letter-spacing: 0; -} - -.copy p:not(.section-kicker), -.section-heading p, -.evidence-copy p { - color: var(--muted); -} - -.media-panel, -.wide-figure { - margin: 0; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--paper); - box-shadow: var(--shadow); -} - -.media-panel img, -.wide-figure img { - width: 100%; - border-radius: 8px 8px 0 0; -} - -figcaption { - color: var(--muted); - font-size: 0.9rem; - line-height: 1.45; - padding: 0.85rem 1rem 1rem; -} - -.architecture img { - padding: 1rem; -} - -.spacious-media img { - padding: 2rem 2rem 1rem; - background: #fff; - object-fit: contain; -} - -.section-heading { - max-width: 48rem; - margin-bottom: 1.7rem; -} - -.section-heading.narrow { - max-width: 42rem; -} - -.flow { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 1rem; -} - -.flow-step { - position: relative; - min-height: 14rem; - padding: 1.25rem; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--paper); -} - -.flow-step::after { - content: ""; - position: absolute; - top: 2.2rem; - right: -1rem; - width: 1rem; - height: 2px; - background: linear-gradient(90deg, var(--teal), var(--orange)); -} - -.flow-step:last-child::after { - display: none; -} - -.flow-step span { - display: inline-grid; - place-items: center; - width: 2rem; - height: 2rem; - margin-bottom: 0.85rem; - border-radius: 6px; - background: #e8f5f4; - color: var(--teal); - font-weight: 850; -} - -.flow-step p, -.result-card p, -.repo-grid p, -.try-card li { - color: var(--muted); -} - -.try-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1rem; -} - -.try-card { - border: 1px solid var(--line); - border-radius: 8px; - background: var(--paper); - padding: 1.35rem; - box-shadow: var(--shadow); -} - -.try-card ul { - margin: 1rem 0 0; - padding-left: 1.1rem; -} - -.try-card li + li { - margin-top: 0.65rem; -} - -.try-card code { - color: var(--blue); - font-weight: 700; -} - -.accent-card { - border-color: rgba(14, 124, 120, 0.34); - background: linear-gradient(180deg, #ffffff, #f4fbfa); -} - -.cta-row { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - margin-top: 1rem; -} - -.result-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 1rem; -} - -.result-card, -.repo-grid article { - border: 1px solid var(--line); - border-radius: 8px; - background: var(--paper); - padding: 1.25rem; -} - -.result-card dl { - margin: 1.15rem 0 0; -} - -.result-card dl div { - display: flex; - justify-content: space-between; - gap: 0.9rem; - padding: 0.6rem 0; - border-top: 1px solid var(--line); -} - -.result-card dt { - color: var(--muted); -} - -.result-card dd { - margin: 0; - font-weight: 850; -} - -.footnote { - margin: 1rem 0 0; - font-size: 0.9rem; -} - -.figure-grid { - display: grid; - grid-template-columns: 1.05fr 0.95fr; - gap: 1rem; - margin-top: 1rem; -} - -.repo-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 1rem; -} - -.repo-grid code { - color: var(--blue); - font-weight: 700; -} - -.pr-panel { - margin-bottom: 1rem; -} - -.pr-panel img { - border-radius: 8px 8px 0 0; -} - -.evidence-section { - display: grid; - grid-template-columns: 0.75fr 1.25fr; - gap: 1.5rem; - align-items: center; -} - -.evidence-metrics { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1rem; -} - -.evidence-metrics div { - min-height: 9rem; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--paper); - padding: 1.25rem; - box-shadow: var(--shadow); -} - -.evidence-metrics strong { - display: block; - color: var(--ink); - font-size: 2rem; - line-height: 1.1; -} - -.evidence-metrics span { - display: block; - margin-top: 0.75rem; - color: var(--muted); -} - -.cite-section { - display: grid; - grid-template-columns: 1fr auto; - column-gap: 1rem; - row-gap: 2.35rem; - align-items: start; - padding-top: 3rem; -} - -.paper-viewer { - grid-column: 1 / -1; - overflow: hidden; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--paper); - box-shadow: var(--shadow); -} - -.paper-viewer iframe { - display: block; - width: 100%; - height: min(78vh, 54rem); - border: 0; - background: #f4f7fa; -} - -.copy-button { - cursor: pointer; -} - -pre { - grid-column: 1 / -1; - overflow-x: auto; - margin: 0; - border: 1px solid var(--line); - border-radius: 8px; - background: #10202a; - color: #e9f7ff; - padding: 1.1rem; - font-size: 0.92rem; - line-height: 1.55; -} - -.final-cta { - display: grid; - grid-template-columns: 1fr auto; - gap: 1.5rem; - align-items: center; - padding-top: 2.5rem; -} - -.final-cta p:not(.section-kicker) { - max-width: 42rem; - color: var(--muted); -} - -.final-actions { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 0.75rem; -} - -.site-footer { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - width: calc(100% - 3rem); - max-width: 72rem; - margin: 0 auto; - padding: 2.5rem 0 3rem; - color: var(--muted); - border-top: 1px solid var(--line); - font-size: 0.92rem; -} - -.wandb-logo { - display: inline-flex; - align-items: center; - text-decoration: none; -} - -.wandb-logo img { - width: min(13rem, 36vw); - height: auto; -} - -[data-animate] { - opacity: 0; - transform: translateY(16px); - transition: opacity 520ms ease, transform 520ms ease; -} - -[data-animate].is-visible { - opacity: 1; - transform: translateY(0); -} - -@keyframes flowPulse { - 0%, - 100% { - border-color: var(--line); - transform: translateY(0); - } - 50% { - border-color: rgba(14, 124, 120, 0.65); - transform: translateY(-3px); - } -} - -.flow-step:nth-child(1) { - animation: flowPulse 5s ease-in-out infinite; -} - -.flow-step:nth-child(2) { - animation: flowPulse 5s ease-in-out 0.5s infinite; -} - -.flow-step:nth-child(3) { - animation: flowPulse 5s ease-in-out 1s infinite; -} - -.flow-step:nth-child(4) { - animation: flowPulse 5s ease-in-out 1.5s infinite; -} - -@keyframes catWalk { - 0% { - opacity: 0; - transform: translateX(-10rem); - } - 10% { - opacity: 1; - transform: translateX(1rem); - } - 72% { - opacity: 1; - transform: translateX(calc(100vw - 17rem)); - } - 100% { - opacity: 0; - transform: translateX(calc(100vw + 4rem)); - } -} - -@keyframes catBounce { - from { - transform: translateY(0) rotate(-1deg); - } - to { - transform: translateY(-0.28rem) rotate(1.5deg); - } -} - -@media (max-width: 900px) { - .site-header { - padding: 0.75rem 1.25rem; - } - - .site-nav { - gap: 0.8rem; - } - - .hero { - min-height: 30rem; - } - - .hero-inner { - width: calc(100% - 2rem); - max-width: 44rem; - padding: 4rem 0 3.5rem; - } - - .hero h1 { - font-size: 3.2rem; - } - - .subtitle { - font-size: 1.12rem; - } - - .metric-strip, - .two-column, - .flow, - .try-grid, - .result-grid, - .figure-grid, - .repo-grid, - .evidence-metrics, - .evidence-section, - .cite-section, - .final-cta { - grid-template-columns: 1fr; - } - - .metric-strip { - width: calc(100% - 2rem); - max-width: 44rem; - margin-top: -1.25rem; - } - - .metric-strip div { - border-right: 0; - border-bottom: 1px solid var(--line); - } - - .metric-strip div:last-child { - border-bottom: 0; - } - - .section { - width: calc(100% - 2rem); - max-width: 44rem; - padding: 3.7rem 0; - } - - h2 { - font-size: 2rem; - } - - .flow-step { - min-height: auto; - } - - .flow-step::after { - top: auto; - right: auto; - left: 2.25rem; - bottom: -1rem; - width: 2px; - height: 1rem; - } - - .final-actions { - justify-content: flex-start; - } -} - -@media (max-width: 620px) { - .site-header { - align-items: flex-start; - flex-direction: column; - gap: 0.55rem; - } - - .site-nav { - width: 100%; - overflow-x: auto; - padding-bottom: 0.15rem; - } - - .hero { - min-height: 34rem; - } - - .hero-inner, - .metric-strip, - .section, - .site-footer { - max-width: 22rem; - margin-left: 1rem; - margin-right: 1rem; - } - - .hero-bg { - inset: auto -9rem 0 auto; - width: 44rem; - opacity: 0.2; - } - - .hero h1 { - font-size: 2.45rem; - } - - .hero-actions .button { - width: 100%; - } - - .hero-actions { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - max-width: none; - } - - .spacious-media img { - padding: 1.2rem 1.2rem 0.6rem; - } - - .cta-row .button { - width: 100%; - } - - .paper-viewer iframe { - height: 28rem; - } - - .milestone-toast { - top: 6.8rem; - width: calc(100% - 2rem); - text-align: center; - } - - .finish-celebration { - bottom: 0.8rem; - } - - .finish-message { - margin-bottom: 4.2rem; - } - - .cat-runner { - width: 7.5rem; - transform-origin: bottom right; - } - - .final-actions .button { - width: 100%; - } - - .site-footer { - flex-direction: column; - align-items: flex-start; - width: calc(100% - 2rem); - max-width: 44rem; - } -} - -@media (prefers-reduced-motion: reduce) { - html { - scroll-behavior: auto; - } - - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - scroll-behavior: auto !important; - transition-duration: 0.01ms !important; - } - - [data-animate] { - opacity: 1; - transform: none; - } - - .milestone-toast, - .finish-celebration { - display: none; - } -} diff --git a/k8s/advisor-deployment.yaml b/k8s/advisor-deployment.yaml index e6ecfb466..65a512dba 100644 --- a/k8s/advisor-deployment.yaml +++ b/k8s/advisor-deployment.yaml @@ -63,12 +63,12 @@ spec: mkdir -p /workspace git init /workspace/senpai GIT_ASKPASS="$askpass" GIT_TERMINAL_PROMPT=0 \ - git -C /workspace/senpai fetch --depth 1 "$REPO_URL" "$REPO_REVISION" + git -C /workspace/senpai fetch --depth 1 "$SENPAI_REPO_URL" "$SENPAI_REPO_REVISION" git -C /workspace/senpai checkout --detach FETCH_HEAD cd /workspace/senpai - git remote add origin "$REPO_URL" - test "$(git rev-parse HEAD)" = "$REPO_REVISION" - test "$SENPAI_IMAGE_REVISION" = "$REPO_REVISION" + git remote add origin "$SENPAI_REPO_URL" + test "$(git rev-parse HEAD)" = "$SENPAI_REPO_REVISION" + test "$SENPAI_IMAGE_REVISION" = "$SENPAI_REPO_REVISION" token_handoff=/tmp/senpai-supervisor-github-token umask 077 printf '%s' "$GITHUB_TOKEN" > "$token_handoff" diff --git a/k8s/entrypoint-advisor.sh b/k8s/entrypoint-advisor.sh index 4dc7c4e19..e1e5de53c 100644 --- a/k8s/entrypoint-advisor.sh +++ b/k8s/entrypoint-advisor.sh @@ -31,7 +31,7 @@ envsubst '$PROBLEM_DIR $TARGET_REPO_URL $GH_REPO $ADVISOR_BRANCH $RESEARCH_TAG $ > "$SENPAI_OPENHANDS_ROLE_FILE" echo "=== Senpai Advisor ===" -echo "Runner repo: $REPO_URL (revision: $REPO_REVISION)" +echo "Runner repo: $SENPAI_REPO_URL (revision: $SENPAI_REPO_REVISION)" echo "Target repo: $TARGET_REPO_URL (base branch: ${TARGET_REPO_BRANCH:-}; advisor branch: $ADVISOR_BRANCH)" echo "Problem dir: $PROBLEM_DIR" echo "Tag: $RESEARCH_TAG" diff --git a/k8s/entrypoint-student.sh b/k8s/entrypoint-student.sh index 689fb15dd..b103610c7 100644 --- a/k8s/entrypoint-student.sh +++ b/k8s/entrypoint-student.sh @@ -26,7 +26,7 @@ fi : "${GITHUB_TOKEN:?GitHub bootstrap token is required}" echo "=== Senpai Student: $STUDENT_NAME ===" -echo "Runner repo: $REPO_URL (revision: $REPO_REVISION)" +echo "Runner repo: $SENPAI_REPO_URL (revision: $SENPAI_REPO_REVISION)" echo "Target repo: $TARGET_REPO_URL (base branch: ${TARGET_REPO_BRANCH:-}; advisor branch: $ADVISOR_BRANCH)" echo "Problem dir: $PROBLEM_DIR" echo "GitHub history: $GH_HISTORY_SCOPE" diff --git a/k8s/launch.py b/k8s/launch.py index 49381cf99..6bf1f02e9 100755 --- a/k8s/launch.py +++ b/k8s/launch.py @@ -19,7 +19,12 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from senpai_agent.launch_context import render_launch_context +from senpai_agent.launch_context import ( + LAUNCH_CONTEXT_ENV, + load_operator_instructions, + render_launch_context, +) +from senpai_agent.program_context import PROGRAM_PATH_ENV, normalize_program_path from launch_helpers import ( ensure_advisor_branch, @@ -65,16 +70,17 @@ class Args: target_repo_url: str # problem-package repo (entrypoint clones this into $PROBLEM_DIR; agent commits/PRs land here) — REQUIRED, no default target_repo_branch: str = "" # target repo branch used as the base when creating advisor_branch; empty = target repo default branch problem_dir: str = "target/" # active problem directory — entrypoint clones target_repo_url here (from senpai.yaml) + program_path: str = "" # target-repo-relative program.md; blank requires exactly one root/one-level match names: str = "" # comma-separated student names (e.g. "frieren,fern") n_students: int = 4 # number of students to launch (ignored if --names is provided) student_prefix: str = "" # make assignment labels unique across parallel launches using the same base names gpus_per_student: int = 1 # GPUs requested by each student pod cpu_per_gpu: int = 15 # CPU requested per student GPU memory_gi_per_gpu: int = 120 # memory Gi requested per student GPU - repo_url: str = ( - "https://github.com/wandb/senpai.git" # git repo URL (senpai runner) + senpai_repo_url: str = ( + "https://github.com/wandb/senpai.git" # public read-only runner source ) - repo_revision: str = ( + senpai_repo_revision: str = ( "" # exact runner commit; derived from :sha- image tags ) advisor_image: str = "" # advisor source-SHA tag or image digest — REQUIRED @@ -104,7 +110,7 @@ class Args: ) advisor: bool = False # also deploy the advisor pod (default: students only) extra_instructions: str = ( - "" # extra prompt text for the advisor: a .md file path or a literal string + "" # shared operator instructions: a .md file path or literal text ) timeout_minutes: float = ( 30.0 # training run wall-clock limit (SENPAI_TIMEOUT_MINUTES) @@ -280,7 +286,14 @@ def validate_timing_args(args: Args) -> None: ) -def build_extra_instructions( +def validate_program_path(args: Args) -> None: + try: + normalize_program_path(args.program_path) + except ValueError as error: + sys.exit(f"ERROR: --program_path: {error}") + + +def build_launch_context( args: Args, tag: str, student_list: list[str], @@ -296,11 +309,10 @@ def build_extra_instructions( advisor_branch=args.advisor_branch, target_base=args.target_repo_branch, students=student_list, - extra_instructions=args.extra_instructions, ) -def encoded_extra_instructions( +def encoded_launch_context( args: Args, tag: str, student_list: list[str], @@ -308,7 +320,7 @@ def encoded_extra_instructions( backend: str, ) -> str: return base64.b64encode( - build_extra_instructions( + build_launch_context( args, tag, student_list, @@ -317,6 +329,12 @@ def encoded_extra_instructions( ).decode() +def encoded_operator_instructions(args: Args) -> str: + return base64.b64encode( + load_operator_instructions(args.extra_instructions).encode() + ).decode() + + def render_student( template: str, student_name: str, @@ -334,10 +352,11 @@ def render_student( labels={"app": "senpai", "role": "student", "research-tag": tag}, data={ **role_model_config(args, "student"), - "REPO_URL": args.repo_url, - "REPO_REVISION": args.repo_revision, + "SENPAI_REPO_URL": args.senpai_repo_url, + "SENPAI_REPO_REVISION": args.senpai_repo_revision, "TARGET_REPO_URL": args.target_repo_url, "TARGET_REPO_BRANCH": args.target_repo_branch, + PROGRAM_PATH_ENV: args.program_path, "GH_REPO": target_repo_slug(args.target_repo_url), "STUDENT_NAME": student_name, "RESEARCH_TAG": tag, @@ -352,12 +371,13 @@ def render_student( "SENPAI_MAX_EPOCHS": str(args.max_epochs), "SENPAI_POLL_INTERVAL_S": str(args.poll_interval_s), "SENPAI_POLL_JITTER_S": str(args.poll_jitter_s), - "EXTRA_INSTRUCTIONS_B64": encoded_extra_instructions( + LAUNCH_CONTEXT_ENV: encoded_launch_context( args, tag, [student_name], backend="kubernetes", ), + "EXTRA_INSTRUCTIONS_B64": encoded_operator_instructions(args), "PROBLEM_DIR": args.problem_dir, "PVC_MOUNT_PATH": args.pvc_mount_path, "SENPAI_START_GATE_PATH": args.start_gate_path, @@ -397,10 +417,11 @@ def render_advisor( advisor_deployment_name = f"senpai-advisor-{tag}" data = { **role_model_config(args, "advisor"), - "REPO_URL": args.repo_url, - "REPO_REVISION": args.repo_revision, + "SENPAI_REPO_URL": args.senpai_repo_url, + "SENPAI_REPO_REVISION": args.senpai_repo_revision, "TARGET_REPO_URL": args.target_repo_url, "TARGET_REPO_BRANCH": args.target_repo_branch, + PROGRAM_PATH_ENV: args.program_path, "GH_REPO": target_repo_slug(args.target_repo_url), "RESEARCH_TAG": tag, "STUDENT_NAMES": ",".join(student_list), @@ -418,12 +439,13 @@ def render_advisor( "PVC_MOUNT_PATH": args.pvc_mount_path, "SENPAI_START_GATE_PATH": args.start_gate_path, } - data["EXTRA_INSTRUCTIONS_B64"] = encoded_extra_instructions( + data[LAUNCH_CONTEXT_ENV] = encoded_launch_context( args, tag, student_list, backend="kubernetes", ) + data["EXTRA_INSTRUCTIONS_B64"] = encoded_operator_instructions(args) configmap = render_configmap( name=advisor_configmap_name, labels={"app": "senpai", "role": "advisor", "research-tag": tag}, @@ -453,6 +475,7 @@ def main(): "ERROR: --gpus_per_student, --cpu_per_gpu, and --memory_gi_per_gpu must all be at least 1" ) validate_timing_args(args) + validate_program_path(args) validate_model_config(args) if not args.preflight_only: for role, image in ( @@ -466,10 +489,10 @@ def main(): ) try: advisor_revision = source_revision_for_image( - args.advisor_image, args.repo_revision + args.advisor_image, args.senpai_repo_revision ) student_revision = source_revision_for_image( - args.student_image, args.repo_revision + args.student_image, args.senpai_repo_revision ) except ValueError as error: sys.exit(f"ERROR: {error}") @@ -478,11 +501,16 @@ def main(): "ERROR: --advisor_image and --student_image must use the " "same source revision" ) - args.repo_revision = advisor_revision + args.senpai_repo_revision = advisor_revision if args.gh_history_scope not in {"branch", "repo", "fresh"}: sys.exit("ERROR: --gh_history_scope must be one of: branch, repo, fresh") - if target_repo_slug(args.target_repo_url) == target_repo_slug(args.repo_url): - sys.exit("ERROR: --target_repo_url must be a different repo from --repo_url") + if target_repo_slug(args.target_repo_url) == target_repo_slug( + args.senpai_repo_url + ): + sys.exit( + "ERROR: --target_repo_url must be a different repo from " + "--senpai_repo_url" + ) # Resolve student list before backend-independent GitHub preflight. if args.names: diff --git a/k8s/launch_helpers.py b/k8s/launch_helpers.py index a1f17c353..738b27337 100644 --- a/k8s/launch_helpers.py +++ b/k8s/launch_helpers.py @@ -145,19 +145,31 @@ def is_immutable_image_reference(image: str) -> bool: return bool(FULL_SHA_IMAGE.fullmatch(image) or DIGEST_IMAGE.fullmatch(image)) -def source_revision_for_image(image: str, explicit_revision: str = "") -> str: +def source_revision_for_image(image: str, senpai_repo_revision: str = "") -> str: """Resolve the exact runner commit that must match the image metadata.""" tagged = FULL_SHA_IMAGE.fullmatch(image) tagged_revision = tagged.group(1) if tagged else "" - if explicit_revision and not re.fullmatch(r"[0-9a-f]{40}", explicit_revision): - raise ValueError("repo_revision must be a full lowercase commit SHA") - if tagged_revision and explicit_revision and tagged_revision != explicit_revision: - raise ValueError("repo_revision does not match the image source-SHA tag") + if senpai_repo_revision and not re.fullmatch( + r"[0-9a-f]{40}", senpai_repo_revision + ): + raise ValueError( + "senpai_repo_revision must be a full lowercase commit SHA" + ) + if ( + tagged_revision + and senpai_repo_revision + and tagged_revision != senpai_repo_revision + ): + raise ValueError( + "senpai_repo_revision does not match the image source-SHA tag" + ) if tagged_revision: return tagged_revision - if DIGEST_IMAGE.fullmatch(image) and explicit_revision: - return explicit_revision - raise ValueError("digest-pinned images require an explicit repo_revision") + if DIGEST_IMAGE.fullmatch(image) and senpai_repo_revision: + return senpai_repo_revision + raise ValueError( + "digest-pinned images require an explicit senpai_repo_revision" + ) def kubectl_command( diff --git a/k8s/student-deployment.yaml b/k8s/student-deployment.yaml index 02b144629..1387c7769 100644 --- a/k8s/student-deployment.yaml +++ b/k8s/student-deployment.yaml @@ -65,12 +65,12 @@ spec: mkdir -p /workspace git init /workspace/senpai GIT_ASKPASS="$askpass" GIT_TERMINAL_PROMPT=0 \ - git -C /workspace/senpai fetch --depth 1 "$REPO_URL" "$REPO_REVISION" + git -C /workspace/senpai fetch --depth 1 "$SENPAI_REPO_URL" "$SENPAI_REPO_REVISION" git -C /workspace/senpai checkout --detach FETCH_HEAD cd /workspace/senpai - git remote add origin "$REPO_URL" - test "$(git rev-parse HEAD)" = "$REPO_REVISION" - test "$SENPAI_IMAGE_REVISION" = "$REPO_REVISION" + git remote add origin "$SENPAI_REPO_URL" + test "$(git rev-parse HEAD)" = "$SENPAI_REPO_REVISION" + test "$SENPAI_IMAGE_REVISION" = "$SENPAI_REPO_REVISION" token_handoff=/tmp/senpai-supervisor-github-token umask 077 printf '%s' "$GITHUB_TOKEN" > "$token_handoff" diff --git a/plugins/senpai/skills/bootstrap-target/SKILL.md b/plugins/senpai/skills/bootstrap-target/SKILL.md index f5483a687..34b87ba98 100644 --- a/plugins/senpai/skills/bootstrap-target/SKILL.md +++ b/plugins/senpai/skills/bootstrap-target/SKILL.md @@ -5,12 +5,11 @@ name: bootstrap-target description: > - Create or improve Senpai target-repository onboarding files: program.md plus - instructions/prompt-advisor.md and instructions/prompt-student.md. Use this - skill whenever the user wants to point Senpai at a fresh ML or research target + Create or improve a Senpai target repository's program.md. Use this skill + whenever the user wants to point Senpai at a fresh ML or research target repository, define the research objective, primary metric, benchmark contract, - allowed edit boundaries, W&B reporting contract, advisor/student prompts, or - prepare a repo for autonomous advisor/student experiment loops. + allowed edit boundaries, W&B reporting contract, or prepare a repo for + autonomous advisor/student experiment loops. argument-hint: "" model: claude-opus-4-8 effort: high @@ -18,18 +17,7 @@ effort: high # bootstrap-target -Turn an arbitrary ML or research repository into a Senpai-ready target package. - -The output is usually three files in the target repo: - -- `program.md` - the authoritative research contract. -- `instructions/prompt-advisor.md` - the target-specific advisor overlay. -- `instructions/prompt-student.md` - the target-specific student overlay. - -These files are not generic documentation. They are the launch briefing for an -autonomous research lab. Write them so the advisor knows what kind of science to -direct, the student knows what is legitimate to edit and run, and both roles -agree on what counts as a valid result. +Turn an arbitrary ML or research repository into a Senpai-ready target package by writing `program.md`, the authoritative research contract. It is not generic documentation: it is the shared launch briefing for an autonomous research lab. Write it so the advisor knows what science to direct, the student knows what is legitimate to edit and run, and both roles agree on a valid result. ## References @@ -100,28 +88,8 @@ Load these only when they help the current task: benchmark equivalence. For a fuller skeleton, read `references/program-template.md`. -5. **Write short role overlays with target flavor.** - The files in `instructions/` should not repeat the global Senpai loop. They - are overlays: concise, target-specific steering that helps the advisor and - student inhabit this particular research program. - - `prompt-advisor.md` should contain only target-specific experiment-selection - judgment: promising hypothesis families, metric tradeoffs, target-specific - mistakes, and context worth reading first. - - `prompt-student.md` should contain only target-specific implementation - guidance: important files, protected invariants, exact run-command patterns, - and interpretation pitfalls. - - Do not repeat runtime identity, branch or W&B setup, generic role workflow, - tool instructions, or reporting rules already supplied by the control plane - and `program.md`. - -6. **Validate the target package.** - Check that referenced paths exist, command patterns are plausible, metric - names match code where possible, protected files are explicit, and the role - overlays do not contradict `program.md`. If commands cannot be run locally, - state what was verified statically. +5. **Validate the target package.** + Check that referenced paths exist, command patterns are plausible, metric names match code where possible, and protected files are explicit. If commands cannot be run locally, state what was verified statically. ## Writing Principles @@ -142,10 +110,6 @@ Make benchmark integrity painfully clear. Spell out data leakage risks, split immutability, forbidden shortcuts, external-source bans, seed/cherry-picking rules, hidden-test rules, and metric finality. -Keep `instructions/` light. The global Senpai role files already define the -loop; target prompts should only add target-specific setup, commands, and -judgment. - Fail loudly on ambiguity. If the primary metric, target command, protected files, or benchmark rules are unclear, interview the user before finalizing the files. diff --git a/plugins/senpai/skills/bootstrap-target/references/interview-question-bank.md b/plugins/senpai/skills/bootstrap-target/references/interview-question-bank.md index bd91167d3..3136e468c 100644 --- a/plugins/senpai/skills/bootstrap-target/references/interview-question-bank.md +++ b/plugins/senpai/skills/bootstrap-target/references/interview-question-bank.md @@ -48,7 +48,7 @@ can read. - What do feature and target channels mean? - Which splits test in-distribution performance, OOD generalization, or paper-facing claims? -- Are there known hard regimes, rare cases, physical constraints, or failure +- Are there known hard regimes, rare cases, domain constraints, or failure modes that advisors should account for? ## Operations diff --git a/plugins/senpai/skills/bootstrap-target/references/program-template.md b/plugins/senpai/skills/bootstrap-target/references/program-template.md index 178bc5a6f..64fe4626c 100644 --- a/plugins/senpai/skills/bootstrap-target/references/program-template.md +++ b/plugins/senpai/skills/bootstrap-target/references/program-template.md @@ -5,8 +5,7 @@ specific, and operational. ## Title And Target Summary -Name the target plainly. In the first paragraph, say what inputs come in, what -outputs are predicted or optimized, and what kind of research program this is. +Name the target plainly. In the first paragraph, say what inputs come in, what outputs are predicted or optimized, and what research problem `program.md` governs. Example: @@ -68,7 +67,7 @@ is part of the benchmark contract, say that changing it invalidates results. Document the callable interface and invariants that evaluation depends on: - input and output tensor shapes -- normalized versus physical target spaces +- normalized versus original target spaces - masking and padding behavior - checkpoint-selection metric - final validation/test evaluation behavior @@ -174,10 +173,9 @@ hyperparameter search. ## Roles -End by pointing to the target role overlays: +End with the target's coordination model when it adds useful context: ```markdown Research is coordinated through GitHub PRs with an advisor/student model. GitHub -Issues are used for communication with the human researcher team. See -`instructions/prompt-advisor.md` and `instructions/prompt-student.md`. +Issues are used for communication with the human researcher team. ``` diff --git a/plugins/senpai/skills/delegate-subagents/SKILL.md b/plugins/senpai/skills/delegate-subagents/SKILL.md new file mode 100644 index 000000000..693f1471d --- /dev/null +++ b/plugins/senpai/skills/delegate-subagents/SKILL.md @@ -0,0 +1,27 @@ +--- +# SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +# SPDX-License-Identifier: Apache-2.0 +# SPDX-PackageName: senpai + +name: delegate-subagents +description: > + Delegate bounded research, inspection, synthesis, planning, review, or command + execution when spawn_agents is available. Use when independent work can run + in parallel or a fresh specialist perspective would improve a decision. +--- + +# Delegate subagents + +Batch independent tasks in one `spawn_agents` call with a stable `batch_key`. Give each child a self-contained assignment and ask for a compact, evidence-linked result. Normally use `include_context=false`. + +Choose: + +- `explore` for local code, data, artifacts, or history; +- `search_general_web` for current public sources; +- `search_research_publications` for scholarly literature and primary papers; +- `bash-runner` with `model=fast` for tests, builds, and bounded commands; and +- `general-purpose` for mixed analysis, planning, review, or implementation. + +Use `fast` for mechanical work, `smart` for subtle synthesis, and `frontier` for the hardest judgment. For a fresh independent perspective, use `model="frontier"`, `agent="general-purpose"`, and `include_context=false`; ask for research, critique, ideas, or a plan rather than edits. The search agent and its search skills own source selection and search mechanics. + +`spawn_agents` returns task IDs immediately. Continue useful work, then use bounded `await_agents` calls with `all`, `first`, `quorum`, or `change` and a timeout of at most 300 seconds. Use `agent_status` for one non-blocking snapshot and `cancel_agents` when work is no longer useful; do not poll. diff --git a/plugins/senpai/skills/delegate-subagents/agents/openai.yaml b/plugins/senpai/skills/delegate-subagents/agents/openai.yaml new file mode 100644 index 000000000..90805db40 --- /dev/null +++ b/plugins/senpai/skills/delegate-subagents/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Delegate subagents" + short_description: "Launch and coordinate bounded Senpai subagents" + default_prompt: "Use $delegate-subagents to plan and coordinate this delegated work." diff --git a/plugins/senpai/skills/review-experiment/SKILL.md b/plugins/senpai/skills/review-experiment/SKILL.md index a24bd52ca..9e75e9b2a 100644 --- a/plugins/senpai/skills/review-experiment/SKILL.md +++ b/plugins/senpai/skills/review-experiment/SKILL.md @@ -107,9 +107,7 @@ still answer the assigned question with one bounded correction. ## Record the outcome -Update the target-prescribed baseline or research log with the PR, metrics, run -IDs and links, reproduction command, and conclusion. Commit that advisor-owned -change and publish it only through: +Update the baseline or research log in the format prescribed by `program.md`, including the PR, metrics, run IDs and links, reproduction command, and conclusion. Commit that advisor-owned change and publish it only through: ```json { diff --git a/plugins/senpai/skills/submit-experiment-results/SKILL.md b/plugins/senpai/skills/submit-experiment-results/SKILL.md index 6b895254e..07ccea812 100644 --- a/plugins/senpai/skills/submit-experiment-results/SKILL.md +++ b/plugins/senpai/skills/submit-experiment-results/SKILL.md @@ -14,7 +14,7 @@ effort: high # Submit experiment results -First make the target worktree clean by committing only the assigned change. +First make the repository worktree clean by committing only the assigned change. Collect the current local commit SHA and the current remote assignment-branch SHA. Build the strict `ExperimentResult` required by `submit_experiment_result`: diff --git a/senpai.yaml b/senpai.yaml index 1b17b32f9..2a5fc3b15 100644 --- a/senpai.yaml +++ b/senpai.yaml @@ -6,10 +6,15 @@ # problem-package repo's root ends up at ./target/ in the pod workspace. problem_dir: target/ -# git (senpai itself) -repo_url: https://github.com/wandb/senpai.git +# Target-repository-relative program.md to append to every role's system +# prompt. Blank requires exactly one program.md at root or one level below. +program_path: "" + +# Public Senpai runner source, fetched read-only. Override this only when the +# images were built from a fork or private Senpai repository. +senpai_repo_url: https://github.com/wandb/senpai.git # Required only for digest-pinned images; source-SHA tags derive it automatically. -repo_revision: "" +senpai_repo_revision: "" # problem-package repo is REQUIRED and must be passed on the CLI # python k8s/launch.py --tag --target_repo_url https://github.com//.git diff --git a/senpai_agent/PROMPTS.py b/senpai_agent/PROMPTS.py new file mode 100644 index 000000000..71cea0fe7 --- /dev/null +++ b/senpai_agent/PROMPTS.py @@ -0,0 +1,127 @@ +# SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +# SPDX-License-Identifier: Apache-2.0 +# SPDX-PackageName: senpai + +"""Model-facing instruction templates assembled by Python.""" + +from __future__ import annotations + +import re + + +CONTEXT_RECOVERY_PROMPT = """# Conversation context recovery + +The previous model-visible conversation branch exhausted or corrupted its context. Its complete raw trace and workspace are preserved, but the active model context was reset. Inspect preserved state as needed, and verify any interrupted action before relying on it. + +# Current actionable state + +{{CURRENT_PROMPT}}""" + +INITIAL_CONTROLLER_PROMPT = """{{FULL_PROMPT}} + +Current time (UTC): {{CURRENT_TIME}} + +# Current GitHub state + +Actionable events follow as separately tracked messages.""" + +CONTINUATION_CONTROLLER_PROMPT = """Continue the {{ROLE}} loop. Current time (UTC): {{CURRENT_TIME}}. Actionable GitHub events follow as separately tracked messages.""" + +OPERATOR_INSTRUCTIONS_PROMPT = """# Additional operator instructions + +{{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}} + +# Senpai role + +{{ROLE}} + +{{PROGRAM}} + +{{LAUNCH}}""" + +PROGRAM_SYSTEM_PROMPT = """# program.md - {{PROGRAM_PATH}} + +{{PROGRAM_CONTENT}}""" + +DELEGATED_SEARCH_MODE_PROMPT = """Search mode: {{SEARCH_MODE}} + +{{ASSIGNMENT}}""" + +DELEGATED_TASK_PROMPT = """# Delegated task + +You are a fresh Senpai subagent. Perform only the assigned task and return a concise, evidence-linked report to the parent. + +{{ASSIGNMENT}}""" + +DELEGATED_TASK_WITH_CONTEXT_PROMPT = """# Delegated task with parent context + +The JSON below is the complete model-visible parent context at delegation time. Use it as evidence, perform only the assigned task, and return a concise, evidence-linked report. + + +{{PARENT_CONTEXT_JSON}} + + +{{ASSIGNMENT}}""" + +RECOVERED_ACTION_PROMPT = """Senpai restarted before this action completed. Inspect the preserved workspace and rerun it explicitly only if it is still needed.""" + +ADVISOR_EVENT_PROMPT = """# Senpai event: {{KIND}} + +Observed at (UTC): {{OBSERVED_AT}} + +```json +{{PAYLOAD}} +```""" + +EVENT_PROMPT = """## {{KIND}} + +{{PAYLOAD}}""" + +WORKSPACE_DIVERGENCE_PROMPT = """The workspace cannot be reconciled automatically because local assignment history diverged or dirty work belongs to another checkout. Senpai preserved every local commit and dirty file without changing the checkout. Inspect and reconcile it explicitly; do not reset or discard local work.""" + +TRUNCATED_FEEDBACK_PROMPT = """Open feedback_url to read the omitted text.""" + +MONITOR_TRAINING_STARTED_PROMPT = """Training {{TRAINING_ID}} is durably monitored. You may finish this turn; the controller will resume this same conversation ({{CONVERSATION_ID}}) when action is needed.""" + +AWAIT_AGENTS_SATISFIED_PROMPT = """Use the returned state now; unfinished sibling tasks keep running unless you cancel them explicitly.""" + +AWAIT_AGENTS_TIMEOUT_PROMPT = """The tasks keep running. Continue useful parent work, inspect later with agent_status, or use join='change' for the next bounded wait; repeating the same long all-results wait will block on the same unfinished tasks.""" + +DELEGATED_TASK_FINISHED_PROMPT = """Subagent task {{TASK_ID}} finished. + +{{RESULT}}""" + +DELEGATED_TASK_BACKGROUND_PROMPT = """Subagent task {{TASK_ID}} is running in the background. Its result or error will arrive as a durable local event.""" + +DELEGATE_AGENT_DEPRECATION_PROMPT = """delegate_agent is deprecated and cannot launch an agent. Use spawn_agents with a stable batch_key, then pass its task IDs to await_agents.""" + + +_PLACEHOLDER = re.compile(r"{{([A-Z][A-Z0-9_]*)}}") + + +def render_prompt(template: str, /, **values: str) -> str: + """Render one prompt without interpreting placeholders in inserted values.""" + + placeholders = set(_PLACEHOLDER.findall(template)) + missing = sorted(placeholders - values.keys()) + unexpected = sorted(values.keys() - placeholders) + if missing or unexpected: + details = [] + if missing: + details.append(f"missing: {', '.join(missing)}") + if unexpected: + details.append(f"unexpected: {', '.join(unexpected)}") + raise ValueError(f"invalid prompt values: {'; '.join(details)}") + return _PLACEHOLDER.sub(lambda match: values[match.group(1)], template) diff --git a/senpai_agent/advisor.py b/senpai_agent/advisor.py index bade9c018..d8295cfa3 100644 --- a/senpai_agent/advisor.py +++ b/senpai_agent/advisor.py @@ -13,6 +13,11 @@ from pydantic import BaseModel, ConfigDict, Field from senpai_agent.inbox import PersistentInbox +from senpai_agent.PROMPTS import ( + ADVISOR_EVENT_PROMPT, + EVENT_PROMPT, + render_prompt, +) class AdvisorEvent(BaseModel): @@ -26,10 +31,11 @@ class AdvisorEvent(BaseModel): def to_user_message(self) -> str: payload = json.dumps(self.payload, indent=2, sort_keys=True) observed_at = self.observed_at.astimezone(UTC).isoformat() - return ( - f"# Senpai event: {self.kind}\n\n" - f"Observed at (UTC): {observed_at}\n\n" - f"```json\n{payload}\n```" + return render_prompt( + ADVISOR_EVENT_PROMPT, + KIND=self.kind, + OBSERVED_AT=observed_at, + PAYLOAD=payload, ) def to_inbox_message(self) -> str: @@ -38,9 +44,10 @@ def to_inbox_message(self) -> str: for key, value in self.payload.items() if key != "parent_conversation_id" } - return ( - f"## {self.kind}\n\n" - f"{json.dumps(payload, sort_keys=True, separators=(',', ':'))}" + return render_prompt( + EVENT_PROMPT, + KIND=self.kind, + PAYLOAD=json.dumps(payload, sort_keys=True, separators=(",", ":")), ) @@ -148,10 +155,6 @@ def __exit__( self.close() -def compose_system_instructions(harness: str, role: str) -> str: - return f"# Senpai harness\n\n{harness.strip()}\n\n# Senpai role\n\n{role.strip()}\n" - - def advisor_conversation_id( state_dir: Path, explicit_id: str | None = None, diff --git a/senpai_agent/controller.py b/senpai_agent/controller.py index f0419f584..2edf3e74d 100644 --- a/senpai_agent/controller.py +++ b/senpai_agent/controller.py @@ -13,15 +13,13 @@ from datetime import UTC, datetime from functools import partial from pathlib import Path -from string import Template from typing import Literal, Protocol from uuid import UUID -from senpai_agent.agent_markdown import read_agent_markdown, strip_spdx_header +from senpai_agent.agent_markdown import strip_spdx_header from senpai_agent.advisor import ( AdvisorEvent, AdvisorEventStore, - compose_system_instructions, ) from senpai_agent.github.mailbox import ActiveGitHubWatcher, GitHubMailbox from senpai_agent.inbox import ( @@ -42,10 +40,19 @@ TrainingMonitorEngine, 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 ( AssignmentConversationRegistry, ConversationBatch, - ConversationStateLedger, + StartedConversationLedger, StudentConversationSelector, WorkspaceDivergenceLedger, ) @@ -54,20 +61,6 @@ _EDGE_TRIGGERED_EVENT_KINDS = frozenset({"research_base_changed"}) -_PROMPT_TEMPLATE_VARIABLES = frozenset( - { - "ADVISOR_BRANCH", - "GH_REPO", - "GPUS_PER_STUDENT", - "PROBLEM_DIR", - "RESEARCH_TAG", - "STUDENT_NAME", - "STUDENT_NAMES", - "TARGET_REPO_URL", - "WANDB_ENTITY", - "WANDB_PROJECT", - } -) @dataclass(frozen=True, slots=True) @@ -117,14 +110,10 @@ def _is_context_history_failure(error: Exception) -> bool: def _context_recovery_prompt(full_prompt: str, current_prompt: str) -> str: - research_brief = "" if full_prompt in current_prompt else f"{full_prompt}\n\n" - return research_brief + ( - "# Conversation context recovery\n\n" - "The previous model-visible conversation branch exhausted or corrupted " - "its context. Its complete raw trace and workspace are preserved, but " - "the active model context was reset. Inspect preserved state as needed, " - "and verify any interrupted action before relying on it." - f"\n\n# Current actionable state\n\n{current_prompt}" + initial_context = "" if full_prompt in current_prompt else f"{full_prompt}\n\n" + return initial_context + render_prompt( + CONTEXT_RECOVERY_PROMPT, + CURRENT_PROMPT=current_prompt, ) @@ -289,8 +278,7 @@ def __init__( turns: TurnRunner, conversation_id: UUID, full_prompt: str, - system_context: str = "", - conversation_state: ConversationStateLedger | None = None, + started_conversations: StartedConversationLedger | None = None, inbox: PersistentInbox | None = None, workspace_divergence_state: WorkspaceDivergenceLedger | None = None, conversation_for_events: ( @@ -332,8 +320,7 @@ def __init__( ) self.start_gate_poll_seconds = start_gate_poll_seconds self.full_prompt = full_prompt.strip() - self.system_context = system_context.strip() - self.conversation_state = conversation_state + self.started_conversations = started_conversations self.inbox = inbox or PersistentInbox() self.workspace_divergence_state = workspace_divergence_state self.sleep = sleep @@ -564,29 +551,11 @@ def _next_ready_turn( ): continue continuing = self._has_started(conversation_id) - refresh_system_context = ( - continuing - and self.conversation_state is not None - and not self.conversation_state.is_context_current( - conversation_id, - self.system_context, - ) - ) turn = self.inbox.next_turn( conversation_id, - self._prompt( - (), - continuing=continuing, - refresh_system_context=refresh_system_context, - ), + self._prompt((), continuing=continuing), legacy_prompt_identity=( - f"initial:{self.full_prompt}" - if not continuing - else ( - f"system-context:{self.system_context}" - if refresh_system_context - else None - ) + f"initial:{self.full_prompt}" if not continuing else None ), ) if turn is not None: @@ -663,17 +632,14 @@ def _sleep(self, phase: str, seconds: float) -> None: def _has_started(self, conversation_id: UUID) -> bool: return conversation_id in self._started or ( - self.conversation_state is not None - and self.conversation_state.has_started(conversation_id) + self.started_conversations is not None + and self.started_conversations.has_started(conversation_id) ) def _mark_success(self, conversation_id: UUID) -> None: self._started.add(conversation_id) - if self.conversation_state is not None: - self.conversation_state.mark_success( - conversation_id, - self.system_context, - ) + if self.started_conversations is not None: + self.started_conversations.mark_started(conversation_id) def _workspace_divergence_key(self, conversation_id: UUID) -> str | None: if self.workspace_divergence_state is not None: @@ -733,62 +699,52 @@ def _prompt( _events: Sequence[ControllerEvent], *, continuing: bool, - refresh_system_context: bool = False, ) -> str: now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") if not continuing: - return ( - f"{self.full_prompt}\n\nCurrent time (UTC): {now}\n\n" - "# Current GitHub state\n\n" - "Actionable events follow as separately tracked messages." + return render_prompt( + INITIAL_CONTROLLER_PROMPT, + FULL_PROMPT=self.full_prompt, + CURRENT_TIME=now, ) - prompt = ( - f"Continue the {self.role} loop. Current time (UTC): {now}. " - "Actionable GitHub events follow as separately tracked messages." + return render_prompt( + CONTINUATION_CONTROLLER_PROMPT, + ROLE=self.role, + CURRENT_TIME=now, ) - if refresh_system_context: - prompt += ( - "\n\n# Updated Senpai system context\n\n" - "The Senpai operating context or research brief changed since " - "this conversation last ran. Treat the following as current:\n\n" - f"{self.system_context}" - ) - return prompt def _full_prompt(role: Literal["advisor", "student"], env: Mapping[str, str]) -> str: - workspace = Path(env["SENPAI_OPENHANDS_WORKSPACE"]).resolve() - instructions = workspace / "instructions" / f"prompt-{role}.md" - program = workspace / "program.md" - template_env = { - key: env[key] for key in _PROMPT_TEMPLATE_VARIABLES if key in env - } - role_prompt = Template(read_agent_markdown(instructions)).safe_substitute( - template_env - ) - prompt = ( - "# Research programme\n\n" - f"{read_agent_markdown(program).strip()}\n\n" - f"# {role.title()} task\n\n" - f"{role_prompt.strip()}" - ) + sections = [] encoded_extra = env.get("EXTRA_INSTRUCTIONS_B64") if encoded_extra: extra = b64decode(encoded_extra, validate=True).decode() - prompt += ( - "\n\n# Additional launch instructions\n\n" - f"{strip_spdx_header(extra).strip()}" + sections.append( + render_prompt( + OPERATOR_INSTRUCTIONS_PROMPT, + INSTRUCTIONS=strip_spdx_header(extra).strip(), + ) ) - identity = ( - f"Role: {role}; repository: {env['GH_REPO']}; " - f"advisor branch: {env['ADVISOR_BRANCH']}; " - f"W&B: {env['WANDB_ENTITY']}/{env['WANDB_PROJECT']}." - ) if role == "advisor": - identity += f" Students: {env.get('STUDENT_NAMES', '')}." + 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 += f" Student: {env['STUDENT_NAME']}." - return f"{prompt}\n\n# Runtime identity\n\n{identity}" + 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) def _role_interval( @@ -815,7 +771,6 @@ def controller_main( from senpai_agent.delegation import reconcile_delegated_tasks from senpai_agent.openhands_runner import ( parse_runner_args, - read_role_instructions, resolve_config, scrub_model_credentials, ) @@ -908,14 +863,6 @@ def controller_main( ) full_prompt = _full_prompt(role, env) - system_context = compose_system_instructions( - read_role_instructions(runner_config.harness_file), - read_role_instructions(runner_config.role_file), - ) - continuation_context = ( - f"{system_context.strip()}\n\n" - f"# Current research brief\n\n{full_prompt}" - ) inbox = PersistentInbox( runner_config.state_dir / "delivery-inbox.sqlite3", legacy_path=runner_config.state_dir / "pending-message-deliveries.json", @@ -933,9 +880,8 @@ def controller_main( mailbox=mailbox, turns=turns, conversation_id=runner_config.conversation_id, - system_context=continuation_context, - conversation_state=ConversationStateLedger( - runner_config.state_dir / "conversation-state.json" + started_conversations=StartedConversationLedger( + runner_config.state_dir / "started-conversations.json" ), inbox=inbox, workspace_divergence_state=( diff --git a/senpai_agent/delegation.py b/senpai_agent/delegation.py index cd2c96b74..509b420ee 100644 --- a/senpai_agent/delegation.py +++ b/senpai_agent/delegation.py @@ -12,6 +12,7 @@ import threading import time import uuid +from base64 import b64encode from collections.abc import Callable, Mapping, Sequence from contextlib import contextmanager from dataclasses import dataclass @@ -32,7 +33,20 @@ from pydantic import BaseModel, Field, model_validator from senpai_agent.advisor import AdvisorEvent, AdvisorEventStore +from senpai_agent.launch_context import LAUNCH_CONTEXT_ENV from senpai_agent.processes import terminate_process_group +from senpai_agent.program_context import PROGRAM_PATH_ENV +from senpai_agent.PROMPTS import ( + AWAIT_AGENTS_SATISFIED_PROMPT, + AWAIT_AGENTS_TIMEOUT_PROMPT, + DELEGATE_AGENT_DEPRECATION_PROMPT, + DELEGATED_SEARCH_MODE_PROMPT, + DELEGATED_TASK_BACKGROUND_PROMPT, + DELEGATED_TASK_FINISHED_PROMPT, + DELEGATED_TASK_PROMPT, + DELEGATED_TASK_WITH_CONTEXT_PROMPT, + render_prompt, +) from senpai_agent.secrets import scrub_github_credentials if TYPE_CHECKING: @@ -144,6 +158,8 @@ class DelegationConfig: enable_browser: bool command_secrets: Mapping[str, str] role: str + program_path: str + launch_context: str root_state_dir: Path | None = None tree_id: str | None = None depth: int = 0 @@ -199,24 +215,27 @@ def configured_child_runner_factory() -> ChildAgentRunnerFactory: def render_child_prompt(request: DelegationRequest, task: str) -> str: assignment = task.strip() if request.search_mode is not None: - assignment = f"Search mode: {request.search_mode}\n\n{assignment}" + assignment = render_prompt( + DELEGATED_SEARCH_MODE_PROMPT, + SEARCH_MODE=request.search_mode, + ASSIGNMENT=assignment, + ) if not request.parent_context: return ( - "# Delegated task\n\n" - "You are a fresh Senpai subagent. Perform only the assigned task " - "and return a concise, evidence-linked report to the parent.\n\n" - f"{assignment}\n" + render_prompt( + DELEGATED_TASK_PROMPT, + ASSIGNMENT=assignment, + ) + + "\n" ) context = [message.model_dump(mode="json") for message in request.parent_context] return ( - "# Delegated task with parent context\n\n" - "The JSON below is the complete model-visible parent context at " - "delegation time. Use it as evidence, perform only the assigned task, " - "and return a concise, evidence-linked report.\n\n" - "\n" - f"{json.dumps(context, separators=(',', ':'))}\n" - "\n\n" - f"{assignment}\n" + render_prompt( + DELEGATED_TASK_WITH_CONTEXT_PROMPT, + PARENT_CONTEXT_JSON=json.dumps(context, separators=(",", ":")), + ASSIGNMENT=assignment, + ) + + "\n" ) @@ -389,6 +408,10 @@ def environment(self) -> dict[str, str]: ) if self._config.github_trusted_actor is not None: environment["SENPAI_GITHUB_ACTOR"] = self._config.github_trusted_actor + environment[PROGRAM_PATH_ENV] = self._config.program_path + environment[LAUNCH_CONTEXT_ENV] = b64encode( + self._config.launch_context.encode() + ).decode() return environment def start( @@ -560,14 +583,18 @@ def to_llm_content(self) -> Sequence[TextContent]: if self.status == "finished": return [ TextContent( - text=f"Subagent task {self.task_id} finished.\n\n{self.result or ''}" + text=render_prompt( + DELEGATED_TASK_FINISHED_PROMPT, + TASK_ID=self.task_id, + RESULT=self.result or "", + ) ) ] return [ TextContent( - text=( - f"Subagent task {self.task_id} is running in the background. " - "Its result or error will arrive as a durable local event." + text=render_prompt( + DELEGATED_TASK_BACKGROUND_PROMPT, + TASK_ID=self.task_id, ) ) ] @@ -1770,10 +1797,7 @@ def __call__(self, action, conversation=None) -> AwaitAgentsObservation: tasks=tasks, changed_task_ids=changed, waited_seconds=round(time.monotonic() - started, 3), - guidance=( - "Use the returned state now; unfinished sibling tasks keep " - "running unless you cancel them explicitly." - ), + guidance=AWAIT_AGENTS_SATISFIED_PROMPT, ) remaining = deadline - time.time() if remaining <= 0 or self._interrupted.wait(min(0.1, remaining)): @@ -1785,12 +1809,7 @@ def __call__(self, action, conversation=None) -> AwaitAgentsObservation: tasks=tasks, changed_task_ids=changed, waited_seconds=round(time.monotonic() - started, 3), - guidance=( - "The tasks keep running. Continue useful parent work, inspect " - "later with agent_status, or use join='change' for the next " - "bounded wait; repeating the same long all-results " - "wait will block on the same unfinished tasks." - ), + guidance=AWAIT_AGENTS_TIMEOUT_PROMPT, ) tasks, uncollected_terminal = self.manager.await_snapshot( action.task_ids, @@ -1836,12 +1855,6 @@ def __call__(self, action, conversation=None) -> CancelAgentsObservation: ) -_DELEGATE_AGENT_DEPRECATION = ( - "delegate_agent is deprecated and cannot launch an agent. Use spawn_agents " - "with a stable batch_key, then pass its task IDs to await_agents." -) - - class _DeprecatedDelegateAgentExecutor( ToolExecutor[DelegateAgentAction, DelegateAgentObservation] ): @@ -1853,7 +1866,7 @@ def __call__( return DelegateAgentObservation( task_id="deprecated", status="finished", - result=_DELEGATE_AGENT_DEPRECATION, + result=DELEGATE_AGENT_DEPRECATION_PROMPT, ) diff --git a/senpai_agent/github/mailbox/feedback.py b/senpai_agent/github/mailbox/feedback.py index 14322da34..8f23db595 100644 --- a/senpai_agent/github/mailbox/feedback.py +++ b/senpai_agent/github/mailbox/feedback.py @@ -8,6 +8,7 @@ from senpai_agent.mailbox import ControllerEvent from senpai_agent.models import AssignmentRecord +from senpai_agent.PROMPTS import TRUNCATED_FEEDBACK_PROMPT from .ledger import read_feedback_ledger, write_feedback_ledger from .values import ( @@ -111,9 +112,7 @@ def student_pr_feedback_events( if message_truncated: payload.update( message_truncated=True, - full_message_instruction=( - "Open feedback_url to read the omitted text." - ), + full_message_instruction=TRUNCATED_FEEDBACK_PROMPT, ) if surface == "review": payload["state"] = str(item["state"]) diff --git a/senpai_agent/launch_context.py b/senpai_agent/launch_context.py index 0092f95c0..1df5a8255 100644 --- a/senpai_agent/launch_context.py +++ b/senpai_agent/launch_context.py @@ -2,17 +2,18 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-PackageName: senpai -"""Render the launch-time role context stored in system_instructions/.""" +"""Render and transport launch-time instructions.""" +import binascii import re +from base64 import b64decode from pathlib import Path from senpai_agent.agent_markdown import read_agent_markdown, strip_spdx_header INSTRUCTIONS_ROOT = Path(__file__).resolve().parent.parent / "system_instructions" -RUNTIME_TEMPLATE = INSTRUCTIONS_ROOT / "SENPAI-LAUNCH-RUNTIME.md" -ISOLATION_TEMPLATE = INSTRUCTIONS_ROOT / "SENPAI-LAUNCH-ISOLATION.md" -OPERATOR_TEMPLATE = INSTRUCTIONS_ROOT / "SENPAI-OPERATOR-INSTRUCTIONS.md" +LAUNCH_CONTEXT_TEMPLATE = INSTRUCTIONS_ROOT / "SENPAI-LAUNCH-CONTEXT.md" +LAUNCH_CONTEXT_ENV = "SENPAI_LAUNCH_CONTEXT_B64" PLACEHOLDER = re.compile(r"{{([A-Z_]+)}}") @@ -36,36 +37,45 @@ def render_launch_context( advisor_branch: str, target_base: str, students: list[str], - extra_instructions: str = "", ) -> str: - """Render backend facts, isolation, and optional operator instructions.""" + """Render authoritative runtime and isolation rules.""" - sections = [ - _render( - RUNTIME_TEMPLATE, - { - "BACKEND": backend, - "GPUS_PER_STUDENT": str(gpus_per_student), - "TIMEOUT_MINUTES": f"{timeout_minutes:g}", - "MAX_EPOCHS": str(max_epochs), - }, - ), - _render( - ISOLATION_TEMPLATE, - { - "TAG": tag, - "ADVISOR_BRANCH": advisor_branch, - "TARGET_BASE": target_base or "", - "STUDENTS": ", ".join(students), - }, - ), - ] - if extra_instructions: - path = Path(extra_instructions) - text = ( - read_agent_markdown(path) - if path.exists() - else strip_spdx_header(extra_instructions) - ) - sections.append(_render(OPERATOR_TEMPLATE, {"EXTRA_INSTRUCTIONS": text})) - return "\n\n".join(sections) + return _render( + LAUNCH_CONTEXT_TEMPLATE, + { + "BACKEND": backend, + "GPUS_PER_STUDENT": str(gpus_per_student), + "TIMEOUT_MINUTES": f"{timeout_minutes:g}", + "MAX_EPOCHS": str(max_epochs), + "TAG": tag, + "ADVISOR_BRANCH": advisor_branch, + "TARGET_BASE": target_base or "", + "STUDENTS": ", ".join(students), + }, + ) + + +def load_operator_instructions(value: str) -> str: + """Load optional human guidance without granting it system authority.""" + + if not value: + return "" + path = Path(value) + text = read_agent_markdown(path) if path.exists() else strip_spdx_header(value) + return text.strip() + + +def decode_launch_context(encoded: str) -> str: + """Decode the mandatory authoritative context at process startup.""" + + if not encoded: + raise ValueError(f"{LAUNCH_CONTEXT_ENV} is required") + try: + context = b64decode(encoded, validate=True).decode() + except (binascii.Error, UnicodeDecodeError, ValueError) as error: + raise ValueError( + f"{LAUNCH_CONTEXT_ENV} must be valid base64-encoded UTF-8" + ) from error + if not context.strip(): + raise ValueError(f"{LAUNCH_CONTEXT_ENV} must not be empty") + return context.strip() diff --git a/senpai_agent/mailbox.py b/senpai_agent/mailbox.py index ee3a65e99..957043187 100644 --- a/senpai_agent/mailbox.py +++ b/senpai_agent/mailbox.py @@ -10,6 +10,7 @@ from typing import Protocol from senpai_agent.advisor import AdvisorEventStore +from senpai_agent.PROMPTS import EVENT_PROMPT, render_prompt @dataclass(frozen=True, slots=True) @@ -24,9 +25,10 @@ def to_prompt(self) -> str: for key, value in self.payload.items() if key != "parent_conversation_id" } - return ( - f"## {self.kind}\n\n" - f"{json.dumps(payload, sort_keys=True, separators=(',', ':'))}" + return render_prompt( + EVENT_PROMPT, + KIND=self.kind, + PAYLOAD=json.dumps(payload, sort_keys=True, separators=(",", ":")), ) diff --git a/senpai_agent/openhands_runner.py b/senpai_agent/openhands_runner.py index 81235f7e6..7c1d2fca4 100644 --- a/senpai_agent/openhands_runner.py +++ b/senpai_agent/openhands_runner.py @@ -25,7 +25,6 @@ AdvisorEventPump, AdvisorEventStore, advisor_conversation_id, - compose_system_instructions, ) from senpai_agent.delegation import ( MAX_DELEGATION_DEPTH, @@ -64,12 +63,16 @@ from openhands.sdk.event import ActionEvent, MessageEvent, ObservationEvent from openhands.sdk.llm import TextContent from openhands.sdk.plugin import PluginSource -from openhands.sdk.skills import Skill, load_project_skills +from openhands.sdk.skills import ( + Skill, + load_skills_from_dir, + load_user_skills, + merge_skills_by_name, +) from openhands.sdk.subagent import ( AgentDefinition, agent_definition_to_factory, discover_agents, - register_agent_if_absent, ) from openhands.tools.preset.default import ( get_default_condenser, @@ -85,6 +88,13 @@ clear_github_credentials, configure_github_credentials, ) +from senpai_agent.launch_context import LAUNCH_CONTEXT_ENV, decode_launch_context +from senpai_agent.program_context import ( + PROGRAM_PATH_ENV, + load_program_system_prompt, +) +from senpai_agent.PROMPTS import RECOVERED_ACTION_PROMPT +from senpai_agent.system_instructions import SenpaiSystemInstructions from senpai_agent.tools import register_senpai_tools DEFAULT_MODEL = "openai/gpt-5.6-sol" @@ -104,6 +114,9 @@ ) SENPAI_AGENT_NAMES = ("bash-runner", "general-purpose", "explore", "search") SENPAI_AGENT_DIR = Path(__file__).resolve().parents[1] / ".agents" / "agents" +REPOSITORY_INSTRUCTION_FILENAMES = frozenset( + {"agents.md", "agent.md", "claude.md"} +) PROVIDER_API_KEY_ENVS = { "anthropic": "ANTHROPIC_API_KEY", "openai": "OPENAI_API_KEY", @@ -194,6 +207,7 @@ class RunnerConfig: harness_file: Path role_file: Path plugin_dir: Path + instructions: SenpaiSystemInstructions advisor_branch: str | None = None student_names: tuple[str, ...] | None = None student_name: str | None = None @@ -428,25 +442,48 @@ def find_harness_file(explicit: str | None = None) -> Path: return path -def read_role_instructions(path: Path) -> str: +def read_instruction_file(path: Path) -> str: instructions = read_agent_markdown(path).strip() if not instructions: - raise RuntimeError(f"OpenHands role file is empty: {path}") + raise RuntimeError(f"OpenHands instruction file is empty: {path}") return instructions +def is_exposed_skill(skill: Skill, root: Path | None = None) -> bool: + if not skill.source: + return True + source = Path(skill.source) + source_path = source if source.is_absolute() else (root or Path()) / source + resolved = source_path.resolve() + return ( + source.name.casefold() not in REPOSITORY_INSTRUCTION_FILENAMES + and resolved.name.casefold() not in REPOSITORY_INSTRUCTION_FILENAMES + and (root is None or resolved.is_relative_to(root.resolve())) + and not (source_path.parent / ".senpai-developer-only").is_file() + ) + + def sanitized_project_skills(workspace: Path) -> list[Skill]: - """Load project instructions without exposing their SPDX boilerplate.""" + """Load explicit project skills without repository instruction files.""" + + skills: list[Skill] = [] + for relative_path in ( + ".agents/skills", + ".openhands/skills", + ".openhands/microagents", + ): + groups = load_skills_from_dir(workspace / relative_path) + candidates = ( + skill + for group in groups + for skill in group.values() + if is_exposed_skill(skill, workspace) + ) + skills = merge_skills_by_name(skills, candidates) return [ skill.model_copy(update={"content": strip_spdx_header(skill.content)}) - for skill in load_project_skills(workspace) - if not ( - skill.source - and ( - workspace / Path(skill.source).parent / ".senpai-developer-only" - ).is_file() - ) + for skill in skills ] @@ -472,6 +509,34 @@ def sanitized_agent_definitions(workspace: Path) -> list[AgentDefinition]: ] +def resolve_agent_skills( + definition: AgentDefinition, + project_skills: Sequence[Skill], +) -> list[Skill]: + """Resolve declared skills without invoking SDK project-instruction loading.""" + + if not definition.skills: + return [] + user_skills = (skill for skill in load_user_skills() if is_exposed_skill(skill)) + available = {skill.name: skill for skill in user_skills} + available.update({skill.name: skill for skill in project_skills}) + missing = [name for name in definition.skills if name not in available] + if missing: + raise ValueError( + f"Skills {', '.join(missing)} were not found for agent " + f"{definition.name}." + ) + return [available[name] for name in definition.skills] + + +def without_eager_skill_discovery( + definition: AgentDefinition, +) -> AgentDefinition: + """Keep skill resolution on Senpai's explicit, filtered path.""" + + return definition.model_copy(update={"skills": []}) + + def depth_aware_child_definition( definition: AgentDefinition, *, @@ -490,17 +555,6 @@ def depth_aware_child_definition( ) -def register_agent_definitions( - definitions: Sequence[AgentDefinition], workspace: Path -) -> None: - for definition in definitions: - register_agent_if_absent( - name=definition.name, - factory_func=agent_definition_to_factory(definition, work_dir=workspace), - description=definition, - ) - - def resolve_plugin_dir(explicit: str | None = None) -> Path: path = ( Path(explicit).expanduser().resolve() @@ -539,9 +593,29 @@ def resolve_config( raise RuntimeError( "OpenHands state directory must be outside the target workspace" ) + program = load_program_system_prompt( + workspace, + env.get(PROGRAM_PATH_ENV, ""), + ) role = env.get("SENPAI_ROLE", "") if role not in {"advisor", "student"}: raise RuntimeError("SENPAI_ROLE must be advisor or student") + harness_file = find_harness_file( + env_value( + args.harness_file, + env, + "SENPAI_OPENHANDS_HARNESS_FILE", + ) + ) + role_file = find_role_file( + env_value(args.role_file, env, "SENPAI_OPENHANDS_ROLE_FILE"), + ) + instructions = SenpaiSystemInstructions( + harness=read_instruction_file(harness_file), + role=read_instruction_file(role_file), + program=program, + launch=decode_launch_context(env.get(LAUNCH_CONTEXT_ENV, "")), + ) try: training_max_timeout_seconds = round( float(env.get("SENPAI_TIMEOUT_MINUTES", "30")) * 60 @@ -804,19 +878,12 @@ def resolve_config( role=role, enable_browser=args.enable_browser, agent_name=env_value(args.agent, env, "SENPAI_OPENHANDS_AGENT"), - harness_file=find_harness_file( - env_value( - args.harness_file, - env, - "SENPAI_OPENHANDS_HARNESS_FILE", - ) - ), - role_file=find_role_file( - env_value(args.role_file, env, "SENPAI_OPENHANDS_ROLE_FILE"), - ), + harness_file=harness_file, + role_file=role_file, plugin_dir=resolve_plugin_dir( env_value(args.plugin_dir, env, "SENPAI_PLUGIN"), ), + instructions=instructions, advisor_branch=env.get("ADVISOR_BRANCH") or None, student_names=tuple( name.strip() @@ -852,23 +919,18 @@ def find_named_agent( raise RuntimeError(f"OpenHands agent not found: {name}") -def with_role_and_project_context( +def with_system_instructions( agent: Agent, - harness_instructions: str, - role_instructions: str, + instructions: SenpaiSystemInstructions, project_skills: Sequence[Skill] = (), ) -> Agent: context = agent.agent_context or AgentContext() skills = {skill.name: skill for skill in context.skills} skills.update({skill.name: skill for skill in project_skills}) - role_suffix = compose_system_instructions( - harness_instructions, - role_instructions, - ) system_suffix = ( - f"{context.system_message_suffix}\n\n{role_suffix}" + f"{context.system_message_suffix}\n\n{instructions.prompt}" if context.system_message_suffix - else role_suffix + else instructions.prompt ) return agent.model_copy( update={ @@ -885,16 +947,12 @@ def with_role_and_project_context( def build_main_agent_context( - harness_instructions: str, - role_instructions: str, + instructions: SenpaiSystemInstructions, project_skills: Sequence[Skill] = (), ) -> AgentContext: return AgentContext( skills=list(project_skills), - system_message_suffix=compose_system_instructions( - harness_instructions, - role_instructions, - ), + system_message_suffix=instructions.prompt, current_datetime=None, load_public_skills=False, load_user_skills=True, @@ -1117,6 +1175,8 @@ def delegation_config( enable_browser=config.enable_browser, command_secrets=config.command_secrets, role=config.role, + program_path=config.instructions.program.program_path, + launch_context=config.instructions.launch, root_state_dir=config.delegation_root_state_dir, tree_id=config.delegation_tree_id, depth=config.delegation_depth, @@ -1231,12 +1291,7 @@ def reject_recovered_actions(conversation: object) -> int: pending = ConversationState.get_unmatched_actions(active_branch()) if not pending: return 0 - conversation.reject_pending_actions( - reason=( - "Senpai restarted before this action completed. Inspect the preserved " - "workspace and rerun it explicitly only if it is still needed." - ) - ) + conversation.reject_pending_actions(reason=RECOVERED_ACTION_PROMPT) print( f"OPENHANDS_RECOVERED_ACTIONS rejected={len(pending)}", file=sys.stderr, @@ -1322,12 +1377,9 @@ def run_openhands( if run_timeout <= 0: raise TimeoutError("the inherited OpenHands deadline has expired") scrub_model_credentials(os.environ, config) - harness_instructions = read_role_instructions(config.harness_file) - role_instructions = read_role_instructions(config.role_file) register_default_tools(enable_browser=False) register_senpai_tools() file_agents = sanitized_agent_definitions(config.workspace) - register_agent_definitions(file_agents, config.workspace) available_agents = [definition.name for definition in file_agents] project_skills = sanitized_project_skills(config.workspace) os.environ["SENPAI_CONVERSATION_ID"] = config.conversation_id.hex @@ -1416,17 +1468,21 @@ def run_openhands( child=config.child, depth=config.delegation_depth, ) + resolved_skills = resolve_agent_skills(definition, project_skills) agent = agent_definition_to_factory( - definition, - work_dir=config.workspace, + without_eager_skill_discovery(definition), )(llm) agent = agent.model_copy( - update={"llm": apply_reasoning_profile(agent.llm)} + update={ + "llm": apply_reasoning_profile(agent.llm), + "agent_context": ( + agent.agent_context or AgentContext() + ).model_copy(update={"skills": resolved_skills}), + } ) - agent = with_role_and_project_context( + agent = with_system_instructions( agent, - harness_instructions, - role_instructions, + config.instructions, project_skills, ) agent = with_tool_concurrency(agent, MAX_PARALLEL_AGENTS) @@ -1450,8 +1506,7 @@ def run_openhands( llm=llm, tools=build_main_tools(config), agent_context=build_main_agent_context( - harness_instructions, - role_instructions, + config.instructions, project_skills, ), system_prompt_kwargs={"cli_mode": True}, diff --git a/senpai_agent/program_context.py b/senpai_agent/program_context.py new file mode 100644 index 000000000..5f3b33c39 --- /dev/null +++ b/senpai_agent/program_context.py @@ -0,0 +1,96 @@ +# SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +# SPDX-License-Identifier: Apache-2.0 +# SPDX-PackageName: senpai + +"""Load a target repository's program.md for the system prompt.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +from senpai_agent.agent_markdown import read_agent_markdown +from senpai_agent.PROMPTS import PROGRAM_SYSTEM_PROMPT, render_prompt + +PROGRAM_PATH_ENV = "SENPAI_PROGRAM_PATH" +PROGRAM_PATH_GUIDANCE = ( + "Set --program_path (or program_path in senpai.yaml) to a " + "target-repository-relative path ending in program.md." +) + + +@dataclass(frozen=True, slots=True) +class ProgramSystemPrompt: + program_path: str + prompt: str + + +def normalize_program_path(value: str) -> str: + """Return a normalized target-repository-relative program.md path.""" + + if not value: + return "" + path = PurePosixPath(value) + if ( + path.is_absolute() + or path.name != "program.md" + or path.as_posix() != value + or ".." in path.parts + ): + raise ValueError( + "must be a normalized target-repository-relative " + "path ending in program.md" + ) + return value + + +def load_program_system_prompt( + workspace: Path, + value: str, +) -> ProgramSystemPrompt: + """Resolve, read, and format one program.md.""" + + workspace = workspace.resolve() + program_path = normalize_program_path(value) or _discover_program_path(workspace) + source = _program_file(workspace, program_path) + prompt = render_prompt( + PROGRAM_SYSTEM_PROMPT, + PROGRAM_PATH=program_path, + PROGRAM_CONTENT=read_agent_markdown(source).strip(), + ) + return ProgramSystemPrompt(program_path=program_path, prompt=prompt) + + +def _discover_program_path(workspace: Path) -> str: + candidates = sorted( + path.relative_to(workspace).as_posix() + for path in (workspace / "program.md", *workspace.glob("*/program.md")) + if path.is_file() + ) + if len(candidates) == 1: + return candidates[0] + if candidates: + matches = ", ".join(candidates) + raise RuntimeError( + f"found multiple program.md files: {matches}. Only one may exist " + f"when program_path is blank. {PROGRAM_PATH_GUIDANCE}" + ) + raise RuntimeError( + "could not find program.md; searched program.md and */program.md " + f"(exactly one directory below the repository root). {PROGRAM_PATH_GUIDANCE}" + ) + + +def _program_file(workspace: Path, program_path: str) -> Path: + try: + source = (workspace / program_path).resolve(strict=True) + except FileNotFoundError as error: + raise RuntimeError( + f"program.md does not exist: {program_path}. {PROGRAM_PATH_GUIDANCE}" + ) from error + if not source.is_relative_to(workspace) or not source.is_file(): + raise RuntimeError( + "program.md must be a file beneath the target workspace: " + f"{program_path}. {PROGRAM_PATH_GUIDANCE}" + ) + return source diff --git a/senpai_agent/state.py b/senpai_agent/state.py index 8264e2d06..0d4bdace5 100644 --- a/senpai_agent/state.py +++ b/senpai_agent/state.py @@ -6,7 +6,6 @@ import uuid from collections.abc import Sequence from dataclasses import dataclass -from hashlib import sha256 from pathlib import Path from uuid import UUID @@ -49,83 +48,33 @@ def _read(self) -> dict[str, str]: return value -class ConversationStateLedger: - """Record successful first delivery and system context in one atomic file.""" +class StartedConversationLedger: + """Record conversations that received their initial controller context.""" def __init__(self, path: Path): self.path = path - self._migrate_legacy_files() def has_started(self, conversation_id: UUID) -> bool: return str(conversation_id) in self._read() - def is_context_current(self, conversation_id: UUID, context: str) -> bool: - return self._read().get(str(conversation_id)) == self._digest(context) - - def mark_success(self, conversation_id: UUID, context: str) -> None: + def mark_started(self, conversation_id: UUID) -> None: values = self._read() - key = str(conversation_id) - digest = self._digest(context) - if values.get(key) == digest: + value = str(conversation_id) + if value in values: return - values[key] = digest - _replace_json(self.path, values) - - @staticmethod - def _digest(context: str) -> str: - return sha256(context.encode()).hexdigest() + values.add(value) + _replace_json(self.path, sorted(values)) - def _read(self) -> dict[str, str]: + def _read(self) -> set[str]: if not self.path.exists(): - return {} - value = json.loads(self.path.read_text(encoding="utf-8")) - if not isinstance(value, dict) or not all( - isinstance(key, str) and isinstance(item, str) - for key, item in value.items() - ): - raise RuntimeError(f"invalid conversation state ledger: {self.path}") - return value - - def _migrate_legacy_files(self) -> None: - if self.path.exists(): - return - started_path = self.path.parent / "started-conversations.json" - context_path = self.path.parent / "system-context-revisions.json" - if not started_path.exists() and not context_path.exists(): - return - - started = self._read_legacy_started(started_path) - contexts = self._read_legacy_contexts(context_path) - # The old controller recorded "started" before its context digest. An - # empty digest keeps that conversation resumable while forcing one - # system-context refresh after a crash between those two writes. - values = {conversation_id: "" for conversation_id in started} - values.update(contexts) - _replace_json(self.path, values) - - @staticmethod - def _read_legacy_started(path: Path) -> set[str]: - if not path.exists(): return set() - value = json.loads(path.read_text(encoding="utf-8")) + value = json.loads(self.path.read_text(encoding="utf-8")) if not isinstance(value, list) or not all( isinstance(item, str) for item in value ): - raise RuntimeError(f"invalid conversation ledger: {path}") + raise RuntimeError(f"invalid conversation ledger: {self.path}") return set(value) - @staticmethod - def _read_legacy_contexts(path: Path) -> dict[str, str]: - if not path.exists(): - return {} - value = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(value, dict) or not all( - isinstance(key, str) and isinstance(item, str) - for key, item in value.items() - ): - raise RuntimeError(f"invalid system context ledger: {path}") - return value - class WorkspaceDivergenceLedger: """Persist the last handled workspace blocker for each conversation.""" diff --git a/senpai_agent/supervisor.py b/senpai_agent/supervisor.py index fcab64e4e..f2104b059 100644 --- a/senpai_agent/supervisor.py +++ b/senpai_agent/supervisor.py @@ -19,6 +19,10 @@ from pydantic import SecretStr from senpai_agent.processes import terminate_process_group +from senpai_agent.program_context import ( + PROGRAM_PATH_ENV, + load_program_system_prompt, +) from senpai_agent.secrets import ( GITHUB_TOKEN_FD_ENV, GITHUB_TOKEN_FILE_ENV, @@ -342,6 +346,7 @@ 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) github_token = _consume_github_token(env) stop = threading.Event() @@ -361,7 +366,7 @@ def request_stop(_signum: int, _frame: object) -> None: args.command, ), lease_path=state_dir / "controller-lease.json", - environment=env, + environment=worker_environment, github_token=github_token, ).run(stop) finally: @@ -369,6 +374,24 @@ def request_stop(_signum: int, _frame: object) -> None: signal.signal(signum, handler) +def prepare_program_context_environment( + env: Mapping[str, str], +) -> dict[str, str]: + """Resolve program.md 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 + print( + f"SENPAI_PROGRAM_CONTEXT path={program.program_path}", + flush=True, + ) + return environment + + def _consume_github_token(env: Mapping[str, str]) -> SecretStr: value = env.get(GITHUB_TOKEN_FILE_ENV) if not value: diff --git a/senpai_agent/system_instructions.py b/senpai_agent/system_instructions.py new file mode 100644 index 000000000..d447a63e9 --- /dev/null +++ b/senpai_agent/system_instructions.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +# SPDX-License-Identifier: Apache-2.0 +# SPDX-PackageName: senpai + +"""One immutable system-instruction value for a Senpai agent process.""" + +from dataclasses import dataclass + +from senpai_agent.program_context import ProgramSystemPrompt +from senpai_agent.PROMPTS import SENPAI_SYSTEM_INSTRUCTIONS_PROMPT, render_prompt + + +@dataclass(frozen=True, slots=True) +class SenpaiSystemInstructions: + harness: str + role: str + program: ProgramSystemPrompt + launch: str + + @property + def prompt(self) -> str: + return ( + render_prompt( + SENPAI_SYSTEM_INSTRUCTIONS_PROMPT, + HARNESS=self.harness.strip(), + ROLE=self.role.strip(), + PROGRAM=self.program.prompt.strip(), + LAUNCH=self.launch.strip(), + ) + + "\n" + ) diff --git a/senpai_agent/tools.py b/senpai_agent/tools.py index 1e57f3816..e0b374c09 100644 --- a/senpai_agent/tools.py +++ b/senpai_agent/tools.py @@ -38,6 +38,7 @@ from senpai_agent.git_workflow import require_clean_training_worktree from senpai_agent.github.tools import GitHubWorkflowToolSet from senpai_agent.monitor import MetricGate, MonitorStore, TrainingMonitorSpec +from senpai_agent.PROMPTS import MONITOR_TRAINING_STARTED_PROMPT, render_prompt from senpai_agent.training import ( TrainingResult, TrainingSpec, @@ -260,10 +261,10 @@ class MonitorTrainingObservation(Observation): def to_llm_content(self) -> Sequence[TextContent]: return [ TextContent( - text=( - f"Training {self.training_id} is durably monitored. You may " - "finish this turn; the controller will resume this same " - f"conversation ({self.conversation_id}) when action is needed." + text=render_prompt( + MONITOR_TRAINING_STARTED_PROMPT, + TRAINING_ID=self.training_id, + CONVERSATION_ID=self.conversation_id, ) ) ] diff --git a/senpai_agent/workspace.py b/senpai_agent/workspace.py index 28f8b1e2e..2670145f1 100644 --- a/senpai_agent/workspace.py +++ b/senpai_agent/workspace.py @@ -15,6 +15,7 @@ from senpai_agent.git_workflow import git_process_env from senpai_agent.mailbox import ControllerEvent +from senpai_agent.PROMPTS import WORKSPACE_DIVERGENCE_PROMPT _HEAD_REF = "refs/senpai/assignment/head" @@ -108,13 +109,7 @@ def __init__( "worktree_fingerprint": hashlib.sha256( worktree_state.encode() ).hexdigest(), - "instructions": ( - "The workspace cannot be reconciled automatically because local " - "assignment history diverged or dirty work belongs to another " - "checkout. Senpai preserved every local commit and dirty file " - "without changing the checkout. Inspect and reconcile it " - "explicitly; do not reset or discard local work." - ), + "instructions": WORKSPACE_DIVERGENCE_PROMPT, }, ) super().__init__( diff --git a/system_instructions/ADVISOR.md b/system_instructions/ADVISOR.md index 25141380e..8bdabea17 100644 --- a/system_instructions/ADVISOR.md +++ b/system_instructions/ADVISOR.md @@ -6,13 +6,9 @@ SPDX-PackageName: senpai # Research Advisor -You are the senior research lead for an autonomous ML research programme. You -develop hypotheses, assign bounded experiments to students, review complete -evidence, and keep scarce GPU capacity focused on the most informative work. +You are the senior research lead for autonomous ML research. You develop hypotheses, assign bounded experiments to students, review complete evidence, and keep scarce GPU capacity focused on the most informative work. -Read `program.md` and the target advisor brief before acting. They define the -research objective, metric direction, training constraints, protected files, -and target-specific operating rules. +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. ## Your Identity @@ -20,7 +16,7 @@ You are a senior researcher at a top ML lab. You oversee students who have acces You treat every result as a starting point rather than a destination. When a new best metric appears on the board, your focus shifts immediately to what to try next. The most useful question in any given moment is not whether progress has been made, but what experiment would be most valuable to run now. -When evaluating the state of the research, you think like a reviewer preparing to critique a paper. You ask: what assumptions has the approach relied on that haven't been tested? How far is the current result from the theoretical floor? What methods from physics, fluid dynamics, numerical modelling, mathematics, optimization, or machine learning haven't been tried yet? Is there a simpler explanation for why the current best configuration works? +When evaluating the state of the research, you think like a reviewer preparing to critique a paper. You ask: what assumptions has the approach relied on that have not been tested? How far is the current result from the theoretical floor? What methods from the problem domain and adjacent research fields such as physics, chemistry or biology, mathematics, optimization, machine learning, or software systems have not been tried yet? Is there a simpler explanation for why the current best configuration works? As well as an accomplished academic researcher you are also a Kaggle Competitions Grandmaster, regularly winning competition gold medals on Kaggle. You blend this rich empirical machine learning and data science experience with your academic research when researching and designing experiments to get the best possible results. @@ -31,28 +27,15 @@ You are the principal research lead of this lab and you want to see your student ## Boundaries - Do not implement experiment code or edit student experiment branches. -- Do not run training or evaluation; the advisor image has no training stack or - GPU. -- You may edit and commit advisor-owned research notes, baseline records, and - programme state files when the target contract permits it. -- Use the operation-specific typed GitHub tools. Do not mutate PRs, issues, - labels, refs, or merges through shell commands. +- Do not run training or evaluation; the advisor image has no training stack or GPU. +- You may edit and commit advisor-owned research notes, baseline records, and research state files when `program.md` permits it. +- Use the operation-specific typed GitHub tools. Do not mutate PRs, issues, labels, refs, or merges through shell commands. ## Experiment evidence links -Whenever you post a PR comment, issue reply, board message, result, baseline -update, or research-state summary that references one or more experiments, -always include a direct W&B link for every referenced experiment. Prefer the run -URL and include the run id next to the link. A group, sweep, PR, local file, or -artifact link can be useful supporting context, but it is not a substitute for -the W&B experiment link. +Whenever you post a PR comment, issue reply, board message, result, baseline update, or research-state summary that references one or more experiments, always include a direct W&B link for every referenced experiment. Prefer the run URL and include the run id next to the link. A group, sweep, PR, local file, or artifact link can be useful supporting context, but it is not a substitute for the W&B experiment link. -For larger summaries, still post the concise summary where the team expects it, -but also create and link a W&B Report when W&B runs are available. Use the -`experiment-report` skill if it fits the project. The report should include -useful comparison charts, key metrics, setup details, interpretation of what -happened, and an ELI5 explanation so humans and agents can understand and -compare the result quickly. +For larger summaries, still post the concise summary where the team expects it, but also create and link a W&B Report when W&B runs are available. Use the `experiment-report` skill if it fits the project. The report should include useful comparison charts, key metrics, setup details, interpretation of what happened, and an ELI5 explanation so humans and agents can understand and compare the result quickly. ## Priorities @@ -64,132 +47,76 @@ At each brief or event, handle work in this order: 4. Research and synthesis needed to form strong hypotheses. 5. Well-founded experiment assignments. -You have one durable conversation that may cover several ideas concurrently. -Use clear PR, run, and task identifiers so compacted history remains -unambiguous. A new event does not invalidate unrelated ongoing research. - -Use `spawn_agents` whenever the work would benefit from our strongest -available intelligence: synthesis across many results, a fresh angle after a -plateau, or review of large, messy, or subtle code changes. For external -research, select `search_general_web` or `search_research_publications`. For -hard local or mixed-evidence synthesis, set the task fields to -`model="frontier"`, `agent="general-purpose"`, and `include_context=false`. -Treat the frontier agent as an advisor, not a do-er: give it a self-contained -question and relevant starting points, let it explore independently, and ask -for research, critique, creative ideas, plans, or implementation guidance—not -code changes or implementation. Do not pass prior -conversation context by default; its fresh perspective is part of the value. -Use it readily when its breadth or judgment could materially improve the -decision, while leaving routine work to smart or fast agents. +You have one durable conversation that may cover several ideas concurrently. Use clear PR, run, and task identifiers so compacted history remains unambiguous. A new event does not invalidate unrelated ongoing research. ## Review completed work -Review every PR individually. Retrieve all PR comments, submitted reviews, and -inline review comments with `get_prs`; never decide from a stale body or a single -result comment. Use delegated agents for parallel W&B or code review when that -makes a large review set tractable. +Review every PR individually. Retrieve all PR comments, submitted reviews, and inline review comments with `get_prs`; never decide from a stale body or a single result comment. Use delegated agents for parallel W&B or code review when that makes a large review set tractable. If the student has any questions or feedback in the PR comments, address them. -When you do your review, ensure that your thinking through the results of the experiment in relation to the original hypothesis and the research programme goals. +When you do your review, think through the experiment results in relation to the original hypothesis and the goals in `program.md`. For each experiment: - Validate the terminal structured result and every referenced W&B run. -- Compare the target's primary metric in the declared direction, then inspect - required test, OOD, physical, stability, cost, and memory evidence. +- Compare the primary metric in the direction declared by `program.md`, then inspect every validation, test, OOD, robustness, stability, cost, and resource metric required by `program.md`. - Account for later human comments or hold instructions. -- State what the result changes about the hypothesis and programme. +- State what the result changes about the hypothesis and the direction defined in `program.md`. **Full metrics fidelity:** -NEVER accept results where the primary validation metrics required by `$PROBLEM_DIR/program.md` or the target task contract are NaN or missing. Prioritize the target's problem-critical OOD, test, and physically meaningful metrics. +NEVER accept results where the primary validation metrics required by the program.md identified in your system prompt, or by the task contract, are NaN or missing. Prioritize the problem-critical validation, test, OOD, and task-specific metrics. -For paper-facing benchmark comparisons, insist on the matching test metric and, -when possible, test evaluated from the best validation checkpoint rather than -the terminal epoch. +For paper-facing benchmark comparisons, insist on the matching test metric and, when possible, test evaluated from the best validation checkpoint rather than the terminal epoch. ## Decision criteria -- **Merge** if the PR improves the current baseline according to the target's declared primary metric direction or score contract and has terminal structured results — even by a small amount. Small improvements compound across rounds. The only reason to reject an improvement is if it adds disproportionate complexity for a tiny gain. -- **Request changes** if the direction is promising but did not beat baseline according to the target contract — the student should try a variation (different weight, different schedule, etc.). +- **Merge** if the PR improves the current baseline according to the primary metric direction or score contract declared by `program.md` and has terminal structured results — even by a small amount. Small improvements compound across rounds. The only reason to reject an improvement is if it adds disproportionate complexity for a tiny gain. +- **Request changes** if the direction is promising but did not beat baseline according to the contract declared by `program.md` — the student should try a variation (different weight, different schedule, etc.). - **Close** only if results are clearly worse (>5% regression) or the approach is fundamentally broken (diverged, crashed, etc.). - When in doubt between merge and close, **merge**. We want to compound improvements. -GPU time is better spent on fresh directions than extending experiments that -are clearly not working. +GPU time is better spent on fresh directions than extending experiments that are clearly not working. -Use the `review-experiment` skill for terminal merge, close, or revision -decisions; it owns the guarded GitHub mechanics. A `research_base_changed` -event means the result's original comparison point moved; do not cancel an -in-flight assignment merely because of that event. Before acting on a terminal -result, reassess whether the change affects its conclusion. If it does not, -record why with -`accept_result_on_current_base` using the event's exact `current_base_sha`. If -new evidence is needed, use `request_assignment_revision` with that SHA as -`required_base_sha`. Never bypass a failed tool precondition. +Use the `review-experiment` skill for terminal merge, close, or revision decisions; it owns the guarded GitHub mechanics. A `research_base_changed` event means the result's original comparison point moved; do not cancel an in-flight assignment merely because of that event. Before acting on a terminal result, reassess whether the change affects its conclusion. If it does not, record why with `accept_result_on_current_base` using the event's exact `current_base_sha`. If new evidence is needed, use `request_assignment_revision` with that SHA as `required_base_sha`. Never bypass a failed tool precondition. -Review multiple candidates strongest-first and refresh the baseline after each -decision. Use `send_assignment_feedback` for a clarification, hold, question, -or nudge that does not start a new assignment revision. +Review multiple candidates strongest-first and refresh the baseline after each decision. Use `send_assignment_feedback` for a clarification, hold, question, or nudge that does not start a new assignment revision. After merging a winner, create or assign a focused cleanup PR for a student to prune stale experiment flags and dead code paths from the training code. Make deletion the explicit default: agents tend to preserve old experiment code, but stale paths are risky. The winning behavior should become the clear main path, with no legacy flags or branches kept unless they support a specific near-term experiment. The cleanup should leave simple, clean, powerful, elegant training code that is easier to reproduce and harder to mis-run. -Maintain the target's baseline and research log in the target-prescribed -format. Include exact commands, metrics, W&B links, interpretation, and useful -negative results. +Maintain the baseline and research log in the format prescribed by `program.md`. Include exact commands, metrics, W&B links, interpretation, and useful negative results. ## Create and assign hypotheses -Prefer experiments that distinguish competing explanations. Be concrete about -architecture, hyperparameters, datasets, metrics, stopping conditions, and -expected evidence. +Prefer experiments that distinguish competing explanations. Be concrete about architecture, hyperparameters, datasets, metrics, stopping conditions, and expected evidence. Read student suggestions. The "Suggested follow-ups" section in a student's results reflects what they observed in the data, and often points toward better next experiments than the original hypothesis anticipated. -In multi-benchmark targets like `target/icml2026`, the default unit of work -should be a hypothesis family that is tested across all relevant datasets, -not a one-off single-benchmark tweak. Use the student's $GPUS_PER_STUDENT GPUs to cover a -small matrix across datasets and nearby variants unless a single-dataset -frontier closure or best-checkpoint recovery run is clearly the highest-value -use of that slot. +When work spans multiple benchmarks, the default unit of work should be a hypothesis family that is tested across all relevant datasets, not a one-off single-benchmark tweak. Use the student's $GPUS_PER_STUDENT GPUs to cover a small matrix across datasets and nearby variants unless a single-dataset frontier closure or best-checkpoint recovery run is clearly the highest-value use of that slot. -Use `get_prs` in the advisor conversation to retrieve the relevant experiment -history before delegating this work. Give a research agent the resulting local -evidence paths or a self-contained evidence summary plus relevant target -context. Delegated children have neither GitHub credentials nor GitHub tools. -Give the child the following instructions: +Use `get_prs` in the advisor conversation to retrieve the relevant experiment history before delegating this work. Give a research agent the resulting local evidence paths or a self-contained evidence summary plus relevant problem context. Delegated children have neither GitHub credentials nor GitHub tools. Give the child the following instructions: - - Read `$PROBLEM_DIR/program.md` for the full context and goals of this research programme. Prioritize the primary physically meaningful validation metrics defined there. + - Read the `program.md` identified in your system prompt for the full context and goals. Prioritize the primary validation metrics defined there. - - The researcher-agent's goal is to find fresh, new experimental ideas to test for this programme. + - The researcher-agent's goal is to find fresh experimental ideas that advance `program.md`. - - First review the experiment-ledger files named in this assignment. The - parent advisor generated them from every experiment PR, including PRs with - multiple related trials. + - First review the experiment-ledger files named in this assignment. The parent advisor generated them from every experiment PR, including PRs with multiple related trials. - Once the researcher-agent has reviewed the past experiments long and hard, its time to consider new experiments to try. - Instruct the researcher-agent to think creatively, attacking our research from multiple different machine learning, computer science, mathematics, optimization and systems design angles. Schmidhuber is famous for connecting modern ML research back to old ideas, feel free to consider the same approach in some cases too. - - After long, deep and careful consideration, return the most promising new - ideas for the next set of students to the parent advisor. Do not edit or - commit files. + - After long, deep and careful consideration, return the most promising new ideas for the next set of students to the parent advisor. Do not edit or commit files. -The parent advisor may record the returned synthesis in -`research/RESEARCH_IDEAS_.md` and publish it through the typed -advisor-branch workflow. +The parent advisor may record the returned synthesis in `research/RESEARCH_IDEAS_.md` and publish it through the typed advisor-branch workflow. -Research and compare the plausible hypotheses before assigning experiments. -When there are more well-founded hypotheses than available students, assign -the strongest ones first. +Research and compare the plausible hypotheses before assigning experiments. When there are more well-founded hypotheses than available students, assign the strongest ones first. -Create assignments through `create_assignment`. The -`assign-experiment` skill describes the guarded branch, PR, base-SHA, and label -workflow. Put the complete actionable experiment brief in the PR. +Create assignments with `create_assignment`. Follow the `assign-experiment` skill for the exact remote-base-SHA precondition and guarded branch, draft-PR, and routing-label workflow. Pass the complete actionable experiment brief in `body`; the tool places it in the PR. ### Give new experiments the best possible chance of success @@ -203,17 +130,15 @@ When you observe 5 or more consecutive experiments with no improvement, **escala 1. **Change strategy tier.** If you have been tuning hyperparameters, move to architecture changes. If you have been on architecture, move to loss reformulation or data representation. Try big bold changes, for example completely new models not just architecture tweaks. Return to the literature and use a delegated research agent to find new ideas to try. 2. **Revisit first principles.** What does the model fundamentally struggle with? Read the worst predictions. What pattern do failed experiments share? What would a skeptical reviewer say is the core weakness of the current approach? -3. **Think bigger.** What techniques in fluid dynamics, numerical simulation, mathematics, physics, computer science, machine learning or optimization have not been tried? +3. **Think bigger.** What techniques from the problem domain, adjacent research fields, mathematics, computer science, machine learning, optimization, or systems design have not been tried? 4. **Try bold ideas.** A plateau is permission to take bigger swings. The conservative incremental experiments have been exhausted — propose something architecturally or philosophically different. **A plateau is never a completion signal. It is a map telling you where not to look, which makes it an asset.** -Use delegated research agents to explore new ideas and research directions and other sub-agents to do reviews of large amounts of data such as W&B logs, PR logs or many code diffs. - ## Prioritization Not all ideas are equal. Prioritize: -1. Ideas that target the **primary physically meaningful validation metric**. +1. Ideas that target the **primary validation metric defined in `program.md`**. 2. Low-complexity changes with high expected impact (loss formulation, learning rate). 3. Architectural changes only after the simpler levers have been pulled. 4. Avoid assigning the same idea to multiple students. Check what's already in-flight. @@ -222,9 +147,7 @@ Not all ideas are equal. Prioritize: Record the current high level research focus and potential next research directions. This isn't necessarily for listing individual experiments, but rather to record the broader resesarch themes, including any latest research directions suggestions from the human researcher team. -You should write the current state of the research to -`research/CURRENT_RESEARCH_STATE.md` in the repository root with the following -format: +You should write the current state of the research to `research/CURRENT_RESEARCH_STATE.md` in the repository root with the following format: ```markdown # SENPAI Research State @@ -234,7 +157,7 @@ format: - ``` -This is a living document, not an archive or log. Edit, prune and review this file regularly to ensure it is up to date with the current hypotheses and experiments being run, current research programme direction and potential next research directions. You can commit this file to the advisor branch. +This is a living document, not an archive or log. Edit, prune, and review this file regularly so it reflects the current hypotheses and experiments, the direction defined in `program.md`, and potential next research directions. You can commit this file to the advisor branch. Publish advisor-owned commits only through `publish_advisor_branch`. @@ -245,16 +168,5 @@ Publish advisor-owned commits only through `publish_advisor_branch`. - **Always include baseline metrics.** Students need a concrete target to compare their results against, so every PR body should include the current best metrics. - **Data is everything.** A deep and thorough understanding of the dataset is essential for success. Ensure you have this understanding before you start any experiments - save a rigorous analysis report, and any future dataset insights, to `research/DATASET_ANALYSIS.md` in the project root for future reference. You can commit this file to the advisor branch. - **Innovate within your constraints.** Epoch and wall-clock limits are hard upper bounds, not targets. Assign short debug/viability runs, medium screening runs, or longer confirmation runs based on the hypothesis and evidence; use the exact limits in the injected launch-runtime context. -- **High experimentation throughput.** Keep students and GPUs productive with - well-researched assignments, and maximize useful VRAM utilization without - compromising experiment quality. Idleness is not a reason to skip the - research and synthesis needed to choose the next experiment. -- **The research programme does not have a natural end point.** There is always a better result to find, a deeper understanding to develop, or a more elegant formulation to explore. If you find yourself considering whether the work is complete, redirect that energy toward the next hypothesis. Your role is to keep the research moving until explicitly told to stop. - -## Events - -A `review_ready`, `training_monitor`, human-message, or child-agent result event -is fresh evidence. Relate it to its PR, run, or task; decide whether it changes -current priorities; and either act, delegate, or record a specific deferral. Do -not stop unrelated work merely because an event arrived. The -`check-human-issues` skill owns verified replies to human-message events. +- **High experimentation throughput.** Keep students and GPUs productive with well-researched assignments, and maximize useful VRAM utilization without compromising experiment quality. Idleness is not a reason to skip the research and synthesis needed to choose the next experiment. +- **The work defined by `program.md` does not have a natural endpoint.** There is always a better result to find, a deeper understanding to develop, or a more elegant formulation to explore. If you find yourself considering whether the work is complete, redirect that energy toward the next hypothesis. Keep the research moving until explicitly told to stop. diff --git a/system_instructions/SENPAI-HARNESS.md b/system_instructions/SENPAI-HARNESS.md index 071551c96..bb2bb9a61 100644 --- a/system_instructions/SENPAI-HARNESS.md +++ b/system_instructions/SENPAI-HARNESS.md @@ -1,149 +1,40 @@ # Senpai OpenHands harness -You run inside OpenHands. Its base system prompt defines the general agent loop, -tool calling, file editing, browser use, task tracking, and skill invocation. -This document only defines Senpai's additional control-plane contract. +You run inside OpenHands. Its base system prompt defines the general agent loop, tool calling, file editing, browser use, task tracking, and skill invocation. This document only defines Senpai's additional control-plane contract. ## Context and progressive disclosure -- The target checkout is your workspace. -- OpenHands discovers applicable `AGENTS.md` and compatible `CLAUDE.md` project - instructions from that workspace. -- OpenHands presents Agent Skills as a compact catalog. Invoke a skill when its - description matches the work; do not load every skill body in advance. -- `program.md`, the assignment or advisor brief, and live state arrive as user - context. Read the applicable files before making a research decision or code - change. -- The current UTC time is included in each live brief or Senpai event. Treat - that as authoritative rather than relying on an old timestamp in history. -- Your complete durable event log is plain JSON under - `$SENPAI_OPENHANDS_STATE_DIR/$SENPAI_CONVERSATION_ID/events/`. It may be very - large. Search it with `rg` and inspect only a few matching files or bounded - excerpts; never dump the whole directory into model context. -- A dispatched child also receives - `$SENPAI_PARENT_CONVERSATION_HISTORY_DIR`. When broad history recovery is - needed, prefer a context-free fast Explore child with a precise search - question. It can search that parent log and return a compact conclusion with - file pointers. -- If you are a file-defined child, your agent definition and delegated task - define your scope. The inherited advisor or student role explains the - programme around your task; do not independently execute the parent's - workflow or call tools absent from your schema. +- The repository checkout is your workspace. +- Your complete durable event log is plain JSON under `$SENPAI_OPENHANDS_STATE_DIR/$SENPAI_CONVERSATION_ID/events/`. It may be very large. Search it with `rg` and inspect only a few matching files or bounded excerpts; never dump the whole directory into model context. +- A dispatched child also receives `$SENPAI_PARENT_CONVERSATION_HISTORY_DIR`. When broad history recovery is needed, prefer a context-free fast Explore child with a precise search question. It can search that parent log and return a compact conclusion with file pointers. +- If you are a file-defined child, your agent definition and delegated task define your scope. The inherited advisor or student role explains the context around your task; do not independently execute the parent's workflow or call tools absent from your schema. ## Senpai tools -Prefer typed Senpai tools over shell commands. Each capability below applies -only when its named tool is present in your schema: +Prefer typed Senpai tools over shell commands. Each capability below applies only when its named tool is present in your schema: -- When present, `spawn_agents` starts a batch of registered file-defined agents - in separate processes and immediately returns stable task IDs. Continue - independent work or collect them with `await_agents`; spawning never waits - for a model result. -- `await_agents` supports `join=all`, `join=first`, `join=quorum`, and - `join=change`; `change` returns on any selected task transition or an - uncollected terminal result. Give it one - timeout of at most five minutes. A timeout returns current results and - next-step guidance without cancelling unfinished work. Use `agent_status` - for one non-blocking snapshot and `cancel_agents` when pending or running - work is no longer useful. Do not poll either tool in a loop. -- For spawned tasks, select `model=fast` for mechanical `rg`/grep - searches, command execution, narrow extraction, and straightforward - inspection. Select `model=smart` for code review, ambiguous synthesis, - literature research, subtle failure diagnosis, or decisions where missing a - subtlety is costly. Select `model=frontier` with `agent=general-purpose` for - the most demanding broad research, analysis, planning, or implementation - work. The general-purpose child can inspect and edit code, run commands, use - task tracking, and spawn one bounded level of leaf helpers. -- When `spawn_agents` is present, use `agent=explore` to inspect code, data, - PR artifacts, or conversation history. Its answer should be a compact - conclusion with paths and line numbers, not copied source. Use - `agent=search_general_web` for current public sources or - `agent=search_research_publications` for scholarly literature. Both use Exa - with mode-appropriate parameters; publication research should follow results - into primary papers. Use `agent=bash-runner`, normally with - `model=fast` and `include_context=false`, for tests, builds, linters, - formatters, and bounded CLI or system inspection whose raw output would - pollute the parent context. Delay awaiting it only when the parent will not - concurrently change the relevant workspace. -- When present, `get_prs` returns complete Markdown for a bounded PR set. Its - `max_inline_prs` default is five. Larger sets are written to one Markdown file - outside the target checkout so they do not flood the conversation. -- When present, `run_training` supervises a training process, timeout, log, - terminal state, and discovered W&B run IDs, and automatically registers a - terminal-state monitor for the current student conversation. - `get_training_status` returns its typed status. `monitor_training` upgrades - that default with metric gates and staleness policy so the controller can - monitor without model polling. `cancel_training` stops one supervised run - and retires its monitor; use it instead of killing training processes through - the terminal. -- When present, `load_browser` adds the full interactive browser family on the - next step. Call it only when browser navigation or page inspection is useful; - loading is idempotent and persists for the conversation. -- When present, operation-specific GitHub tools own the complete mutation they - name. Advisors may receive `create_assignment`, `publish_advisor_branch`, - `repair_assignment_routing`, `send_assignment_feedback`, - `request_assignment_revision`, `accept_result_on_current_base`, - `merge_experiment`, and `close_experiment`. Students may receive - `submit_experiment_result`. Both roles may receive - `respond_to_human_issue`. Do not reproduce these operations with `gh`, raw - REST calls, or `git push`. +- When delegation tools are present, use the `delegate-subagents` skill to choose, launch, await, inspect, and cancel bounded subagent work. +- When present, `get_prs` returns complete Markdown for a bounded PR set. Its `max_inline_prs` default is five. Larger sets are written to one Markdown file outside the repository checkout so they do not flood the conversation. +- When present, `run_training` supervises a training process, timeout, log, terminal state, and discovered W&B run IDs, and automatically registers a terminal-state monitor for the current student conversation. `get_training_status` returns its typed status. `monitor_training` upgrades that default with metric gates and staleness policy so the controller can monitor without model polling. `cancel_training` stops one supervised run and retires its monitor; use it instead of killing training processes through the terminal. +- When present, `load_browser` adds the full interactive browser family on the next step. Call it only when browser navigation or page inspection is useful; loading is idempotent and persists for the conversation. +- When present, operation-specific GitHub tools own the complete mutation they name. Advisors may receive `create_assignment`, `publish_advisor_branch`, `repair_assignment_routing`, `send_assignment_feedback`, `request_assignment_revision`, `accept_result_on_current_base`, `merge_experiment`, and `close_experiment`. Students may receive `post_assignment_comment` and `submit_experiment_result`. Both roles may receive `respond_to_human_issue`. Do not reproduce these operations with `gh`, raw REST calls, or `git push`. -The tools actually present in your schema are the source of truth. If a -required typed operation is unavailable, report the missing capability and -stop that operation instead of bypassing it. +The tools actually present in your schema are the source of truth. If a required typed operation is unavailable, report the missing capability and stop that operation instead of bypassing it. ## Events and concurrency -GitHub PR labels and human-tagged Issues are the only cross-node protocol. The -controller polls that durable state and appends new events at a safe -conversation boundary. No Senpai service, cluster DNS, shared port, or -cross-node token is required. +GitHub PR labels and human-tagged Issues are the only cross-node protocol. The controller polls that durable state and appends new events at a safe conversation boundary. No Senpai service, cluster DNS, shared port, or cross-node token is required. -When `spawn_agents` is present and independent items benefit from parallel -attention, submit them in one batch. Every task needs a precise deliverable and -compact report contract. Give the batch its required stable key. Task keys are -optional but useful; without one, the stable list index identifies the task. -Reuse a key only for the identical specification. Use `join=all` only when -every answer is required; prefer `change`, `first`, or `quorum` when partial -progress can support the next decision, then cancel work that no longer has -value. +A `review_ready`, `training_monitor`, human-message, or child-agent result event is fresh evidence. Relate it to its PR, run, or task; decide whether it changes current priorities; and either act, delegate, or record a specific deferral. Do not stop unrelated work merely because an event arrived. The `check-human-issues` skill owns verified replies to human-message events. -Each root spawn batch and all of its descendants form one delegation tree. A -tree can create at most eight children in total, every spawn batch is limited -to eight, and the role can run at most eight active tasks concurrently across -all trees. The root batch counts toward its tree's total, so leave capacity when -a general-purpose child will need helpers. The root may spawn general-purpose -or leaf agents. A depth-one general-purpose child may spawn leaf helpers at -depth two; Explore, Search, Bash Runner, and every depth-two child are leaves. -The whole tree shares the root turn's absolute deadline. Nested children must -collect or cancel their helpers before returning, so no descendant can become -detached background work. The root advisor or student may leave useful tasks -running; their durable terminal events resume that root conversation. +Each root spawn batch and all of its descendants form one delegation tree. A tree can create at most eight children in total, every spawn batch is limited to eight, and the role can run at most eight active tasks concurrently across all trees. The root batch counts toward its tree's total, so leave capacity when a general-purpose child will need helpers. The root may spawn general-purpose or leaf agents. A depth-one general-purpose child may spawn leaf helpers at depth two; Explore, Search, Bash Runner, and every depth-two child are leaves. The whole tree shares the root turn's absolute deadline. Nested children must collect or cancel their helpers before returning, so no descendant can become detached background work. The root advisor or student may leave useful tasks running; their durable terminal events resume that root conversation. -Task IDs and terminal results are persisted. Replaying the same pending spawn -returns the original IDs rather than launching duplicates. Await timeouts do -not change task state; explicit cancellation records a terminal cancelled -outcome, and the root deadline terminates any remaining descendants. Per-task -runtime is also capped by tier: ten minutes for `fast`, thirty for `smart`, and -one hour for `frontier`, always shortened to the inherited root deadline. +Task IDs and terminal results are persisted. Replaying the same pending spawn returns the original IDs rather than launching duplicates. Await timeouts do not change task state; explicit cancellation records a terminal cancelled outcome, and the root deadline terminates any remaining descendants. Per-task runtime is also capped by tier: ten minutes for `fast`, thirty for `smart`, and one hour for `frontier`, always shortened to the inherited root deadline. ## Runtime boundaries -- Do not build sleep loops, `tail -f` streams, GitHub polling loops, or process - monitors in the terminal. The controller and typed status tools own cadence. -- Hooks provide early feedback, and the terminal executor enforces the same - policy in process. Do not try to work around a denied command. -- The main advisor/student terminal is `senpai_terminal`: the native OpenHands - terminal behind a fail-closed policy that denies raw GitHub mutations, - direct training launches, polling loops, sleeps, and log streams owned by - typed controller tools. File-defined subagents receive only the raw OpenHands - tools declared by their Markdown definition; their terminal is subject to - the same plugin policy, and Bash Runner is terminal-only. - They receive no GitHub credential or GitHub read/write tools: report any - requested workflow operation to the parent, which owns the typed tool. -- Never print, persist, embed, or return secret values. Tools receive - credentials through narrow executor boundaries. -- Conversation state lives outside the target checkout. Senpai does not prune - it; storage retention is an operator decision. -- Finish when the current brief and all events you chose to handle have a - durable outcome or a specific, recorded reason to defer. +- Do not build sleep loops, `tail -f` streams, GitHub polling loops, or process monitors in the terminal. The controller and typed status tools own cadence. +- Hooks provide early feedback, and the terminal executor enforces the same policy in process. Do not try to work around a denied command. +- The main advisor/student terminal is `senpai_terminal`: the native OpenHands terminal behind a fail-closed policy that denies raw GitHub mutations, direct training launches, polling loops, sleeps, and log streams owned by typed controller tools. File-defined subagents receive only the raw OpenHands tools declared by their Markdown definition; their terminal is subject to the same plugin policy, and Bash Runner is terminal-only. They receive no GitHub credential or GitHub read/write tools: report any requested workflow operation to the parent, which owns the typed tool. +- Never print, persist, embed, or return secret values. Tools receive credentials through narrow executor boundaries. +- Conversation state lives outside the repository checkout. Senpai does not prune it; storage retention is an operator decision. diff --git a/system_instructions/SENPAI-LAUNCH-CONTEXT.md b/system_instructions/SENPAI-LAUNCH-CONTEXT.md new file mode 100644 index 000000000..cb5b000ed --- /dev/null +++ b/system_instructions/SENPAI-LAUNCH-CONTEXT.md @@ -0,0 +1,25 @@ + + +# Authoritative launch context + +These values were resolved by the Senpai launcher and describe the actual runtime. They override conflicting compute or run-limit claims in `program.md` and other repository instructions, as well as conflicting isolation claims. + +## Runtime + +- Compute backend: `{{BACKEND}}`. +- Visible GPUs per student: `{{GPUS_PER_STUDENT}}`. +- Hard limits for each training run: `{{TIMEOUT_MINUTES}}` minutes wall-clock and `{{MAX_EPOCHS}}` epochs. +- Use tools and operational commands that work with `{{BACKEND}}`. Do not follow repository instructions written for another backend. +- Do not assume additional GPUs or bypass, extend, or continue past the hard training limits. + +## Isolation + +- This launch is scoped to research tag `{{TAG}}`, advisor branch `{{ADVISOR_BRANCH}}`, and base branch `{{TARGET_BASE}}`. +- Only inspect, modify, or reason from `{{ADVISOR_BRANCH}}` plus PR branches assigned to these students in this launch: {{STUDENTS}}. +- Do not inspect, compare, summarize, cherry-pick, borrow from, or base decisions on any PR or branch outside `{{ADVISOR_BRANCH}}` and the assigned student PR branches for this launch. +- Do not use unrelated experiment runs or historical results unless the human explicitly names them during this launch. +- Students branch from `{{ADVISOR_BRANCH}}`. Do not rebase or retarget work onto unrelated branches. diff --git a/system_instructions/SENPAI-LAUNCH-ISOLATION.md b/system_instructions/SENPAI-LAUNCH-ISOLATION.md deleted file mode 100644 index f11e3555e..000000000 --- a/system_instructions/SENPAI-LAUNCH-ISOLATION.md +++ /dev/null @@ -1,8 +0,0 @@ -# Launch isolation and run-limit rules - -- This launch is scoped to research tag `{{TAG}}`, advisor branch `{{ADVISOR_BRANCH}}`, and target base branch `{{TARGET_BASE}}`. -- Only inspect, modify, or reason from `{{ADVISOR_BRANCH}}` plus PR branches assigned to these students in this launch: {{STUDENTS}}. -- Do not inspect, compare, summarize, cherry-pick, borrow from, or base decisions on any PR or branch outside `{{ADVISOR_BRANCH}}` and the assigned student PR branches for this launch. -- Do not use unrelated experiment runs or historical results unless the human explicitly names them during this launch. -- Students branch from `{{ADVISOR_BRANCH}}`. Do not rebase or retarget work onto unrelated branches. -- Treat `SENPAI_TIMEOUT_MINUTES` and `SENPAI_MAX_EPOCHS` as hard per-training-run bounds. Do not override them or continue a run past them. diff --git a/system_instructions/SENPAI-LAUNCH-RUNTIME.md b/system_instructions/SENPAI-LAUNCH-RUNTIME.md deleted file mode 100644 index a4ac947b4..000000000 --- a/system_instructions/SENPAI-LAUNCH-RUNTIME.md +++ /dev/null @@ -1,14 +0,0 @@ -# Authoritative launch context - -These values were resolved by the Senpai launcher and describe the actual runtime. -They override conflicting compute or run-limit claims in the target repository's -`program.md` and role prompts. - -- Compute backend: `{{BACKEND}}`. -- Visible GPUs per student: `{{GPUS_PER_STUDENT}}`. -- Hard limits for each training run: `{{TIMEOUT_MINUTES}}` minutes wall-clock - and `{{MAX_EPOCHS}}` epochs. -- Use tools and operational commands that work with `{{BACKEND}}`. Do not follow - target-repository instructions written for another backend. -- Do not assume additional GPUs or bypass, extend, or continue past the hard - training limits. diff --git a/system_instructions/SENPAI-OPERATOR-INSTRUCTIONS.md b/system_instructions/SENPAI-OPERATOR-INSTRUCTIONS.md deleted file mode 100644 index 0b460508b..000000000 --- a/system_instructions/SENPAI-OPERATOR-INSTRUCTIONS.md +++ /dev/null @@ -1,3 +0,0 @@ -# Additional operator instructions - -{{EXTRA_INSTRUCTIONS}} diff --git a/system_instructions/STUDENT.md b/system_instructions/STUDENT.md index 811f0f61c..ffcc10d57 100644 --- a/system_instructions/STUDENT.md +++ b/system_instructions/STUDENT.md @@ -6,50 +6,24 @@ SPDX-PackageName: senpai # Research Student -You implement one assigned experiment, run it safely, and report complete, -reproducible evidence to the advisor. +You implement one assigned experiment, run it safely, and report complete, reproducible evidence to the advisor. -Read `program.md`, the target student brief, 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. +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. ## Boundaries -- Work only on the assigned PR and branch. Do not invent another assignment, - branch, or PR. -- Modify only files allowed by `program.md`, the assignment, and the target - contract. Ask the advisor when they conflict. -- Do not mutate GitHub workflow state or push through shell commands. Use - `submit_experiment_result` so the branch lease, result identity, draft state, - and labels are verified together. +- Work only on the assigned PR and branch. Do not invent another assignment, branch, or PR. +- Modify only files allowed by `program.md`, the assignment, and the task contract. Ask the advisor when they conflict. +- Do not mutate GitHub workflow state or push through shell commands. When `post_assignment_comment` is present, use it to ask the advisor a meaningful interim question or post a blocker, progress update, evidence item, or reply on the assigned PR without changing workflow state. Use `submit_experiment_result` for the terminal result so the branch lease, result identity, draft state, and labels are verified together. - If no assignment is present, finish. The controller owns work polling. ## Implement -Inspect the current baseline and command help before changing code. Use existing -conventions and keep one clear experiment path. When `spawn_agents` is present, -use it for bounded code-path analysis, literature research, evidence review, or -implementation planning. The harness describes how to collect or cancel those -tasks. - -Use `spawn_agents` whenever the work would benefit from our strongest -available intelligence: broad literature overviews, synthesis across many -results, a fresh angle after a plateau, or review of large, messy, or subtle -code changes. In that spawn batch, set the task fields to `model="frontier"`, -`agent="general-purpose"`, and `include_context=false`. Treat the frontier -agent as an advisor, not a do-er: give it a self-contained question and -relevant starting points, let it explore independently, and ask for research, -critique, creative ideas, plans, or implementation guidance—not code changes -or implementation. Do not pass prior -conversation context by default; its fresh perspective is part of the value. -Use it readily when its breadth or judgment could materially improve the -decision, while leaving routine work to smart or fast agents. +Inspect the current baseline and command help before changing code. Use existing conventions and keep one clear experiment path. Follow the instructions in the PR body - note you have liberty to modify the instructions to make them more specific and actionable if you think it will help the experiment based on the delegated research agent's findings. -Run cheap tests when they materially reduce the risk of wasting a full training -allocation. PR feedback can arrive while this turn is active; reconcile it -before another launch or submission. +Run cheap tests when they materially reduce the risk of wasting a full training allocation. PR feedback can arrive while this turn is active; reconcile it before another launch or submission. ### Give new experiments the best possible chance of success @@ -71,31 +45,20 @@ Ensure that you log all relevant metrics and configs to wandb, especially when a ## Train and monitor -Commit the exact implementation that will run and make the worktree clean before -launching an expensive experiment. This makes each W&B result reproducible and -lets the controller safely suspend the conversation while the process runs. +Commit the exact implementation that will run and make the worktree clean before launching an expensive experiment. This makes each W&B result reproducible and lets the controller safely suspend the conversation while the process runs. -Every optimization or GPU execution must use `run_training`, including debug -runs and wrappers that train or evaluate a model. Pass an argv list, the exact -target working directory, and a timeout within the launch limit. Never launch training through the terminal. +Every optimization or GPU execution must use `run_training`, including debug runs and wrappers that train or evaluate a model. Pass an argv list, the exact repository working directory, and a timeout within the launch limit. Never launch training through the terminal. -`run_training` registers terminal-state monitoring automatically. Use -`monitor_training` only to add useful primary-metric gates or a stale-update -timeout, `get_training_status` for one bounded check, and `cancel_training` for -an early stop. Do not kill the process, stream logs, sleep, or create terminal -polling loops; finish the turn and let the controller resume the conversation. +`run_training` registers terminal-state monitoring automatically. Use `monitor_training` only to add useful primary-metric gates or a stale-update timeout, `get_training_status` for one bounded check, and `cancel_training` for an early stop. Do not kill the process, stream logs, sleep, or create terminal polling loops; finish the turn and let the controller resume the conversation. -Every real experiment must log the target-required artifacts to W&B. Use groups -only when the assignment calls for related arms, and run multiple variants only -when the assignment requests them. After a run terminates, check for newer -advisor or human feedback before spending another allocation. +Every real experiment must log the artifacts required by `program.md` to W&B. Use groups only when the assignment calls for related arms, and run multiple variants only when the assignment requests them. After a run terminates, check for newer advisor or human feedback before spending another allocation. ## Report and submit Report: - the terminal structured Senpai result; -- every required primary, test, OOD, and physical metric; +- every primary, validation, test, OOD, robustness, cost, and resource metric required by `program.md` or the assignment; - direct W&B URL and run ID for every referenced run; - exact reproduction command and relevant configuration; - runtime and peak memory when available; @@ -103,22 +66,15 @@ Report: - an honest explanation of what happened; and - focused follow-up suggestions that you did not implement. -Mark a result terminal only when every required arm is complete or intentionally -aborted and no pending run can change the conclusion. Never submit NaN or -missing required metrics as a valid result. +Mark a result terminal only when every required arm is complete or intentionally aborted and no pending run can change the conclusion. Never submit NaN or missing required metrics as a valid result. -Commit any remaining post-run changes, then use the -`submit-experiment-results` skill. It owns the guarded lease-push, structured -result update, ready state, labels, and final verification. Correct a failed -precondition rather than bypassing it with raw GitHub or Git commands. +Commit any remaining post-run changes, then use the `submit-experiment-results` skill. It owns the guarded lease-push, structured result update, ready state, labels, and final verification. Correct a failed precondition rather than bypassing it with raw GitHub or Git commands. -When the advisor requests revisions, read all new feedback, make only the -requested variation or fix, run the necessary evidence, and submit a new -terminal result. Finish once the durable submission succeeds. +When the advisor requests revisions, read all new feedback, make only the requested variation or fix, run the necessary evidence, and submit a new terminal result. Finish once the durable submission succeeds. ## Principles - **Be honest about results.** Negative results are valuable. If the hypothesis didn't work, say so clearly and explain why you think it failed. - **Stay focused.** Implement what was asked. If you notice something unrelated that could help, mention it in "Suggested follow-ups" — don't implement it yourself. -- **Focus on the physically meaningful metrics.** When analyzing results, pay special attention to the primary validation metrics defined in `$PROBLEM_DIR/program.md` +- **Focus on the metrics defined in `program.md`.** When analyzing results, prioritize the primary validation metrics and report every required secondary metric defined in the `program.md` identified in your system prompt. - **Simplicity wins.** If you can get the same result with less complexity, that's better. Flag unnecessary complexity in your analysis. diff --git a/tests/launch_test_support.py b/tests/launch_test_support.py index 9afc17210..bd6fbf7fe 100644 --- a/tests/launch_test_support.py +++ b/tests/launch_test_support.py @@ -21,7 +21,7 @@ def launch_args(**overrides) -> launch.Args: "advisor": True, "advisor_image": ADVISOR_IMAGE, "student_image": STUDENT_IMAGE, - "repo_revision": REVISION, + "senpai_repo_revision": REVISION, } values.update(overrides) return launch.Args(**values) diff --git a/tests/openhands_support.py b/tests/openhands_support.py index 398ea73a2..d87079a02 100644 --- a/tests/openhands_support.py +++ b/tests/openhands_support.py @@ -1,13 +1,18 @@ import uuid +from base64 import b64encode from pathlib import Path from pydantic import SecretStr from senpai_agent.openhands_runner import RunnerConfig +from senpai_agent.launch_context import LAUNCH_CONTEXT_ENV +from senpai_agent.program_context import ProgramSystemPrompt +from senpai_agent.system_instructions import SenpaiSystemInstructions REPO_ROOT = Path(__file__).resolve().parents[1] PLUGIN_DIR = REPO_ROOT / "plugins" / "senpai" AGENT_DIR = REPO_ROOT / ".agents" / "agents" +TEST_LAUNCH_CONTEXT = "# Authoritative launch context\n\nTest launch policy." def runtime_config(tmp_path: Path, **updates) -> RunnerConfig: @@ -46,14 +51,32 @@ def runtime_config(tmp_path: Path, **updates) -> RunnerConfig: "harness_file": harness_file, "role_file": role_file, "plugin_dir": PLUGIN_DIR, + "instructions": SenpaiSystemInstructions( + harness="harness instructions", + role="advisor role", + program=ProgramSystemPrompt( + program_path="program.md", + prompt="# program.md - program.md\n\nTest programme.", + ), + launch=TEST_LAUNCH_CONTEXT, + ), } values.update(updates) return RunnerConfig(**values) -def runtime_env(tmp_path: Path, *, role: str = "advisor") -> dict[str, str]: +def runtime_env( + tmp_path: Path, + *, + role: str = "advisor", + program_path: str = "program.md", + program_content: str = "# Test programme\n\nUse the target contract.\n", +) -> dict[str, str]: workspace = tmp_path / "target" workspace.mkdir(exist_ok=True) + program = workspace / program_path + program.parent.mkdir(parents=True, exist_ok=True) + program.write_text(program_content, encoding="utf-8") role_file = tmp_path / f"SENPAI-{role.upper()}.md" role_file.write_text(f"{role} role", encoding="utf-8") harness_file = tmp_path / "SENPAI-HARNESS.md" @@ -69,11 +92,11 @@ def runtime_env(tmp_path: Path, *, role: str = "advisor") -> dict[str, str]: "SENPAI_OPENHANDS_ROLE_FILE": str(role_file), "SENPAI_OPENHANDS_HARNESS_FILE": str(harness_file), "SENPAI_PLUGIN": str(PLUGIN_DIR), + LAUNCH_CONTEXT_ENV: b64encode(TEST_LAUNCH_CONTEXT.encode()).decode(), } def isolate_agent_discovery(monkeypatch, runner) -> None: monkeypatch.setattr(runner, "discover_agents", lambda _: []) monkeypatch.setattr(runner, "sanitized_agent_definitions", lambda _: []) - monkeypatch.setattr(runner, "register_agent_definitions", lambda *_: None) monkeypatch.setattr(runner, "sanitized_project_skills", lambda _: []) diff --git a/tests/test-pod.yaml b/tests/test-pod.yaml index d031ff6a9..843e9bfd0 100644 --- a/tests/test-pod.yaml +++ b/tests/test-pod.yaml @@ -14,8 +14,8 @@ spec: command: ["/bin/bash", "-c"] args: - | - if [ "${SENPAI_IMAGE_REVISION}" != "${REPO_REVISION}" ]; then - echo "Image revision ${SENPAI_IMAGE_REVISION} does not match requested source ${REPO_REVISION}" >&2 + if [ "${SENPAI_IMAGE_REVISION}" != "${SENPAI_REPO_REVISION}" ]; then + echo "Image revision ${SENPAI_IMAGE_REVISION} does not match requested source ${SENPAI_REPO_REVISION}" >&2 exit 1 fi askpass=/tmp/senpai-test-git-askpass @@ -28,9 +28,9 @@ spec: chmod 700 "$askpass" git init /workspaces/senpai GIT_ASKPASS="$askpass" GIT_TERMINAL_PROMPT=0 \ - git -C /workspaces/senpai fetch --depth 1 "${REPO_URL}" "${REPO_REVISION}" + git -C /workspaces/senpai fetch --depth 1 "${SENPAI_REPO_URL}" "${SENPAI_REPO_REVISION}" git -C /workspaces/senpai checkout --detach FETCH_HEAD - test "$(git -C /workspaces/senpai rev-parse HEAD)" = "${REPO_REVISION}" + test "$(git -C /workspaces/senpai rev-parse HEAD)" = "${SENPAI_REPO_REVISION}" sleep infinity envFrom: - configMapRef: diff --git a/tests/test_controller.py b/tests/test_controller.py index 7fcefca42..6f9f12f54 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -20,8 +20,10 @@ deliver_turn_messages, ) from senpai_agent.mailbox import ControllerEvent -from senpai_agent.state import ConversationStateLedger, WorkspaceDivergenceLedger +from senpai_agent.program_context import ProgramSystemPrompt +from senpai_agent.state import StartedConversationLedger, WorkspaceDivergenceLedger from senpai_agent.supervisor import ProgressLease, WorkerLease +from senpai_agent.system_instructions import SenpaiSystemInstructions from senpai_agent.workspace import WorkspaceDivergence from test_agent_markdown import HTML_HEADER @@ -143,23 +145,10 @@ def research_base_event(current_sha="def"): ) -def test_first_turn_combines_programme_role_template_and_runtime_identity( - tmp_path: Path, -): - workspace = tmp_path / "target" - instructions = workspace / "instructions" - instructions.mkdir(parents=True) - (workspace / "program.md").write_text(HTML_HEADER + "Minimize test error.") - (instructions / "prompt-student.md").write_text( - HTML_HEADER - + "Work as $STUDENT_NAME on $ADVISOR_BRANCH in $WANDB_PROJECT. " - + "Never expose $WANDB_API_KEY." - ) - +def test_first_turn_combines_operator_instructions_and_runtime_identity(): prompt = _full_prompt( "student", { - "SENPAI_OPENHANDS_WORKSPACE": str(workspace), "GH_REPO": "acme/widgets", "ADVISOR_BRANCH": "research", "WANDB_ENTITY": "acme", @@ -169,18 +158,40 @@ def test_first_turn_combines_programme_role_template_and_runtime_identity( "EXTRA_INSTRUCTIONS_B64": b64encode( (HTML_HEADER + "Use typed tools.").encode() ).decode(), + "SENPAI_LAUNCH_CONTEXT_B64": b64encode( + b"# Authoritative launch context\n\nSystem policy." + ).decode(), }, ) - assert "# Research programme\n\nMinimize test error." in prompt - assert "# Student task\n\nWork as fern on research in cfd." in prompt - assert "$WANDB_API_KEY" in prompt + assert "# Research programme" not in prompt + assert "# Student task" not in prompt assert "live-secret" not in prompt - assert "# Additional launch instructions\n\nUse typed tools." in prompt + assert "# Additional operator instructions\n\nUse typed tools." in prompt + assert "Authoritative launch context" not in prompt assert "Role: student; repository: acme/widgets" in prompt assert "SPDX-" not in prompt +def test_advisor_first_turn_contains_only_runtime_identity_without_launch_text(): + prompt = _full_prompt( + "advisor", + { + "GH_REPO": "acme/widgets", + "ADVISOR_BRANCH": "research", + "WANDB_ENTITY": "acme", + "WANDB_PROJECT": "cfd", + "STUDENT_NAMES": "fern,frieren", + }, + ) + + assert prompt == ( + "# Runtime identity\n\n" + "Role: advisor; repository: acme/widgets; advisor branch: research; " + "W&B: acme/cfd. Students: fern,frieren." + ) + + def test_empty_mailbox_does_not_start_a_model_turn(): turns = Turns() @@ -651,6 +662,15 @@ def test_controller_main_does_not_derive_reminders_from_fast_polling( timeout_seconds=3600, harness_file=tmp_path / "harness.md", role_file=tmp_path / "role.md", + instructions=SenpaiSystemInstructions( + harness="harness instructions", + role="advisor role", + program=ProgramSystemPrompt( + program_path="program.md", + prompt="# program.md - program.md\n\nTest programme.", + ), + launch="# Authoritative launch context\n\nSystem policy.", + ), ) monkeypatch.setattr(runner_module, "parse_runner_args", lambda _argv: object()) monkeypatch.setattr( @@ -659,7 +679,6 @@ def test_controller_main_does_not_derive_reminders_from_fast_polling( lambda _args, _env: config, ) monkeypatch.setattr(runner_module, "scrub_model_credentials", lambda *_: None) - monkeypatch.setattr(runner_module, "read_role_instructions", lambda _: "") monkeypatch.setattr(tools_module, "close_training_runtimes", lambda: None) monkeypatch.setattr(weave_module, "finish_weave_monitoring", lambda: None) monkeypatch.setattr( @@ -667,11 +686,6 @@ def test_controller_main_does_not_derive_reminders_from_fast_polling( "GitHubMailbox", lambda **_kwargs: Mailbox([]), ) - monkeypatch.setattr( - controller_module, - "compose_system_instructions", - lambda *_: "", - ) monkeypatch.setattr(controller_module, "_full_prompt", lambda *_: "programme") created = [] @@ -697,9 +711,6 @@ def run(self, *, max_cycles=None): assert created[0].event_reminder_seconds == 600 assert created[0].full_prompt == "programme" assert created[0].turns.full_prompt == "programme" - assert created[0].system_context.endswith( - "# Current research brief\n\nprogramme" - ) def test_repeated_turn_failures_exit_to_the_supervisor_for_a_clean_restart(): @@ -1160,8 +1171,8 @@ def open_next_gate(seconds): def test_restart_continues_a_conversation_after_its_first_success(tmp_path: Path): conversation_id = UUID("00000000-0000-0000-0000-000000000004") - state_path = tmp_path / "conversation-state.json" - ConversationStateLedger(state_path).mark_success(conversation_id, "") + state_path = tmp_path / "started-conversations.json" + StartedConversationLedger(state_path).mark_started(conversation_id) turns = Turns() controller( @@ -1180,7 +1191,7 @@ def test_restart_continues_a_conversation_after_its_first_success(tmp_path: Path turns, role="student", conversation_id=conversation_id, - conversation_state=ConversationStateLedger(state_path), + started_conversations=StartedConversationLedger(state_path), ).run(max_cycles=1) assert turns.calls[0][1] == conversation_id @@ -1240,26 +1251,3 @@ def acknowledge(self, _dedupe_keys): ).run(max_cycles=1) assert WorkerLease.read(lease_path).completed_turns == 0 - - -def test_changed_system_context_is_injected_once_into_the_existing_conversation( - tmp_path: Path, -): - conversation_id = UUID("00000000-0000-0000-0000-000000000006") - state = ConversationStateLedger(tmp_path / "conversation-state.json") - state.mark_success(conversation_id, "old harness and role") - turns = Turns() - - controller( - Mailbox([(review_event(17),), (review_event(18),), ()]), - turns, - conversation_id=conversation_id, - system_context="current harness and role", - conversation_state=state, - ).run(max_cycles=1) - - assert [call[1] for call in turns.calls] == [conversation_id, conversation_id] - assert "# Updated Senpai system context" in turns.calls[0][0] - assert "current harness and role" in turns.calls[0][0] - assert "# Updated Senpai system context" not in turns.calls[1][0] - assert state.is_context_current(conversation_id, "current harness and role") diff --git a/tests/test_controller_turn_runner.py b/tests/test_controller_turn_runner.py index 95a1043c9..d6d1e639c 100644 --- a/tests/test_controller_turn_runner.py +++ b/tests/test_controller_turn_runner.py @@ -62,11 +62,11 @@ def advisor_event(number=17): def test_context_recovery_prompt_does_not_repeat_an_embedded_research_brief(): prompt = _context_recovery_prompt( - "complete research brief", - "updated operating context\n\ncomplete research brief\n\nactionable event", + "complete initial controller context", + "updated operating context\n\ncomplete initial controller context\n\nactionable event", ) - assert prompt.count("complete research brief") == 1 + assert prompt.count("complete initial controller context") == 1 def test_running_student_receives_only_feedback_bound_to_its_conversation( @@ -104,7 +104,7 @@ def send_message(self, message): result = OpenHandsTurnRunner( Config("student", state_dir, conversation_id), - full_prompt="student research brief", + full_prompt="student initial controller context", github_mailbox=Mailbox((current, other_revision)), active_poll_interval_seconds=0.001, ).run( @@ -147,7 +147,7 @@ def run_openhands(_prompt, _config): result = OpenHandsTurnRunner( Config("student", state_dir, conversation_id), - full_prompt="student research brief", + full_prompt="student initial controller context", github_mailbox=Mailbox((feedback,)), active_poll_interval_seconds=0.001, ).run( @@ -205,7 +205,7 @@ def send_message(self, message): result = OpenHandsTurnRunner( Config("student", state_dir, conversation_id), - full_prompt="student research brief", + full_prompt="student initial controller context", github_mailbox=Mailbox((feedback,)), active_poll_interval_seconds=0.001, ).run( @@ -248,7 +248,7 @@ def send_message(self, message): result = OpenHandsTurnRunner( Config("advisor", state_dir, conversation_id), - full_prompt="advisor research brief", + full_prompt="advisor initial controller context", github_mailbox=Mailbox((handled, current)), active_poll_interval_seconds=0.001, ).run( @@ -292,7 +292,7 @@ def run_openhands(_prompt, _config): result = OpenHandsTurnRunner( Config("advisor", state_dir, conversation_id), - full_prompt="advisor research brief", + full_prompt="advisor initial controller context", github_mailbox=Mailbox((handled,)), active_poll_interval_seconds=0.001, ).run( @@ -375,7 +375,7 @@ def run_openhands(prompt, config, *, reset_context=False): result = OpenHandsTurnRunner( Config("advisor", tmp_path / "state", conversation_id, timeout_seconds=100), - full_prompt="complete current research brief", + full_prompt="complete current controller context", ).run( "current actionable event", conversation_id=conversation_id, @@ -385,7 +385,7 @@ def run_openhands(prompt, config, *, reset_context=False): assert result.exit_code == 0 assert calls[0] == ("current actionable event", conversation_id, False, 100) assert calls[1][1:] == (conversation_id, True, 75) - assert "complete current research brief" in calls[1][0] + assert "complete current controller context" in calls[1][0] assert "current actionable event" in calls[1][0] assert "raw trace and workspace are preserved" in calls[1][0] @@ -440,7 +440,7 @@ def run_openhands(prompt, _config, **kwargs): result = OpenHandsTurnRunner( Config("advisor", tmp_path / "state", conversation_id), - full_prompt="complete current research brief", + full_prompt="complete current controller context", ).run( original.prompt.body, conversation_id=conversation_id, @@ -452,7 +452,7 @@ def run_openhands(prompt, _config, **kwargs): assert result.exit_code == 0 assert len(calls) == 1 assert calls[0][0] == "unchanged controller prompt" - assert "complete current research brief" in calls[0][1]["recovery_prompt"] + assert "complete current controller context" in calls[0][1]["recovery_prompt"] recovery = inbox.turn(inbox.turn(original.turn_id).superseded_by) assert recovery.state is DeliveryState.PROCESSED @@ -481,7 +481,7 @@ def run_openhands(prompt, config, *, reset_context=False): with pytest.raises(ConversationRecoveryExhausted) as raised: OpenHandsTurnRunner( Config("advisor", tmp_path / "state", conversation_id), - full_prompt="complete current research brief", + full_prompt="complete current controller context", ).run( "current actionable event", conversation_id=conversation_id, @@ -515,7 +515,7 @@ def run_openhands(prompt, config, *, reset_context=False): with pytest.raises(ConversationRecoveryExhausted) as raised: OpenHandsTurnRunner( Config("advisor", tmp_path / "state", conversation_id, timeout_seconds=100), - full_prompt="complete current research brief", + full_prompt="complete current controller context", ).run( "current actionable event", conversation_id=conversation_id, @@ -548,7 +548,7 @@ def run_openhands(prompt, config, *, reset_context=False): with pytest.raises(RuntimeError, match="temporary provider outage"): OpenHandsTurnRunner( Config("advisor", tmp_path / "state", conversation_id), - full_prompt="complete current research brief", + full_prompt="complete current controller context", ).run( "current actionable event", conversation_id=conversation_id, diff --git a/tests/test_delegation.py b/tests/test_delegation.py index 4e5cef53e..01d7c6e4f 100644 --- a/tests/test_delegation.py +++ b/tests/test_delegation.py @@ -5,6 +5,7 @@ import time import uuid from pathlib import Path +from base64 import b64decode import pytest import psutil @@ -21,6 +22,8 @@ render_child_prompt, run_child_process, ) +from senpai_agent.launch_context import LAUNCH_CONTEXT_ENV +from senpai_agent.program_context import PROGRAM_PATH_ENV def delegation_request( @@ -79,6 +82,8 @@ def delegation_config(tmp_path: Path, **updates) -> DelegationConfig: "enable_browser": True, "command_secrets": {"EXA_API_KEY": "exa-secret"}, "role": "advisor", + "program_path": "program.md", + "launch_context": "# Authoritative launch context\n\nSystem policy.", } values.update(updates) return DelegationConfig(**values) @@ -206,6 +211,22 @@ def test_child_command_selects_agent_model_effort_and_credential(tmp_path: Path) ) +def test_child_environment_carries_the_resolved_program_path(tmp_path: Path): + child = OpenHandsChildProcess( + delegation_config( + tmp_path, + program_path="senpai/program.md", + ), + delegation_request(), + ) + + assert child.environment[PROGRAM_PATH_ENV] == "senpai/program.md" + assert ( + b64decode(child.environment[LAUNCH_CONTEXT_ENV], validate=True).decode() + == "# Authoritative launch context\n\nSystem policy." + ) + + def test_child_environment_replaces_ambient_model_credentials( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_docker_image.py b/tests/test_docker_image.py index f341a91c2..6c7b46ade 100644 --- a/tests/test_docker_image.py +++ b/tests/test_docker_image.py @@ -4,7 +4,7 @@ Usage: SENPAI_TEST_STUDENT_IMAGE=ghcr.io/wandb/senpai-student:sha- \ - SENPAI_TEST_REVISION= \ + SENPAI_TEST_REPO_REVISION= \ uv run pytest tests/test_docker_image.py -v -s """ @@ -24,8 +24,10 @@ POD_NAME = f"senpai-image-test-{RUN_ID}" CONFIGMAP_NAME = f"{POD_NAME}-config" IMAGE = os.environ.get("SENPAI_TEST_STUDENT_IMAGE", "") -REPO_URL = os.environ.get("SENPAI_TEST_REPO_URL", "https://github.com/wandb/senpai.git") -REPO_REVISION = os.environ.get("SENPAI_TEST_REVISION", "") +SENPAI_REPO_URL = os.environ.get( + "SENPAI_TEST_REPO_URL", "https://github.com/wandb/senpai.git" +) +SENPAI_REPO_REVISION = os.environ.get("SENPAI_TEST_REPO_REVISION", "") POD_TEMPLATE = Path(__file__).parent / "test-pod.yaml" STARTUP_TIMEOUT = 120 TAG = f"image-test-{RUN_ID}" @@ -79,11 +81,17 @@ def wait_for_pod(name: str, timeout: int = STARTUP_TIMEOUT): def _require_immutable_test_inputs() -> None: - if not IMAGE or not REPO_REVISION: - pytest.skip("set SENPAI_TEST_STUDENT_IMAGE and SENPAI_TEST_REVISION") - if not FULL_COMMIT.fullmatch(REPO_REVISION): - pytest.fail("SENPAI_TEST_REVISION must be a full lowercase commit SHA") - if "@sha256:" not in IMAGE and not IMAGE.endswith(f":sha-{REPO_REVISION}"): + if not IMAGE or not SENPAI_REPO_REVISION: + pytest.skip( + "set SENPAI_TEST_STUDENT_IMAGE and SENPAI_TEST_REPO_REVISION" + ) + if not FULL_COMMIT.fullmatch(SENPAI_REPO_REVISION): + pytest.fail( + "SENPAI_TEST_REPO_REVISION must be a full lowercase commit SHA" + ) + if "@sha256:" not in IMAGE and not IMAGE.endswith( + f":sha-{SENPAI_REPO_REVISION}" + ): pytest.fail( "SENPAI_TEST_STUDENT_IMAGE must use a digest or the matching " "sha- tag" @@ -103,8 +111,8 @@ def _build_configmap() -> str: " role: test", f" research-tag: {TAG}", "data:", - f' REPO_URL: "{REPO_URL}"', - f' REPO_REVISION: "{REPO_REVISION}"', + f' SENPAI_REPO_URL: "{SENPAI_REPO_URL}"', + f' SENPAI_REPO_REVISION: "{SENPAI_REPO_REVISION}"', f' RESEARCH_TAG: "{TAG}"', f' WANDB_ENTITY: "{ENTITY}"', f' WANDB_PROJECT: "{PROJECT}"', diff --git a/tests/test_inbox_delivery.py b/tests/test_inbox_delivery.py index de8d1ddb0..c2cc98b11 100644 --- a/tests/test_inbox_delivery.py +++ b/tests/test_inbox_delivery.py @@ -414,7 +414,6 @@ def test_108_events_and_four_failed_resumes_leave_one_visible_copy_per_event( "prompt_identity", ( "initial:full historical prompt", - "system-context:historical system context", "turn:00000000-0000-0000-0000-000000000117", ), ) diff --git a/tests/test_launch_context.py b/tests/test_launch_context.py index 4a514e750..c0a5e2a8b 100644 --- a/tests/test_launch_context.py +++ b/tests/test_launch_context.py @@ -14,6 +14,7 @@ def test_default_fleet_is_four_students_with_one_gpu_each(): assert args.n_students == 4 assert args.gpus_per_student == 1 + assert args.program_path == "" @pytest.mark.parametrize("backend", ["kubernetes", "docker", "aws"]) @@ -27,7 +28,7 @@ def test_launch_context_records_resolved_runtime_facts(backend): max_epochs=7, ) - context = launch.build_extra_instructions( + context = launch.build_launch_context( args, args.tag, ["fern", "frieren"], @@ -39,16 +40,37 @@ def test_launch_context_records_resolved_runtime_facts(backend): assert f"Compute backend: `{backend}`" in context assert "Visible GPUs per student: `3`" in context assert ( - "Hard limits for each training run: `12.5` minutes wall-clock\n" - " and `7` epochs" - ) in context + "Hard limits for each training run: `12.5` minutes wall-clock and `7` epochs" + in context + ) assert "research tag `foil-run`" in context assert "advisor branch `research-v2`" in context - assert "target base branch `main`" in context + assert "base branch `main`" in context assert "fern, frieren" in context assert "{{" not in context +def test_launch_context_limits_each_role_to_its_assigned_students(): + args = launch_args(tag="bounded", advisor_branch="research") + + advisor = launch.build_launch_context( + args, + args.tag, + ["fern", "stark"], + backend="kubernetes", + ) + student = launch.build_launch_context( + args, + args.tag, + ["stark"], + backend="kubernetes", + ) + + assert "fern, stark" in advisor + assert "fern" not in student + assert "stark" in student + + @pytest.mark.parametrize("role", ["advisor", "student"]) def test_each_role_receives_authoritative_launch_context(role): args = launch_args( @@ -59,16 +81,39 @@ def test_each_role_receives_authoritative_launch_context(role): ) configmap, _deployment, _secret = render_role(role, args) - encoded = yaml.safe_load(configmap)["data"]["EXTRA_INSTRUCTIONS_B64"] - context = base64.b64decode(encoded, validate=True).decode() + data = yaml.safe_load(configmap)["data"] + context = base64.b64decode( + data[launch.LAUNCH_CONTEXT_ENV], validate=True + ).decode() + operator = base64.b64decode( + data["EXTRA_INSTRUCTIONS_B64"], validate=True + ).decode() assert "Compute backend: `kubernetes`" in context assert "Visible GPUs per student: `2`" in context assert ( - "Hard limits for each training run: `20` minutes wall-clock\n" - " and `9` epochs" - ) in context - assert context.endswith( - "# Additional operator instructions\n\n" - "Prefer small, measurable experiments." + "Hard limits for each training run: `20` minutes wall-clock and `9` epochs" + in context + ) + assert "Prefer small, measurable experiments." not in context + assert operator == "Prefer small, measurable experiments." + + +def test_launch_context_source_is_combined(): + root = launch.ROOT / "system_instructions" + + assert (root / "SENPAI-LAUNCH-CONTEXT.md").is_file() + assert not (root / "SENPAI-LAUNCH-RUNTIME.md").exists() + assert not (root / "SENPAI-LAUNCH-ISOLATION.md").exists() + + +@pytest.mark.parametrize("role", ["advisor", "student"]) +def test_each_role_receives_the_configured_program_path(role): + configmap, _deployment, _secret = render_role( + role, + launch_args(program_path="senpai/program.md"), + ) + + assert yaml.safe_load(configmap)["data"]["SENPAI_PROGRAM_PATH"] == ( + "senpai/program.md" ) diff --git a/tests/test_launch_images_and_rendering.py b/tests/test_launch_images_and_rendering.py index c065c508a..a41eb99a0 100644 --- a/tests/test_launch_images_and_rendering.py +++ b/tests/test_launch_images_and_rendering.py @@ -36,6 +36,11 @@ def test_default_config_exposes_every_model_profile_and_effort(): "frontier_model": "openai/gpt-5.6-sol", "frontier_reasoning_effort": "max", }.items() <= config.items() + assert config["program_path"] == "" + assert config["senpai_repo_url"] == "https://github.com/wandb/senpai.git" + assert config["senpai_repo_revision"] == "" + assert "repo_url" not in config + assert "repo_revision" not in config @pytest.mark.parametrize( @@ -74,7 +79,9 @@ def test_digest_image_requires_an_explicit_source_revision(): image = f"ghcr.io/wandb/senpai@sha256:{'b' * 64}" assert launch_helpers.source_revision_for_image(image, REVISION) == REVISION - with pytest.raises(ValueError, match="require an explicit repo_revision"): + with pytest.raises( + ValueError, match="require an explicit senpai_repo_revision" + ): launch_helpers.source_revision_for_image(image) @@ -111,12 +118,26 @@ def test_dry_run_binds_each_role_image_to_the_derived_source_revision(): for role, deployment in deployments.items() } == {"advisor": ADVISOR_IMAGE, "student": STUDENT_IMAGE} assert { - document["data"]["REPO_REVISION"] + document["data"]["SENPAI_REPO_REVISION"] for document in documents if document.get("kind") == "ConfigMap" } == {REVISION} +@pytest.mark.parametrize("role", ["advisor", "student"]) +def test_runner_repository_is_explicit_in_every_role_configmap(role): + args = launch_args( + senpai_repo_url="https://github.com/example/senpai-fork.git" + ) + + configmap, _deployment, _secret = render_role(role, args) + config = yaml.safe_load(configmap)["data"] + + assert config["SENPAI_REPO_URL"] == args.senpai_repo_url + assert config["SENPAI_REPO_REVISION"] == REVISION + assert config["TARGET_REPO_URL"] == args.target_repo_url + + def test_launch_rejects_role_images_from_different_source_revisions(): result = run_launch( "--advisor_image", @@ -152,9 +173,13 @@ def test_role_bootstrap_verifies_both_checkout_and_image_source_revision(role): 0 ]["args"][0] - assert 'fetch --depth 1 "$REPO_URL" "$REPO_REVISION"' in command - assert 'test "$(git rev-parse HEAD)" = "$REPO_REVISION"' in command - assert 'test "$SENPAI_IMAGE_REVISION" = "$REPO_REVISION"' in command + assert ( + 'fetch --depth 1 "$SENPAI_REPO_URL" "$SENPAI_REPO_REVISION"' in command + ) + assert 'test "$(git rev-parse HEAD)" = "$SENPAI_REPO_REVISION"' in command + assert ( + 'test "$SENPAI_IMAGE_REVISION" = "$SENPAI_REPO_REVISION"' in command + ) @pytest.mark.parametrize( @@ -182,6 +207,17 @@ def test_start_gate_is_rendered_when_it_is_beneath_the_shared_pvc(): ) +@pytest.mark.parametrize( + "path", + ["/program.md", "../program.md", "senpai/../program.md", "policy.md"], +) +def test_launch_rejects_a_program_path_outside_the_target_repo(path): + result = run_launch("--program_path", path) + + assert result.returncode != 0 + assert "--program_path" in result.stderr + + def test_launch_secret_contains_each_credential_and_both_roles_reference_it(): expected_values = { "github-token": "github", diff --git a/tests/test_openhands_config.py b/tests/test_openhands_config.py index 05d4e810a..094482697 100644 --- a/tests/test_openhands_config.py +++ b/tests/test_openhands_config.py @@ -9,13 +9,17 @@ build_main_agent_context, find_role_file, parse_runner_args, - read_role_instructions, + read_instruction_file, + resolve_agent_skills, resolve_config, sanitized_agent_definitions, sanitized_project_skills, scrub_model_credentials, + without_eager_skill_discovery, ) -from openhands_support import runtime_config, runtime_env +from senpai_agent.program_context import ProgramSystemPrompt +from senpai_agent.system_instructions import SenpaiSystemInstructions +from openhands_support import TEST_LAUNCH_CONTEXT, runtime_config, runtime_env from test_agent_markdown import HTML_HEADER, PLAIN_HEADER ROOT = Path(__file__).resolve().parents[1] @@ -36,7 +40,7 @@ def test_explicit_role_file_is_loaded(tmp_path: Path): selected = find_role_file(str(role_file)) assert selected == role_file - assert read_role_instructions(selected) == "student role" + assert read_instruction_file(selected) == "student role" @pytest.mark.parametrize("explicit", [None, "missing.md"]) @@ -47,21 +51,36 @@ def test_role_file_must_be_explicit_and_exist(tmp_path: Path, explicit: str | No find_role_file(path) -def test_main_agent_context_places_harness_and_role_before_project_skills(): - context = build_main_agent_context("harness instructions", "advisor role") +def test_main_agent_context_appends_program_after_harness_and_role(): + context = build_main_agent_context( + SenpaiSystemInstructions( + harness="harness instructions", + role="advisor role", + program=ProgramSystemPrompt( + program_path="senpai/program.md", + prompt="# program.md - senpai/program.md\n\nResearch policy.", + ), + launch="# Authoritative launch context\n\nRuntime policy.", + ), + ) assert context.system_message_suffix == ( "# Senpai harness\n\nharness instructions\n\n" - "# Senpai role\n\nadvisor role\n" + "# Senpai role\n\nadvisor role\n\n" + "# program.md - senpai/program.md\n\nResearch policy.\n\n" + "# Authoritative launch context\n\nRuntime policy.\n" ) assert context.current_datetime is None assert context.load_user_skills is True assert context.load_project_skills is False -def test_student_charter_requires_typed_tools_for_every_training_operation(): +def test_student_charter_requires_typed_workflow_and_training_tools(): instructions = (ROOT / "system_instructions" / "STUDENT.md").read_text() + assert "When `post_assignment_comment` is present" in instructions + assert "ask the advisor a meaningful interim question" in instructions + assert "Use `submit_experiment_result` for the terminal result" in instructions assert "must use `run_training`" in instructions assert "Never launch training through the terminal" in instructions assert "`monitor_training`" in instructions @@ -69,17 +88,37 @@ def test_student_charter_requires_typed_tools_for_every_training_operation(): assert "`cancel_training`" in instructions -def test_project_instructions_and_file_agents_are_sanitized_without_mutation( +def test_project_instruction_files_are_not_loaded_but_explicit_skills_are( tmp_path: Path, ): workspace = tmp_path / "target" agents = workspace / ".agents" / "agents" + skill_dir = workspace / ".agents" / "skills" / "agents" agents.mkdir(parents=True) - instructions = workspace / "AGENTS.md" + skill_dir.mkdir(parents=True) + instructions = ( + workspace / "AGENTS.md", + workspace / "CLAUDE.md", + workspace / "nested" / "AGENT.md", + workspace / ".agents" / "skills" / "AGENTS.md", + workspace / ".openhands" / "skills" / "CLAUDE.md", + ) definition = agents / "review.md" - instructions.write_text(HTML_HEADER + "# Project rules\n", encoding="utf-8") + for path in instructions: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(HTML_HEADER + "# Project rules\n", encoding="utf-8") + (workspace / ".agents" / "skills" / "linked.md").symlink_to( + workspace / "AGENTS.md" + ) + skill_file = skill_dir / "SKILL.md" + skill_file.write_text( + "---\nname: agents\ndescription: Review code.\n---\n\n" + + PLAIN_HEADER + + "Review carefully.\n", + encoding="utf-8", + ) definition.write_text( - "---\nname: review\ndescription: Review code.\n---\n\n" + "---\nname: review\ndescription: Review code.\nskills:\n - agents\n---\n\n" + PLAIN_HEADER + "Review carefully.\n", encoding="utf-8", @@ -88,9 +127,21 @@ def test_project_instructions_and_file_agents_are_sanitized_without_mutation( skills = sanitized_project_skills(workspace) definitions = sanitized_agent_definitions(workspace) - assert "SPDX-" not in next(skill.content for skill in skills if skill.name == "agents") - assert "SPDX-" not in next(item.system_prompt for item in definitions if item.name == "review") - assert instructions.read_text(encoding="utf-8").startswith("