diff --git a/docs/agent-lifecycle/agent-roster.md b/docs/agent-lifecycle/agent-roster.md index bb0b9683..51541476 100644 --- a/docs/agent-lifecycle/agent-roster.md +++ b/docs/agent-lifecycle/agent-roster.md @@ -106,13 +106,16 @@ state dirs work as for every other agent. deploy log). Any agent holding it gets the same mounts. The operator dashboard's `POST /api/meta-update` is the only path that writes `flake.lock`. -- **Prompt** — identical for every container: `prompts/system.md` +- **Prompt** — identical for every container but one block (below): + `prompts/system.md` carries `` / `` marker blocks, and `prompt::render` filters for `"agent"` unconditionally, manager included ("always `agent` role — there is only one role", `hive-agent/src/prompt.rs`'s own module doc). The `role:manager` blocks are dead in production, exercised only by a unit test - (`filter_role_blocks(SAMPLE, "manager")`). + (`filter_blocks(SAMPLE, "role", "manager")`). The one block that differs + is `` vs ``: an agent built + from an [archetype](../swarm/archetypes.md) is told it has no config repo. - **Tool allow-list** — the MCP tools claude sees come from the agent's permissions alone, the same mechanism for every agent ([`docs/turn-loop/mcp.md`](../turn-loop/mcp.md)). Ruth's permissions diff --git a/docs/agent-lifecycle/config-changes.md b/docs/agent-lifecycle/config-changes.md index e08cac73..85c971b7 100644 --- a/docs/agent-lifecycle/config-changes.md +++ b/docs/agent-lifecycle/config-changes.md @@ -65,6 +65,8 @@ Creating a brand-new agent is swarm-level (`swarmctl agent create`, an agent. The swarm controller's `InitAgentConfigRepo` job seeds the agent's config repo with a default `agent.nix` template, then asks the target hive to deploy it. +An agent created from an [archetype](../swarm/archetypes.md) gets no +config repo: its hive builds it from the archetype's module. Changing what the template seeded isn't a special case: like every later change, it's a PR on that config repo, made from a clone and diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index 26929d39..ee46d818 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -420,6 +420,12 @@ reads it at every container start for `manage_root_agent`. Each agent gets its own entry from it as `swarm-permissions.json` in its runtime dir, which it holds until its own first read of its key succeeds. +`/var/lib/hyperhive/swarm-agent-archetypes.json` holds the archetype each of +this hive's archetype agents runs, as last read from the swarm's +`agent-archetype` bucket at the agent's provision and each rebuild. An agent +with an entry has no `applied/` repo and no `agent-` meta input; see +[archetypes](../swarm/archetypes.md). + hive-c0re renders containers onto `meta#` at creation. ## Destroy vs purge diff --git a/docs/swarm/archetypes.md b/docs/swarm/archetypes.md index c9708a6c..ab49de4e 100644 --- a/docs/swarm/archetypes.md +++ b/docs/swarm/archetypes.md @@ -1,21 +1,24 @@ # Agent archetypes -An archetype is a named set of settings to create an agent from. The swarm -UI's create form lists the archetypes the swarm knows. Picking one fills the -form's permissions, resource caps and inference fields from it, and you can -still change each field before you create the agent. +An archetype is a named agent setup: settings to create the agent with, and +a NixOS module its container runs. The swarm UI's create form lists the +archetypes the swarm knows. Picking one fills the form's permissions, +resource caps and inference fields from it, and you can still change each +field before you create the agent. -The agent you create this way is an ordinary agent: it gets its own -`agent-configs/` repo like any other, and the request carries only the -fields as the form shows them. Nothing records which archetype you picked. +An agent created from an archetype has no `agent-configs/` repo of its +own. Its hive builds it from the archetype's module, so every agent of that +archetype runs the same configuration. To change what it runs, change the +archetype. Picking "none" creates a custom agent, with its own config repo. ## The built-in archetypes The hyperhive flake ships these: -| archetype | what it sets | -| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| `reviewer` | permissions `messaging`, `inbox`, `execution`: the default set without `meta`. With `execution` it can run `hive-forge`, `git` and other commands. | +| archetype | what it sets | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `reviewer` | permissions `messaging`, `inbox`, `execution`: the default set without `meta`. With `execution` it can run `hive-forge`, `git` and other commands. | +| `android-dev` | installs `android-tools` (`adb`, `fastboot`), Gradle and a JDK; default permissions. | ## Where an archetype comes from @@ -38,8 +41,8 @@ hyperhiveArchetypes.reviewer = { - Every archetype needs a `description`. `permissions`, `limits` and `inference` are optional and take the shapes `POST /api/agents` takes. The swarm ignores other keys in `meta`. -- `module` is a NixOS module for the agent's container. The swarm doesn't - apply it yet. +- `module` is a NixOS module for the agent's container. A hive imports it + into each agent created from the archetype. Today the swarm reads one flake: the hyperhive source the [builder](builder.md)'s host runs, so only the built-ins appear. @@ -85,3 +88,46 @@ The stored value: "refused": { "broken": "limits: …" } } ``` + +## Creating an agent from an archetype + +The create form sends the picked archetype as `archetype` on +`POST /api/agents`, `{ "source": "hyperhive", "name": "reviewer" }`. The +controller refuses with 400 an archetype the stored list doesn't hold, then: + +1. stores the choice in the `agent-archetype` KV bucket, keyed by agent name, + next to the agent's permissions, caps and inference; +2. queues the creation graph without `CreateRepo`, `AddRepoMember` and + `InitAgentConfigRepo`. Identity, forge user, docs read, the mints and the + wanted state stay, and the deploy waits on those alone. + +Destroying the agent deletes its key. +Every hive may read any key of the bucket +(`$JS.API.DIRECT.GET.KV_agent-archetype.>`) and write none; only the +controller writes it. + +Deploy `swarm-nats-auth` before the hives: every provision reads the +agent's key, and a hive whose queue connection predates the grant gets a +refused read, so its `Provision` nodes fail until it reconnects. + +The hive reads the agent's key when it provisions the agent and on every +rebuild, and keeps the last value read in +`/var/lib/hyperhive/swarm-agent-archetypes.json`, so the meta flake renders +without the queue. When the read fails, a rebuild keeps the value +stored there. For an archetype agent the hive: + +- seeds no `proposed` or `applied` repo, and gives the meta flake no + `agent-` input. `mkAgent` imports + `hyperhive.hyperhiveArchetypes..module` in place of the agent's own + flake, and sets `HIVE_ARCHETYPE` in the harness environment; +- relocks no agent input on a rebuild or a meta-input bump, and ignores the + `rev` of a swarm deploy request; +- mounts an empty `/agents//config/`. + +The agent's system prompt says it has no config repo and names its +archetype. + +The hive takes the module from its own `hyperhive` input. When that input +predates the archetype, the agent's build fails with +`this hive's hyperhive input has no archetype `. Update the hive's +`hyperhive` input to fix it. diff --git a/docs/turn-loop/claude-invocation.md b/docs/turn-loop/claude-invocation.md index c1441c99..22b1ba15 100644 --- a/docs/turn-loop/claude-invocation.md +++ b/docs/turn-loop/claude-invocation.md @@ -255,7 +255,12 @@ needs to `chown` a bind mount), once at startup: `hive-agent/prompts/system.md` by `hive-agent`'s `prompt::render`: HTML-comment markers (`...`, same for `role:manager`) gate the role-specific blocks; both flavors - share everything else. Five placeholders are then + share everything else. `` and + `` blocks gate the config-repo paragraphs: an + agent with `HIVE_ARCHETYPE` set (the meta flake sets it on an agent built + from an [archetype](../swarm/archetypes.md)) gets the archetype block and + every other agent the repo block, with `{archetype}` filled in with the + archetype's name. Five placeholders are then substituted: `{label}` (short agent name), `{qualified_label}` (hive-qualified `name@domain` form), `{hive_identity}` (for example `` on hive `pr1ma` ``; empty when @@ -275,14 +280,17 @@ needs to `chown` a bind mount), once at startup: telling the agent where the docs live. Passed via `--system-prompt-file`. - **Marker grammar.** `` opens a block; any - `` closes the current block. The renderer always uses - role `agent`, so it elides blocks with other role tags. Nesting **isn't** + **Marker grammar.** `` opens a block; any + `` closes the current block of that kind. The renderer + filters the two kinds one after the other. The renderer always uses + role `agent`, so it elides blocks with other role tags, and config `repo` + or `archetype` as above. Nesting **isn't** supported — a stray opener with no closer runs until end of file. The renderer tolerates whitespace inside markers (`` parses the same as ``). Content outside any marker is always - included. Today's `system.md` carries no markers (single agent role) — - the grammar stays wired for a future manager / multi-role prompt. + included. Today's `system.md` carries no role markers (single agent + role), and the role grammar stays wired for a future manager / multi-role + prompt; it carries one pair of config blocks. **`hive_identity` / `swarm_identity` shape.** Each carries a leading space + backticked name (` on hive \`pr1ma\``, diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentArchetype.ts b/frontend/packages/swarm-ui/src/pages/CreateAgentArchetype.ts index 6b1edc73..508fcf64 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentArchetype.ts +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentArchetype.ts @@ -61,3 +61,13 @@ export function prefill( }, }; } + +/** + * The `archetype` field of `POST /api/agents` for `archetype`, or nothing for + * a custom agent. + */ +export function requestArchetype( + archetype: Archetype | undefined, +): { source: string; name: string } | undefined { + return archetype && { source: archetype.source, name: archetype.name }; +} diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx index e9734be3..3074c765 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx @@ -57,6 +57,7 @@ import { import { archetypeKey, prefill, + requestArchetype, type ArchetypeList, } from "./CreateAgentArchetype.js"; import "./CreateAgentForm.css"; @@ -230,6 +231,7 @@ export function CreateAgentForm({ permissions: catalogue ? requestPermissions(selected, catalogue.default) : undefined, + archetype: requestArchetype(picked), }), }); if (!r.ok) { @@ -321,7 +323,9 @@ export function CreateAgentForm({ /> {picked && (

- {picked.description} + {picked.description} An agent created from an archetype has + no config repo: its hive builds it from the archetype's + module.

)} @@ -396,7 +400,9 @@ export function CreateAgentForm({ Submitting this queues everything a new agent needs: a swarm-level identity, a config repo on the forge with the operator added as a collaborator, and a container running the agent on the hive you pick - above. Watch it all settle on jobs. + above. An agent created from an archetype gets no config repo: its + container runs the archetype's module. Watch it all settle on{" "} + jobs.

diff --git a/frontend/packages/swarm-ui/test/create-agent-archetype.test.mjs b/frontend/packages/swarm-ui/test/create-agent-archetype.test.mjs index fb884822..e6d3d0ca 100644 --- a/frontend/packages/swarm-ui/test/create-agent-archetype.test.mjs +++ b/frontend/packages/swarm-ui/test/create-agent-archetype.test.mjs @@ -2,7 +2,11 @@ // Run by `npm test` with Node's type stripping. import assert from "node:assert/strict"; import { test } from "node:test"; -import { archetypeKey, prefill } from "../src/pages/CreateAgentArchetype.ts"; +import { + archetypeKey, + prefill, + requestArchetype, +} from "../src/pages/CreateAgentArchetype.ts"; const defaults = ["messaging", "meta", "inbox", "execution"]; @@ -65,3 +69,17 @@ test("the key tells sources apart", () => { archetypeKey({ source: "b", name: "x", description: "" }), ); }); + +test("a picked archetype is sent by source and name, none is left out", () => { + const reviewer = { + source: "hyperhive", + name: "reviewer", + description: "reviews", + permissions: ["messaging"], + }; + assert.deepEqual(requestArchetype(reviewer), { + source: "hyperhive", + name: "reviewer", + }); + assert.equal(requestArchetype(undefined), undefined); +}); diff --git a/hive-agent/prompts/system.md b/hive-agent/prompts/system.md index 18bba8de..c3dd9e95 100644 --- a/hive-agent/prompts/system.md +++ b/hive-agent/prompts/system.md @@ -3,13 +3,24 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity Tools (hyperhive surface). Full signature + behavior for each comes from the tool's own MCP description (you already received it via the MCP tool schema) — this is just the map of what exists and which ones are gated, so you know where to look: - **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__mark_todos_done`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. `send` reaches peers on this hive only; **reach the operator over matrix** if you have the matrix MCP tools (`mcp__matrix__send_dm` or `mcp__matrix__send_message`); without them the operator reads your `set_status` line and turn output on the dashboard, and your logs. `send(to: "operator")` is refused. One habit worth internalizing beyond the tool descriptions themselves: prefer ending the turn over repeatedly polling `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint). For a large todo backlog (`get_loose_ends` caps at 40 rows), clear reviewed ids in bulk with `mark_todos_done` rather than cancelling one at a time — there's no blind range-clear, only ids you've actually looked at. -- **Extra MCP tools** (some agents only): `mcp____` — agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `services.hyperhive.agent.extraMcpServers`. First-class tools, already operator-approved at deploy time. +- **Extra MCP tools** (some agents only): `mcp____` — agent-specific (matrix client, scraper, db connector, etc.) declared in your NixOS config under `services.hyperhive.agent.extraMcpServers`. First-class tools, already operator-approved at deploy time. + + Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — open a PR on your `agent-configs/` repo, or contact the operator directly. Config repos live at `/agents/{label}/config/` (read-only inside your container). All changes flow through operator-approved commits. Your config repo is mounted **read-only** at `/agents/{label}/config/` — `agent.nix` plus whatever extra files define you (declared packages, env vars, MCP servers). Read it to see exactly what defines you before asking for a change, so you can point at the precise file and line. -Operator boundary: starting, stopping and rebuilding containers is the operator's — ask them when any agent needs one. _Creating_ a new agent is not something you can do from here — ask the operator, who scaffolds the new agent's config repo and spawns it from the dashboard. _Changing_ any agent's config is not a tool call at all — it's a forge PR on the agent's `agent-configs/` repo, which an operator reviews and merges on the forge; the merge deploys it. The operator only signs off on changes; you run the day-to-day. +_Changing_ any agent's config is not a tool call at all — it's a forge PR on the agent's `agent-configs/` repo, which an operator reviews and merges on the forge; the merge deploys it. + + + + +You are built from the `{archetype}` archetype and have no config repo of your own: there is no `agent-configs/` repo for you, and `/agents/{label}/config/` is empty. Need new packages, env vars, or other NixOS config for yourself? Ask the operator: what defines you is the archetype, shared with every agent built from it. + + + +Operator boundary: starting, stopping and rebuilding containers is the operator's — ask them when any agent needs one. _Creating_ a new agent is not something you can do from here — ask the operator, who creates agents at swarm level. The operator only signs off on changes; you run the day-to-day. Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `container_crash`, `needs_update`. Use these to react to lifecycle changes: diff --git a/hive-agent/src/prompt.rs b/hive-agent/src/prompt.rs index 5dccb8ea..4d865040 100644 --- a/hive-agent/src/prompt.rs +++ b/hive-agent/src/prompt.rs @@ -1,6 +1,6 @@ //! System-prompt renderer. Single `prompts/system.md` with -//! HTML-comment markers gating role-specific blocks; this module -//! assembles the final prompt (always "agent" role — there is only one +//! HTML-comment markers gating role-specific and config-repo-specific +//! blocks; this module assembles the final prompt (always "agent" role — there is only one //! role). Marker grammar + placeholder substitution rules in //! `docs/turn-loop/claude-invocation.md::On-boot files` (`claude-system-prompt.md`). @@ -32,7 +32,7 @@ pub fn render( docs_dir: Option<&str>, operators: &[OperatorProfile], ) -> String { - let body = filter_role_blocks(template, "agent"); + let body = filter_blocks(template, "role", "agent"); let qualified = crate::identity::qualify(label); let hive_identity = hive_name .filter(|n| !n.is_empty()) @@ -118,27 +118,27 @@ async fn within_timeout( } } -/// Walk `template` line-by-line, stripping `` / -/// `` marker lines and suppressing the lines inside a block -/// unless `X == target`. A close marker ends the current block. Production -/// `system.md` carries no markers today (single agent role — see the module -/// doc), so this is effectively a passthrough; the marker grammar stays wired -/// for a future manager / multi-role prompt. -fn filter_role_blocks(template: &str, target: &str) -> String { +/// Walk `template` line-by-line, stripping `` / +/// `` marker lines and suppressing the lines inside a block +/// unless `X == target`. A close marker ends the current block. Markers of +/// any other kind are left in place, for the pass that filters that kind. +/// Two kinds exist: `role` (always `agent`; the grammar stays wired for a +/// future manager / multi-role prompt) and `config` ([`config_blocks`]). +fn filter_blocks(template: &str, kind: &str, target: &str) -> String { let mut out = String::with_capacity(template.len()); - // None = outside any block; Some(role) = inside role-tagged block. - let mut active_role: Option<&str> = None; + // None = outside any block; Some(value) = inside a block of this kind. + let mut active: Option<&str> = None; for line in template.lines() { let trimmed = line.trim(); - if let Some(role) = parse_open_marker(trimmed) { - active_role = Some(role); + if let Some(value) = parse_open_marker(trimmed, kind) { + active = Some(value); continue; } - if parse_close_marker(trimmed).is_some() { - active_role = None; + if parse_close_marker(trimmed, kind).is_some() { + active = None; continue; } - if active_role.is_none_or(|role| role == target) { + if active.is_none_or(|value| value == target) { out.push_str(line); out.push('\n'); } @@ -146,23 +146,37 @@ fn filter_role_blocks(template: &str, target: &str) -> String { out } -/// `` → `Some("agent")`. Anything else returns -/// None. Whitespace inside the marker is tolerated so a future -/// author's `` (no spaces) still parses; the dashboard -/// markdown renderer is equally lenient. Close tags (`/role:...`) -/// can't accidentally match — the `strip_prefix("role:")` rejects -/// the leading slash before we'd ever see it. -fn parse_open_marker(line: &str) -> Option<&str> { - let inside = line.strip_prefix("")?.trim(); - let role = inside.strip_prefix("role:")?.trim(); - Some(role) +/// `template` with its `config:repo` blocks for a custom agent, or its +/// `config:archetype` blocks and `{archetype}` filled in for an agent built +/// from `archetype`. An empty name is a custom agent. +fn config_blocks(template: &str, archetype: Option<&str>) -> String { + match archetype.filter(|a| !a.is_empty()) { + Some(name) => filter_blocks(template, "config", "archetype").replace("{archetype}", name), + None => filter_blocks(template, "config", "repo"), + } } -/// `` → `Some("agent")`. Mirror of -/// [`parse_open_marker`] for the closing tag. -fn parse_close_marker(line: &str) -> Option<&str> { +/// `` with `kind = "role"` → `Some("agent")`. Anything +/// else returns None. Whitespace inside the marker is tolerated so a future +/// author's `` (no spaces) still parses; the dashboard +/// markdown renderer is equally lenient. Close tags (`/role:...`) can't +/// accidentally match — the `strip_prefix(kind)` rejects the leading slash +/// before we'd ever see it. +fn parse_open_marker<'a>(line: &'a str, kind: &str) -> Option<&'a str> { let inside = line.strip_prefix("")?.trim(); - inside.strip_prefix("/role:").map(str::trim) + let value = inside.strip_prefix(kind)?.strip_prefix(':')?.trim(); + Some(value) +} + +/// `` with `kind = "role"` → `Some("agent")`. Mirror of +/// [`parse_open_marker`] for the closing tag. +fn parse_close_marker<'a>(line: &'a str, kind: &str) -> Option<&'a str> { + let inside = line.strip_prefix("")?.trim(); + inside + .strip_prefix('/')? + .strip_prefix(kind)? + .strip_prefix(':') + .map(str::trim) } /// Write the assembled prompt to a stable path next to the harness @@ -196,6 +210,9 @@ pub async fn write_system_prompt(_socket: &Path, label: &str) -> Result let docs_dir = std::env::var("HIVE_DOCS_DIR") .ok() .filter(|d| !d.is_empty()); + // The meta flake sets HIVE_ARCHETYPE on an agent built from an archetype, + // which has no config repo of its own. + let template = config_blocks(&template, std::env::var("HIVE_ARCHETYPE").ok().as_deref()); let body = render( &template, label, @@ -257,7 +274,7 @@ shared closer #[test] fn filter_keeps_shared_and_target_role() { - let agent = filter_role_blocks(SAMPLE, "agent"); + let agent = filter_blocks(SAMPLE, "role", "agent"); assert!(agent.contains("shared opener")); assert!(agent.contains("agent-only line")); assert!(!agent.contains("manager-only line")); @@ -268,7 +285,7 @@ shared closer #[test] fn filter_for_manager_picks_manager_block() { - let manager = filter_role_blocks(SAMPLE, "manager"); + let manager = filter_blocks(SAMPLE, "role", "manager"); assert!(manager.contains("shared opener")); assert!(!manager.contains("agent-only line")); assert!(manager.contains("manager-only line")); @@ -278,23 +295,38 @@ shared closer #[test] fn parse_open_marker_handles_whitespace_variants() { - assert_eq!(parse_open_marker(""), Some("agent")); - assert_eq!(parse_open_marker(""), Some("agent")); - assert_eq!(parse_open_marker(""), Some("manager")); + assert_eq!( + parse_open_marker("", "role"), + Some("agent") + ); + assert_eq!( + parse_open_marker("", "role"), + Some("agent") + ); + assert_eq!( + parse_open_marker("", "role"), + Some("manager") + ); // Close tags must NOT match open-tag parser. - assert_eq!(parse_open_marker(""), None); + assert_eq!(parse_open_marker("", "role"), None); // Non-markers pass through (return None). - assert_eq!(parse_open_marker("just text"), None); - assert_eq!(parse_open_marker(""), None); + assert_eq!(parse_open_marker("just text", "role"), None); + assert_eq!(parse_open_marker("", "role"), None); } #[test] fn parse_close_marker_handles_whitespace_variants() { - assert_eq!(parse_close_marker(""), Some("agent")); - assert_eq!(parse_close_marker(""), Some("manager")); + assert_eq!( + parse_close_marker("", "role"), + Some("agent") + ); + assert_eq!( + parse_close_marker("", "role"), + Some("manager") + ); // Open tags must NOT match close-tag parser. - assert_eq!(parse_close_marker(""), None); - assert_eq!(parse_close_marker("just text"), None); + assert_eq!(parse_close_marker("", "role"), None); + assert_eq!(parse_close_marker("just text", "role"), None); } #[test] @@ -331,6 +363,49 @@ shared closer assert!(rendered.contains("`/agents/alice/work/`"), "{rendered}"); } + const CONFIG_FIXTURE: &str = "\ +shared + +open a PR on agent-configs + + +built from `{archetype}`, no config repo + +"; + + /// A custom agent gets the config-repo lines, an archetype agent the + /// archetype lines with its name, and an empty name is a custom agent. + #[test] + fn config_blocks_pick_the_block_by_archetype() { + for custom in [None, Some("")] { + let picked = config_blocks(CONFIG_FIXTURE, custom); + assert!(picked.contains("open a PR on agent-configs"), "{picked}"); + assert!(!picked.contains("no config repo"), "{picked}"); + } + let picked = config_blocks(CONFIG_FIXTURE, Some("android-dev")); + assert!(picked.contains("built from `android-dev`, no config repo")); + assert!(!picked.contains("agent-configs"), "{picked}"); + assert!(picked.contains("shared")); + assert!(!picked.contains("