From 2ad81c971fafd11fafb0588bb6386646fe4bd0c0 Mon Sep 17 00:00:00 2001 From: Onyeka Nwamba Date: Sun, 24 May 2026 04:26:39 +0100 Subject: [PATCH 1/7] feat: support role-based agent templates --- README.md | 28 +- defaults/agent-roles/README.md | 32 + docs/api/intake-routing-admin.openapi.yaml | 13 +- .../local-self-hosted-setup-cli-contract.md | 34 +- docs/integration-guide.md | 10 +- docs/quick-start.md | 33 +- docs/sdk.md | 17 +- docs/testing/ai-assisted-routing-e2e.md | 6 +- src/agent-profile-store.ts | 16 + src/agent-role-template.ts | 92 +- src/cli/agent-management.ts | 473 +++++++--- src/cli/agent-runner.ts | 101 +- src/cli/agentrail-home.ts | 4 + src/cli/doctor.ts | 160 +++- src/cli/global-management.ts | 8 + src/cli/index.ts | 5 +- src/cli/run-context.ts | 10 + src/cli/setup-config.ts | 1 + src/cli/setup-files.ts | 26 +- src/cli/setup-wizard.ts | 96 +- src/intake-routing-control-plane.ts | 14 + src/managed-run-context.ts | 5 + src/managed-run-role-brief.ts | 214 +++++ src/repo-map.ts | 449 +++++++++ src/runner-execution-policy.ts | 7 +- src/setup-verification-task.ts | 7 +- test/agent-management.test.ts | 860 ++++++++++++++++-- test/agent-profile-store.test.ts | 26 + test/agent-role-template.test.ts | 77 ++ test/agent-runner.test.ts | 213 ++++- test/docs-contract.test.js | 4 +- test/helpers/setup-doctor-fixture.ts | 7 +- test/init-local-bootstrap.test.ts | 72 +- test/intake-routing-control-plane.test.ts | 31 + test/managed-run-role-brief.test.ts | 152 ++++ test/repo-map.test.ts | 152 ++++ test/runner-execution-policy.test.ts | 31 + test/setup-doctor.test.ts | 224 ++++- test/setup-files.test.ts | 18 +- test/setup-onboarding-e2e.test.ts | 2 +- test/setup-wizard.test.ts | 108 ++- 41 files changed, 3527 insertions(+), 311 deletions(-) create mode 100644 src/managed-run-role-brief.ts create mode 100644 src/repo-map.ts create mode 100644 test/managed-run-role-brief.test.ts create mode 100644 test/repo-map.test.ts diff --git a/README.md b/README.md index 241cf31..3584cca 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,11 @@ agentrail server start In another terminal, verify setup: ```bash -agentrail doctor +agentrail doctor --agent-id agt_example ``` `doctor` is the success gate. It checks the local API, agent credentials, -profile/routing state, and whether the current agent can see assigned work. +profile/routing state, and whether the selected agent can see assigned work. ## Telemetry @@ -132,6 +132,30 @@ agentrail agent create agentrail agent update --agent-id agt_example ``` +Use a built-in role template, or install your own YAML-backed role template: + +```bash +agentrail agent create --role-template frontend-ui +agentrail agent create --role-template-file ./agent-roles/payments-backend.yaml +``` + +Custom templates are validated and copied into +`~/.agentrail/agent-role-templates/`, so later agents can reuse them with +`--role-template `. + +During `agentrail init` and `agentrail repo add`, AgentRail also creates an +editable repo map under `~/.agentrail/repo-maps/.yaml`. Role templates use +repo-agnostic areas such as `frontend_source`, `tests`, and `ci_configuration`; +the repo map translates those areas to the actual folders in your project. + +When a managed agent starts work, AgentRail combines the selected role template +with the repo map for that repository and passes a compact role brief to the +runner. The brief points the agent at relevant repo areas and validation +expectations, while AgentRail still owns lifecycle actions such as PR creation, +CI watching, review handling, and shipping. If a repo map is missing or +incomplete, AgentRail still runs the agent and includes a warning in the run +context. Run `agentrail doctor` to check repo-map coverage. + Inspect local config and connected repos: ```bash diff --git a/defaults/agent-roles/README.md b/defaults/agent-roles/README.md index 8eae26b..2eaffaf 100644 --- a/defaults/agent-roles/README.md +++ b/defaults/agent-roles/README.md @@ -21,3 +21,35 @@ The templates are also deliberately repo-neutral. Workspace entries use abstract areas such as `backend_source`, `api_contracts`, `tests`, or `ci_configuration` instead of concrete path globs. A project-level repo profile should map those areas to actual paths during `agentrail init` or a later template-tailoring step. + +`agentrail init` and `agentrail repo add` create editable repo maps under +`~/.agentrail/repo-maps/.yaml`. Keep concrete paths there, not in role +templates. + +When a managed agent starts work, AgentRail combines the selected role template +with the repo map for that repository and passes a compact role brief to the +runner. The brief points the agent at relevant repo areas and validation +expectations, while AgentRail still owns lifecycle actions such as PR creation, +CI watching, review handling, and shipping. + +If a repo map is missing or incomplete, AgentRail still runs the agent and +includes a warning in the run context. Run `agentrail doctor` to check repo-map +coverage. + +## Custom Templates + +Install a custom role template from a local YAML file when creating or updating +an agent: + +```bash +agentrail agent create --role-template-file ./agent-roles/payments-backend.yaml +agentrail agent update --agent-id agt_payments --role-template-file ./agent-roles/payments-backend.yaml +``` + +AgentRail validates the YAML, rejects IDs that collide with built-in templates, +and stores the installed copy under `~/.agentrail/agent-role-templates/.yaml`. +After that, reuse it by ID: + +```bash +agentrail agent create --role-template payments-backend +``` diff --git a/docs/api/intake-routing-admin.openapi.yaml b/docs/api/intake-routing-admin.openapi.yaml index 5ee348a..05b598e 100644 --- a/docs/api/intake-routing-admin.openapi.yaml +++ b/docs/api/intake-routing-admin.openapi.yaml @@ -303,6 +303,7 @@ paths: agentId: agt_cto displayName: CTO role: cto + roleTemplateId: backend-api status: active capabilityTags: [api-design, architecture, security-review] ownershipTags: [control-plane, docs] @@ -344,6 +345,7 @@ paths: value: displayName: CTO role: cto + roleTemplateId: backend-api status: active capabilityTags: [api-design, architecture, security-review] ownershipTags: [control-plane, docs] @@ -365,6 +367,7 @@ paths: agentId: agt_cto displayName: CTO role: cto + roleTemplateId: backend-api status: active capabilityTags: [api-design, architecture, security-review] ownershipTags: [control-plane, docs] @@ -394,7 +397,7 @@ paths: description: | Creates one deterministic setup smoke task per selected `agentId`. The task is assigned directly to that agent and stays in - `in_progress` so the setup runner can prove `GET /tasks/mine` works. + `in_progress` so the managed runner can prove its run-scoped context works. The CLI pairs this endpoint with `agentrail doctor`; setup is not complete until the generated agent key can read the task from `GET /tasks/mine?status=in_progress&limit=1`. @@ -813,6 +816,10 @@ components: type: string role: type: string + roleTemplateId: + type: [string, 'null'] + description: Optional built-in or custom role template id used to create this agent profile. + pattern: '^[a-z][a-z0-9-]*$' status: type: string enum: [active, paused, disabled] @@ -865,6 +872,10 @@ components: type: string role: type: string + roleTemplateId: + type: [string, 'null'] + description: Optional built-in or custom role template id used to create this agent profile. + pattern: '^[a-z][a-z0-9-]*$' status: type: string enum: [active, paused, disabled] diff --git a/docs/architecture/local-self-hosted-setup-cli-contract.md b/docs/architecture/local-self-hosted-setup-cli-contract.md index 4b2c67c..99756ad 100644 --- a/docs/architecture/local-self-hosted-setup-cli-contract.md +++ b/docs/architecture/local-self-hosted-setup-cli-contract.md @@ -130,8 +130,8 @@ Review setup plan: - After server start, create AgentIdentity agt_codex_local - Create AgentProfile for oxnw/agentrail - Create starter routing and setup verification task -- Write .agentrail/agent.env with mode 0600 -- Run agentrail doctor and require /tasks/mine to return assigned work +- Write .agentrail/agents/agt_codex_local.env with mode 0600 +- Run agentrail doctor --agent-id agt_codex_local and require /tasks/mine to return assigned work ? Continue Yes @@ -314,8 +314,8 @@ The local/self-hosted data model should stay cloud-shaped: store. The important boundary is that the store is queryable, versioned, and compatible with API semantics; ad hoc Markdown parsing is not acceptable. - `.agentrail/config.json` stores non-secret configuration only. -- `.agentrail/agent.env` stores generated local agent secrets and must be - created with mode `0600`. +- `.agentrail/agents/.env` stores generated local agent secrets and + must be created with mode `0600`. - Optional Markdown export writes snapshots such as `.agentrail/notes/tasks/.md`, `.agentrail/notes/routing/.md`, and @@ -412,7 +412,7 @@ Expected response fields used by the CLI: } ``` -The CLI stores only `data.apiKey` in `.agentrail/agent.env`, never in +The CLI stores only `data.apiKey` in `.agentrail/agents/.env`, never in `.agentrail/config.json`. The key ID is not sufficient for authentication and must not be printed as if it were a secret. @@ -457,13 +457,14 @@ create or ingest one setup verification task through the normal assignment path. Real provider mode may use a selected provider issue instead, but it must still end with an assigned AgentRail task visible to the new agent. -The CLI must run `agentrail doctor` after registration. Success means the -generated key can call `/tasks/mine` against the configured base URL. +The CLI must run `agentrail doctor --agent-id ` after registration. +Success means the generated key can call `/tasks/mine` against the configured +base URL. ## Smoke Tests And Success Gate -`agentrail doctor` is part of setup, not an optional diagnostic. Setup reports -success only after these checks pass: +`agentrail doctor --agent-id ` is part of setup, not an optional +diagnostic. Setup reports success only after these checks pass: 1. `GET /health` returns `status: "ok"` from the configured base URL. 2. In auth-enabled mode, the generated `AGENTRAIL_API_KEY` authenticates as the @@ -500,7 +501,8 @@ command instead of telling the user to start the LLM agent. ## Agent Env Output -`agentrail agent create` writes `.agentrail/agent.env` with mode `0600`: +`agentrail agent create` writes `.agentrail/agents/agt_codex_local.env` with +mode `0600`: ```bash AGENTRAIL_BASE_URL=http://127.0.0.1:3000 @@ -516,19 +518,19 @@ It also prints the next command for the selected runner. Codex: ```bash -source .agentrail/agent.env && cd /path/to/target-repo && codex +agentrail agent run --agent-id agt_codex_local --once ``` Claude Code: ```bash -source .agentrail/agent.env && cd /path/to/target-repo && claude --append-system-prompt-file "$AGENTRAIL_AGENT_RECIPE_PATH" +source .agentrail/agents/agt_claude_local.env && cd /path/to/target-repo && claude --append-system-prompt-file "$AGENTRAIL_AGENT_RECIPE_PATH" ``` Cursor: ```bash -source .agentrail/agent.env && cursor /path/to/target-repo +source .agentrail/agents/agt_cursor_local.env && cursor /path/to/target-repo ``` For Cursor, the CLI should also write or update a project rule that contains @@ -582,11 +584,11 @@ surface before the underlying auth and routing contracts are stable. equivalent command. - All server mutations use deterministic payload-derived idempotency keys so exact replays are safe and later edits do not collide with earlier requests. -- `agentrail agent create --rotate-key` rotates the API key and rewrites - `.agentrail/agent.env`. +- `agentrail agent update --agent-id ` rotates the API key and rewrites + `.agentrail/agents/.env`. - Real provider mode fails closed when required provider token env vars are missing. -- The CLI never commits `.agentrail/agent.env`, provider tokens, or generated +- The CLI never commits `.agentrail/agents/*.env`, provider tokens, or generated AgentRail API keys. - The first auth-enabled local setup run may create an admin key through the documented auth creation path. Later setup calls require the admin key or a diff --git a/docs/integration-guide.md b/docs/integration-guide.md index d6e1cb0..01ef13a 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -52,7 +52,7 @@ integration can rely on today: | Intake | **Current:** Provider intake is documented in the routing OpenAPI, but the self-managed server does not yet run a live provider intake worker. | **Legacy:** The removed demo used a pre-seeded task instead of ingesting a provider issue. | **Planned:** The control plane receives or pulls provider issue snapshots and normalizes them into AgentRail task candidates. | | Routing | **Current:** Routing rules, optional AI routing, dry-run evaluation, assignment, and audit are implemented as operator/admin contracts; `AGENTRAIL_ROUTING_AUDIT_STORE_PATH` persists decisions and evaluation/intake idempotency replay locally. | **Legacy:** The removed demo skipped routing by starting with a pre-assigned task. | **Planned:** Hosted control-plane deployments evaluate deterministic rules, use AI to route tasks to the right agents where configured, store `routingReason`, wake the selected agent, and expose managed audit history. | | Auth | **Current:** Agent API key creation, scopes, rate limits, and route enforcement are implemented on the default server path. | **Legacy:** Placeholder demo keys are no longer valid on the core runtime. | **Planned:** Hosted control-plane deployments issue least-privilege scoped keys per agent and expose operator rotation workflows. | -| Local/self-hosted setup | **Current:** `agentrail init` writes local `.agentrail` scaffolding and operator bootstrap state, `agentrail agent create` creates scoped local agent credentials/profile/routing, and `agentrail doctor` verifies health, auth, profile/routing state, and `/tasks/mine` visibility. | **Legacy:** The removed demo runtime used a built-in fixture task instead of explicit task-store configuration. | **Planned:** Hosted setup will wrap the same identity/profile/routing concepts in a managed team onboarding service. | +| Local/self-hosted setup | **Current:** `agentrail init` writes local `.agentrail` scaffolding and operator bootstrap state, `agentrail agent create` creates scoped local agent credentials/profile/routing, and `agentrail doctor --agent-id ` verifies health, auth, profile/routing state, and `/tasks/mine` visibility. | **Legacy:** The removed demo runtime used a built-in fixture task instead of explicit task-store configuration. | **Planned:** Hosted setup will wrap the same identity/profile/routing concepts in a managed team onboarding service. | | Live task store | **Current:** The server reads durable task records from `AGENTRAIL_TASK_STORE_PATH`, can persist routing audit records through `AGENTRAIL_ROUTING_AUDIT_STORE_PATH`, and never falls back to hidden fixture data. | **Legacy:** The removed demo used an in-memory deterministic lifecycle store. | **Planned:** The control plane persists assigned tasks, routing decisions, lifecycle state, and event cursors in managed storage. | | Submit | **Current:** `mode: "adapter_managed"` lets the GitHub submit adapter create or reuse provider PRs from persisted `task.source` metadata. | **Legacy:** Artifact-style placeholder PR examples remain documentation-only; they are not a runtime mode. | **Planned:** Submit is always mediated by provider adapters, with idempotent create-or-reuse behavior and compact response state. | | CI / review | **Current:** GitHub Actions, CircleCI, and GitHub review feedback adapters expose compact status summaries from persisted `task.source` metadata. | **Legacy:** The removed demo simulated CI and review transitions locally. | **Planned:** The control plane stores provider status snapshots, emits task events, and prefers push delivery over agent polling. | @@ -90,7 +90,7 @@ Current CLI-assisted model: failures go to triage. - `agentrail agent create` creates the scoped agent key, `AgentProfile`, starter routing state, managed agent env file, and optional per-agent model/profile. -- `agentrail doctor` uses the generated agent key plus operator state to verify +- `agentrail doctor --agent-id ` uses the generated agent key plus operator state to verify that the bootstrap produced visible assigned work. In AI routing mode it also verifies that the configured local runner is available without spending model tokens. @@ -167,7 +167,7 @@ agent credentials, routing, and provider state. The default path is: local agents awake. 3. Create or connect the first local agent with `agentrail agent create` if `init` did not already do it interactively. -4. Finish with `agentrail doctor`. +4. Finish with `agentrail doctor --agent-id `. Track A enables anonymous product telemetry by default during setup. It uses an anonymous install ID. AgentRail does not send source code, issue text, prompts, @@ -193,7 +193,7 @@ In a second terminal: ```bash agentrail agent create -agentrail doctor +agentrail doctor --agent-id agt_example ``` Cloud boundary note: Track A proves the local lifecycle contract. It does not @@ -450,7 +450,7 @@ secret `data.apiKey`. Current behavior: auth-enabled setup is an operator/server wiring path, not a one-command local CLI. Planned behavior: `agentrail agent create/connect` wraps -the API key and profile calls described below, writes `.agentrail/agent.env`, +the API key and profile calls described below, writes `.agentrail/agents/.env`, and verifies that the generated key can call `/tasks/mine`. Create the first admin key: diff --git a/docs/quick-start.md b/docs/quick-start.md index 2aac444..0b98ac2 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -2,7 +2,7 @@ This is the default local/self-hosted onboarding path for the current source-available runtime. It is CLI-first: use `agentrail init`, start the local -API, create or connect a local agent, then use `agentrail doctor` as the +API, create or connect a local agent, then use `agentrail doctor --agent-id ` as the success gate. This setup path closes the routing setup work described in @@ -106,6 +106,25 @@ The agent wizard creates scoped local agent credentials, writes the managed agent env file, creates or updates the agent profile, and can configure starter routing for the selected repo. +Use `--role-template ` for a built-in or previously installed template. Use +`--role-template-file ` when you want to install a custom YAML template for +that agent role; AgentRail validates it and stores a copy under +`~/.agentrail/agent-role-templates/`. + +AgentRail creates editable repo maps under `~/.agentrail/repo-maps/` during +`init` and `repo add`. These maps connect template areas like +`frontend_source`, `tests`, and `ci_configuration` to the actual paths in your +repo. + +Agent role templates are used at run time. When a managed agent starts work, +AgentRail combines the selected role template with the repo map for that +repository and passes a compact role brief to the runner. The brief points the +agent at relevant repo areas and validation expectations, while AgentRail still +owns lifecycle actions such as PR creation, CI watching, review handling, and +shipping. If a repo map is missing or incomplete, AgentRail still runs the agent +and includes a warning in the run context. Run `agentrail doctor` to check +repo-map coverage. + Use this later to change permissions, routing, or repo allowlists: ```bash @@ -115,13 +134,13 @@ agentrail agent update --agent-id agt_example ## 5. Run Doctor ```bash -agentrail doctor +agentrail doctor --agent-id agt_example ``` Success means: - The local API is reachable. -- The current agent env file is usable. +- The selected agent env file is usable. - The agent profile is active. - Routing state exists for the current repo. - `/tasks/mine?status=in_progress&limit=1` returns assigned work when setup @@ -182,15 +201,13 @@ routing configuration fails closed instead of creating hidden unassigned work. ## 7. Start A Coding Agent -Load the generated agent env file before launching a runner from the target -repo. The exact file path depends on the agent id created by the wizard. +Use the generated agent id when manually launching a runner. The exact id is +printed by `agentrail agent create`. Example: ```bash -source ~/.agentrail/agent.env -cd /path/to/target-repo -codex +agentrail agent run --agent-id agt_example --once ``` The shared runner guidance lives in [agent recipes](./agent-recipes.md). diff --git a/docs/sdk.md b/docs/sdk.md index 17032d7..487ee63 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -20,16 +20,19 @@ agentrail init agentrail server start ``` -In another terminal, create an agent if `init` did not create one, load the -generated environment, and verify the setup: +In another terminal, create an agent if `init` did not create one and verify +that specific agent: ```bash agentrail agent create -source ~/.agentrail/agent.env -agentrail doctor +agentrail doctor --agent-id agt_example ``` -The generated env file provides the two values SDK clients need: +SDK examples need an agent API key. Use the per-agent env file created by +`agentrail agent create`; AgentRail does not create a default +`~/.agentrail/agent.env`. + +The generated per-agent env file provides the two values SDK clients need: ```bash AGENTRAIL_BASE_URL=http://127.0.0.1:3000 @@ -392,10 +395,10 @@ Use a new key for a genuinely new attempt. | Symptom | Likely cause | Fix | | --- | --- | --- | | `AgentRailClient requires baseUrl` | TypeScript client was created without a base URL. | Pass `baseUrl: process.env.AGENTRAIL_BASE_URL ?? "http://127.0.0.1:3000"`. | -| `401` | Missing or wrong bearer token. | Load `~/.agentrail/agent.env` and use `AGENTRAIL_API_KEY`, not the `akey_` id. | +| `401` | Missing or wrong bearer token. | Load `~/.agentrail/agents/.env` and use `AGENTRAIL_API_KEY`, not the `akey_` id. | | `403` | Key is missing a required scope. | Create or rotate an agent key with the needed scope. | | `409` | The task is not in a state that allows the requested action, or an idempotency key was reused with a different body. | Re-read the task and follow `availableActions`; use a fresh key for a new attempt. | -| Empty task list | No assigned task matches the filter. | Check routing, agent profile, and `agentrail doctor`. | +| Empty task list | No assigned task matches the filter. | Check routing, agent profile, and `agentrail doctor --agent-id `. | | CI or review data is missing | Provider is not connected or the task source is incomplete. | Run `agentrail provider doctor` and repair source metadata if needed. | More reference material: diff --git a/docs/testing/ai-assisted-routing-e2e.md b/docs/testing/ai-assisted-routing-e2e.md index c8c6165..cf3f582 100644 --- a/docs/testing/ai-assisted-routing-e2e.md +++ b/docs/testing/ai-assisted-routing-e2e.md @@ -40,7 +40,7 @@ agentrail agent create --runner codex --model gpt-5.4-mini Run doctor: ```bash -agentrail doctor +agentrail doctor --agent-id ``` Expected result: @@ -110,7 +110,7 @@ Expected result: ## Case 5: Runner Readiness Failure Is Visible 1. Configure AI routing with an unavailable local runner. -2. Run `agentrail doctor`. +2. Run `agentrail doctor --agent-id `. Expected result: @@ -122,7 +122,7 @@ Expected result: For each case, record: -- command transcript for `agentrail doctor`; +- command transcript for `agentrail doctor --agent-id `; - provider issue URL or import command; - task id and `routingReason`; - assignment source; diff --git a/src/agent-profile-store.ts b/src/agent-profile-store.ts index c3fc38f..fad52c8 100644 --- a/src/agent-profile-store.ts +++ b/src/agent-profile-store.ts @@ -2,11 +2,13 @@ import crypto from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; +import { ROLE_TEMPLATE_ID_PATTERN } from "./agent-role-template.ts"; import { TaskLifecycleError } from "./task-lifecycle-errors.ts"; export interface AgentProfileReplaceRequest { displayName: string; role: string; + roleTemplateId?: string | null; status: "active" | "paused" | "disabled"; capabilityTags: string[]; ownershipTags: string[]; @@ -20,6 +22,7 @@ export interface AgentProfile { agentId: string; displayName: string; role: string; + roleTemplateId: string | null; status: "active" | "paused" | "disabled"; capabilityTags: string[]; ownershipTags: string[]; @@ -169,6 +172,7 @@ export class AgentProfileStore { agentId, displayName: payload.displayName, role: payload.role, + roleTemplateId: payload.roleTemplateId ?? null, status: payload.status, capabilityTags: clone(payload.capabilityTags), ownershipTags: clone(payload.ownershipTags), @@ -251,6 +255,15 @@ export class AgentProfileStore { availableActions: ["retry"], }); } + if ( + payload.roleTemplateId !== undefined && + payload.roleTemplateId !== null && + (typeof payload.roleTemplateId !== "string" || !ROLE_TEMPLATE_ID_PATTERN.test(payload.roleTemplateId)) + ) { + throw new TaskLifecycleError(400, "validation_error", "Routing agent profile `roleTemplateId` must be a lowercase kebab-case template id.", { + availableActions: ["retry"], + }); + } } } @@ -267,6 +280,9 @@ function normalizeProfile(value: unknown): AgentProfile | null { agentId: value.agentId, displayName: value.displayName, role: value.role, + roleTemplateId: typeof value.roleTemplateId === "string" && ROLE_TEMPLATE_ID_PATTERN.test(value.roleTemplateId) + ? value.roleTemplateId + : null, status: value.status as AgentProfile["status"], capabilityTags: clone(value.capabilityTags), ownershipTags: clone(value.ownershipTags), diff --git a/src/agent-role-template.ts b/src/agent-role-template.ts index 0179bc6..81259a0 100644 --- a/src/agent-role-template.ts +++ b/src/agent-role-template.ts @@ -1,10 +1,13 @@ -import { readdir, readFile } from "node:fs/promises"; +import { chmod, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { parse as parseYaml } from "yaml"; +import { customAgentRoleTemplatesDirForHome } from "./cli/agentrail-home.ts"; + export const AGENT_ROLE_TEMPLATE_SCHEMA_VERSION = "agentrail.agent-role/v1"; +export const ROLE_TEMPLATE_ID_PATTERN = /^[a-z][a-z0-9-]*$/u; export interface CapabilityPreference { allow?: string[]; @@ -50,9 +53,12 @@ export interface AgentRoleTemplate { }; } +export type AgentRoleTemplateSourceKind = "built_in" | "custom"; + export interface LoadedAgentRoleTemplate { template: AgentRoleTemplate; sourcePath: string; + sourceKind: AgentRoleTemplateSourceKind; } const BUILT_IN_TEMPLATE_DIR = path.resolve( @@ -60,7 +66,6 @@ const BUILT_IN_TEMPLATE_DIR = path.resolve( "../defaults/agent-roles", ); -const ID_PATTERN = /^[a-z][a-z0-9-]*$/u; const AREA_PATTERN = /^[a-z][a-z0-9_]*$/u; const TEMPLATE_EXTENSION = ".yaml"; @@ -92,7 +97,7 @@ export function builtInAgentRoleTemplatesDir(): string { } export async function loadBuiltInAgentRoleTemplates(): Promise { - return loadAgentRoleTemplatesFromDir(BUILT_IN_TEMPLATE_DIR); + return loadAgentRoleTemplatesFromDir(BUILT_IN_TEMPLATE_DIR, "built_in"); } export async function getBuiltInAgentRoleTemplate(id: string): Promise { @@ -100,25 +105,84 @@ export async function getBuiltInAgentRoleTemplate(id: string): Promise entry.template.id === id) ?? null; } -export async function loadAgentRoleTemplatesFromDir(templateDir: string): Promise { +export async function loadAgentRoleTemplatesFromDir( + templateDir: string, + sourceKind: AgentRoleTemplateSourceKind = "custom", +): Promise { const entries = await readdir(templateDir, { withFileTypes: true }); const templateFiles = entries .filter((entry) => entry.isFile() && entry.name.endsWith(TEMPLATE_EXTENSION)) .map((entry) => path.join(templateDir, entry.name)) .sort((a, b) => a.localeCompare(b)); - const loaded = await Promise.all(templateFiles.map(async (sourcePath) => { - const yaml = await readFile(sourcePath, "utf8"); - return { - template: parseAgentRoleTemplateYaml(yaml, sourcePath), - sourcePath, - }; - })); + const loaded = await Promise.all(templateFiles.map((sourcePath) => loadAgentRoleTemplateFromFile(sourcePath, sourceKind))); assertUniqueTemplateIds(loaded); return loaded; } +export async function loadAgentRoleTemplateFromFile( + sourcePath: string, + sourceKind: AgentRoleTemplateSourceKind = "custom", +): Promise { + const absolutePath = path.resolve(sourcePath); + const yaml = await readFile(absolutePath, "utf8"); + return { + template: parseAgentRoleTemplateYaml(yaml, absolutePath), + sourcePath: absolutePath, + sourceKind, + }; +} + +export async function loadInstalledCustomAgentRoleTemplates(homePath: string): Promise { + const templateDir = customAgentRoleTemplatesDirForHome(homePath); + try { + return await loadAgentRoleTemplatesFromDir(templateDir, "custom"); + } catch (error) { + if (isNodeErrorWithCode(error, "ENOENT") && typeof error.path === "string" && path.resolve(error.path) === path.resolve(templateDir)) { + return []; + } + throw error; + } +} + +export async function resolveAgentRoleTemplate({ + homePath, + id, +}: { + homePath: string; + id: string; +}): Promise { + const builtIn = await getBuiltInAgentRoleTemplate(id); + if (builtIn) { + return builtIn; + } + const customTemplates = await loadInstalledCustomAgentRoleTemplates(homePath); + return customTemplates.find((entry) => entry.template.id === id) ?? null; +} + +export async function installCustomAgentRoleTemplate({ + homePath, + sourcePath, +}: { + homePath: string; + sourcePath: string; +}): Promise { + const loaded = await loadAgentRoleTemplateFromFile(sourcePath, "custom"); + const builtIn = await getBuiltInAgentRoleTemplate(loaded.template.id); + if (builtIn) { + throw new Error(`Custom agent role template id ${loaded.template.id} conflicts with built-in template ${builtIn.sourcePath}. Choose a different id.`); + } + + const templateDir = customAgentRoleTemplatesDirForHome(homePath); + await mkdir(templateDir, { recursive: true, mode: 0o700 }); + await chmod(templateDir, 0o700); + const installedPath = path.join(templateDir, `${loaded.template.id}${TEMPLATE_EXTENSION}`); + await writeFile(installedPath, await readFile(loaded.sourcePath, "utf8"), { mode: 0o600 }); + await chmod(installedPath, 0o600); + return await loadAgentRoleTemplateFromFile(installedPath, "custom"); +} + export function parseAgentRoleTemplateYaml(yaml: string, sourcePath = ""): AgentRoleTemplate { let parsed: unknown; try { @@ -139,7 +203,7 @@ export function validateAgentRoleTemplate(value: unknown, sourcePath = " } const id = requireString(root.id, sourcePath, "id"); - if (!ID_PATTERN.test(id)) { + if (!ROLE_TEMPLATE_ID_PATTERN.test(id)) { throw new Error(`Agent role template ${sourcePath} has invalid id ${id}. Use lowercase kebab-case.`); } @@ -315,3 +379,7 @@ function rejectUnknownKeys( function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } + +function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === code; +} diff --git a/src/cli/agent-management.ts b/src/cli/agent-management.ts index 7dad653..96ae6b7 100644 --- a/src/cli/agent-management.ts +++ b/src/cli/agent-management.ts @@ -3,11 +3,17 @@ import { spawnSync } from "node:child_process"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; +import { + installCustomAgentRoleTemplate, + loadBuiltInAgentRoleTemplates, + loadInstalledCustomAgentRoleTemplates, + resolveAgentRoleTemplate, + type LoadedAgentRoleTemplate, +} from "../agent-role-template.ts"; import { SUPPORTED_SCOPES } from "../agent-auth-store.ts"; import { DEFAULT_ROUTING_CLASSIFIER_TIMEOUT_MS } from "../routing-classifier-config.ts"; import { runDoctor } from "./doctor.ts"; import { - currentAgentEnvPathForHome, managedAgentEnvPathForHome, normalizeSetupConfigLike, operatorEnvPathForHome, @@ -41,6 +47,8 @@ export interface AgentCreateFlags { agentId?: string; name?: string; role?: string; + roleTemplateId?: string; + roleTemplateFile?: string; runner?: string; model?: string; repoAllowlist?: string[]; @@ -51,7 +59,6 @@ export interface AgentCreateFlags { scopes?: string[]; permissionPreset?: string; enableShip?: boolean; - setDefaultEnv?: boolean; configureRouting?: boolean; routingLabels?: string[]; routingProjects?: string[]; @@ -108,6 +115,7 @@ interface AgentEnvValues { AGENTRAIL_AGENT_ID?: string; AGENTRAIL_AGENT_RUNNER?: string; AGENTRAIL_AGENT_MODEL?: string; + AGENTRAIL_AGENT_ROLE_TEMPLATE_ID?: string; AGENTRAIL_MAX_CONCURRENT_TASKS?: string; AGENTRAIL_REPO_ALLOWLIST?: string; AGENTRAIL_AGENT_RECIPE_PATH?: string; @@ -123,6 +131,8 @@ interface AgentCommandInputs { agentId: string; name: string; role: string; + roleTemplateId: string | null; + roleTemplateName: string | null; repoPath: string; repoAllowlist: string[]; primaryRepoUrl: string; @@ -131,7 +141,6 @@ interface AgentCommandInputs { maxConcurrentTasks: number; instructionsPath: string; scopes: string[]; - setDefaultEnv: boolean; configureRouting: boolean; routingLabels: string[]; routingProjects: string[]; @@ -145,6 +154,7 @@ interface ProfileBody { agentId: string; displayName: string; role: string; + roleTemplateId?: string | null; status: "active" | "paused" | "disabled"; capabilityTags: string[]; ownershipTags: string[]; @@ -337,6 +347,12 @@ function parseAgentCommandArgs(argv: string[]): AgentCreateFlags { case "--role": flags.role = nextValue(argv, ++index, arg); break; + case "--role-template": + flags.roleTemplateId = nextValue(argv, ++index, arg); + break; + case "--role-template-file": + flags.roleTemplateFile = nextValue(argv, ++index, arg); + break; case "--runner": flags.runner = nextValue(argv, ++index, arg); break; @@ -375,8 +391,7 @@ function parseAgentCommandArgs(argv: string[]): AgentCreateFlags { flags.enableShip = true; break; case "--set-default-env": - flags.setDefaultEnv = true; - break; + throw new Error("--set-default-env was removed. AgentRail no longer keeps a default local agent; use --agent-id or --env-file when running agent commands."); case "--configure-routing": flags.configureRouting = true; break; @@ -405,6 +420,9 @@ function parseAgentCommandArgs(argv: string[]): AgentCreateFlags { throw new Error(`Unknown flag "${arg}".`); } } + if (flags.roleTemplateId && flags.roleTemplateFile) { + throw new Error("Use either --role-template or --role-template-file , not both."); + } return flags; } @@ -461,7 +479,7 @@ export async function runAgentCreate(argv: string[], options: RunAgentCommandOpt homePath, agentId: inputs.agentId, }); - if (existingManagedEnv.path && path.basename(existingManagedEnv.path) !== "agent.env") { + if (existingManagedEnv.path) { stderr.write(`Managed env file already exists for ${inputs.agentId}. Use \`agentrail agent update --agent-id ${inputs.agentId}\`.\n`); return 1; } @@ -477,6 +495,7 @@ export async function runAgentCreate(argv: string[], options: RunAgentCommandOpt idempotencyKey: mutationIdempotencyKey("agent-profile", inputs.agentId, { displayName: inputs.name, role: inputs.role, + roleTemplateId: inputs.roleTemplateId, status: "active", capabilityTags: inputs.capabilityTags, ownershipTags: inputs.ownershipTags, @@ -488,6 +507,7 @@ export async function runAgentCreate(argv: string[], options: RunAgentCommandOpt body: { displayName: inputs.name, role: inputs.role, + roleTemplateId: inputs.roleTemplateId, status: "active", capabilityTags: inputs.capabilityTags, ownershipTags: inputs.ownershipTags, @@ -554,6 +574,7 @@ export async function runAgentCreate(argv: string[], options: RunAgentCommandOpt agentId: inputs.agentId, runner: inputs.runner, model: inputs.model, + roleTemplateId: inputs.roleTemplateId, maxConcurrentTasks: inputs.maxConcurrentTasks, repoAllowlist: inputs.repoAllowlist, instructionsPath: inputs.instructionsPath, @@ -591,13 +612,6 @@ export async function runAgentCreate(argv: string[], options: RunAgentCommandOpt } await writeAgentEnvFileAtPath(envFilePath, envValues); - await maybeWriteDefaultEnvAlias({ - homePath, - envFilePath, - setDefaultEnv: inputs.setDefaultEnv, - agentLabel: inputs.name, - prompt, - }); await captureCliTelemetry(telemetry, "agent_created", { runner: safeTelemetryRunner(inputs.runner), @@ -624,6 +638,7 @@ export async function runAgentCreate(argv: string[], options: RunAgentCommandOpt stdout.write(`Created agent ${inputs.name} (${inputs.agentId}).\n`); stdout.write(`GitHub repo: ${inputs.primaryRepoUrl}\n`); stdout.write(`Env file: ${envFilePath}\n`); + stdout.write(`Run this agent explicitly with: agentrail agent run --agent-id ${inputs.agentId}\n`); stdout.write(`${runnerDefinitionFor(inputs.runner).signInHint}\n`); stdout.write(`${renderRunnerCommand(inputs.runner, envFilePath, inputs.repoPath)}\n`); } @@ -755,6 +770,7 @@ export async function runAgentUpdate(argv: string[], options: RunAgentCommandOpt idempotencyKey: mutationIdempotencyKey("update-agent-profile", agentId, { displayName: inputs.name, role: inputs.role, + roleTemplateId: inputs.roleTemplateId, status: currentProfile.status ?? "active", capabilityTags: inputs.capabilityTags, ownershipTags: inputs.ownershipTags, @@ -766,6 +782,7 @@ export async function runAgentUpdate(argv: string[], options: RunAgentCommandOpt body: { displayName: inputs.name, role: inputs.role, + roleTemplateId: inputs.roleTemplateId, status: currentProfile.status ?? "active", capabilityTags: inputs.capabilityTags, ownershipTags: inputs.ownershipTags, @@ -794,6 +811,7 @@ export async function runAgentUpdate(argv: string[], options: RunAgentCommandOpt agentId, runner: inputs.runner, model: inputs.model, + roleTemplateId: inputs.roleTemplateId, maxConcurrentTasks: inputs.maxConcurrentTasks, repoAllowlist: inputs.repoAllowlist, instructionsPath: inputs.instructionsPath, @@ -801,14 +819,6 @@ export async function runAgentUpdate(argv: string[], options: RunAgentCommandOpt const envFilePath = resolveManagedEnvPath(cwd, homePath, agentId, flags.envFile); await writeAgentEnvFileAtPath(envFilePath, envValues); - await maybeWriteDefaultEnvAlias({ - homePath, - envFilePath, - setDefaultEnv: Boolean(flags.setDefaultEnv || (currentEnv.path && path.basename(currentEnv.path) === "agent.env")), - agentLabel: inputs.name, - prompt: null, - }); - const doctorArgs = ["--env-file", path.relative(cwd, envFilePath), "--setup-api-key", setupApiKey]; if (!routingResult.mutated || setupConfig?.routing?.mode === "ai_assist") { doctorArgs.push("--skip-routing-check"); @@ -886,10 +896,11 @@ async function collectCreateInputs({ const model = flags.model !== undefined ? normalizeOptionalModel(flags.model) : prompt - ? normalizeOptionalModel(await prompt.input({ - message: "Runner model/profile", - defaultValue: "", - })) + ? await promptAgentModel({ + prompt, + runner, + currentModel: null, + }) : null; await describeCreateStep(prompt, "Agent name", "This is the name shown in AgentRail when this agent receives or updates work."); const name = flags.name ?? (prompt ? await prompt.input({ @@ -908,10 +919,18 @@ async function collectCreateInputs({ currentScopes: READ_WRITE_SCOPE_PRESET, }); const agentLabel = name; + const roleTemplate = await resolveCreateRoleTemplate({ + cwd, + flags, + homePath, + prompt, + agentName: agentLabel, + }); const role = await resolveCreateRole({ flags, prompt, agentName: agentLabel, + roleTemplate, }); const repoSelection = await resolvePrimaryRepo({ flags, @@ -920,23 +939,8 @@ async function collectCreateInputs({ defaultRepo: defaultAllowlist[0] ?? repo.remoteSlug ?? repo.repoPath, connectedRepos, }); - const capabilityTags = await resolveStructuredTags({ - prompt, - values: flags.capabilityTags, - message: `What should ${agentLabel} be able to help with?`, - description: `Choose the kinds of work ${agentLabel} should be best at. This helps AgentRail match task types to ${agentLabel}.`, - options: SKILL_TAG_OPTIONS, - defaultValues: ["backend", "api", "tests"], - }); - const ownershipTags = await resolveStructuredTags({ - prompt, - values: flags.ownershipTags, - message: `Which areas should ${agentLabel} own?`, - description: `Use this when ${agentLabel} should be preferred for specific parts of the product or codebase, like billing, auth, or integrations.`, - options: OWNERSHIP_TAG_OPTIONS, - required: false, - defaultValues: [], - }); + const capabilityTags = await resolveCreateCapabilityTags({ flags, prompt, agentName: agentLabel, roleTemplate }); + const ownershipTags = await resolveCreateOwnershipTags({ flags, prompt, agentName: agentLabel, roleTemplate }); const maxConcurrentTasks = await resolveCreateCapacity({ flags, prompt, @@ -951,6 +955,8 @@ async function collectCreateInputs({ agentId, name, role, + roleTemplateId: roleTemplate?.template.id ?? null, + roleTemplateName: roleTemplate?.template.name ?? null, repoPath: repoSelection.path, repoAllowlist: [repoSelection.slug], primaryRepoUrl: repoSelection.url, @@ -962,10 +968,6 @@ async function collectCreateInputs({ defaultValue: defaultInstructionsPath(homePath), })) : defaultInstructionsPath(homePath))), scopes, - setDefaultEnv: flags.setDefaultEnv ?? (prompt ? await prompt.confirm({ - message: `Use ${agentLabel} as the local agent profile for this machine?`, - defaultValue: true, - }) : false), configureRouting: flags.configureRouting ?? false, routingLabels: flags.routingLabels ?? [], routingProjects: flags.routingProjects ?? [], @@ -1005,23 +1007,33 @@ async function collectUpdateInputs({ currentProfile: ProfileBody; currentUsage: NonNullable; }) { + const homePath = resolveAgentRailHome({ cwd, explicitHome: null }); const runner = flags.runner ?? currentRunner; + const effectiveCurrentModel = runner === currentRunner ? currentModel : null; const model = flags.model !== undefined ? normalizeOptionalModel(flags.model) : prompt - ? normalizeOptionalModel(await prompt.input({ - message: "Runner model/profile", - defaultValue: currentModel ?? "", - })) - : currentModel; + ? await promptAgentModel({ + prompt, + runner, + currentModel: effectiveCurrentModel, + }) + : effectiveCurrentModel; const name = flags.name ?? (prompt ? await prompt.input({ message: "Display name", defaultValue: currentProfile.displayName ?? currentUsage.agent?.displayName ?? "Agent", }) : currentProfile.displayName ?? currentUsage.agent?.displayName ?? "Agent"); - const role = flags.role ?? (prompt ? await prompt.input({ + const roleTemplate = await resolveUpdateRoleTemplate({ + cwd, + flags, + homePath, + }); + const roleTemplateId = roleTemplate?.template.id ?? (flags.role ? null : currentProfile.roleTemplateId ?? null); + const role = flags.role ?? roleTemplate?.template.id ?? (prompt ? await prompt.input({ message: "Role", defaultValue: currentProfile.role ?? currentUsage.agent?.role ?? "coding_agent", }) : currentProfile.role ?? currentUsage.agent?.role ?? "coding_agent"); + assertRoleTemplateMatchesRole(roleTemplate, role); const scopes = await resolveAgentScopes({ flags, prompt, @@ -1039,6 +1051,8 @@ async function collectUpdateInputs({ agentId: currentProfile.agentId, name, role, + roleTemplateId, + roleTemplateName: roleTemplate?.template.name ?? null, runner, model, repoPath: currentProfile.repoAllowlist?.[0] @@ -1049,14 +1063,14 @@ async function collectUpdateInputs({ message: "Repo allowlist (comma-separated)", defaultValue: (currentProfile.repoAllowlist ?? [repo.remoteSlug ?? repo.repoPath]).join(","), }) : (currentProfile.repoAllowlist ?? [repo.remoteSlug ?? repo.repoPath]).join(",")), - capabilityTags: flags.capabilityTags?.length ? flags.capabilityTags : parseCsv(prompt ? await prompt.input({ + capabilityTags: normalizeTagList(flags.capabilityTags?.length ? flags.capabilityTags : roleTemplate ? roleTemplate.template.routing.capabilityTags : parseCsv(prompt ? await prompt.input({ message: "Capability tags (comma-separated)", defaultValue: (currentProfile.capabilityTags ?? []).join(","), - }) : (currentProfile.capabilityTags ?? []).join(",")), - ownershipTags: flags.ownershipTags?.length ? flags.ownershipTags : parseCsv(prompt ? await prompt.input({ + }) : (currentProfile.capabilityTags ?? []).join(","))), + ownershipTags: normalizeTagList(flags.ownershipTags?.length ? flags.ownershipTags : roleTemplate ? roleTemplate.template.workspace.preferredAreas : parseCsv(prompt ? await prompt.input({ message: "Ownership tags (comma-separated)", defaultValue: (currentProfile.ownershipTags ?? []).join(","), - }) : (currentProfile.ownershipTags ?? []).join(",")), + }) : (currentProfile.ownershipTags ?? []).join(","))), maxConcurrentTasks: flags.maxConcurrentTasks ?? validateCapacity(Number.parseInt(prompt ? await prompt.input({ message: "Max concurrent tasks", defaultValue: String(currentProfile.maxConcurrentTasks ?? 1), @@ -1066,7 +1080,6 @@ async function collectUpdateInputs({ defaultValue: currentInstructionsPath, }) : currentInstructionsPath)), scopes, - setDefaultEnv: flags.setDefaultEnv ?? false, configureRouting, routingLabels: flags.routingLabels ?? [], routingProjects: flags.routingProjects ?? [], @@ -1307,35 +1320,6 @@ function stableStringify(value: unknown): string { return JSON.stringify(value); } -async function maybeWriteDefaultEnvAlias({ - homePath, - envFilePath, - setDefaultEnv, - agentLabel, - prompt, -}: { - homePath: string; - envFilePath: string; - setDefaultEnv: boolean; - agentLabel: string; - prompt: PromptSession | null; -}) { - const aliasPath = currentAgentEnvPathForHome(homePath); - const content = await readFile(envFilePath, "utf8"); - let shouldWrite = setDefaultEnv; - const aliasState = await readAliasState(aliasPath, content); - shouldWrite = shouldWrite || aliasState.shouldWrite; - if (prompt && shouldWrite && aliasState.existing?.trim() && aliasState.existing !== content) { - const overwrite = await prompt.confirm({ - message: `Replace the current local agent profile on this machine with ${agentLabel}?`, - defaultValue: true, - }); - if (!overwrite) return; - } - if (!shouldWrite) return; - await writeFile(aliasPath, content, { mode: 0o600 }); -} - function resolveManagedEnvPath(cwd: string, homePath: string, agentId: string, explicitEnvFile?: string): string { return explicitEnvFile ? path.resolve(cwd, explicitEnvFile) @@ -1349,6 +1333,7 @@ function buildAgentEnvValues({ agentId, runner, model, + roleTemplateId, maxConcurrentTasks, repoAllowlist, instructionsPath, @@ -1359,6 +1344,7 @@ function buildAgentEnvValues({ agentId: string; runner: string; model: string | null; + roleTemplateId: string | null; maxConcurrentTasks: number; repoAllowlist: string[]; instructionsPath: string; @@ -1370,6 +1356,7 @@ function buildAgentEnvValues({ AGENTRAIL_AGENT_ID: agentId, AGENTRAIL_AGENT_RUNNER: runner, ...(model ? { AGENTRAIL_AGENT_MODEL: model } : {}), + ...(roleTemplateId ? { AGENTRAIL_AGENT_ROLE_TEMPLATE_ID: roleTemplateId } : {}), AGENTRAIL_MAX_CONCURRENT_TASKS: String(validateCapacity(maxConcurrentTasks)), AGENTRAIL_REPO_ALLOWLIST: repoAllowlist.join(","), AGENTRAIL_AGENT_RECIPE_PATH: instructionsPath, @@ -1405,7 +1392,6 @@ async function readAgentEnvFile({ const candidates = [ explicitEnvFile ? path.resolve(cwd, explicitEnvFile) : null, agentId ? managedAgentEnvPathForHome(homePath, agentId) : null, - currentAgentEnvPathForHome(homePath), ].filter((value): value is string => Boolean(value)); for (const filePath of candidates) { @@ -1706,6 +1692,96 @@ function describeRoleHint(role: string): string | null { return match?.hint ?? null; } +function assertRoleTemplateMatchesRole(roleTemplate: LoadedAgentRoleTemplate | null, role: string): void { + if (roleTemplate && role !== roleTemplate.template.id) { + throw new Error(`--role-template ${roleTemplate.template.id} sets role to ${roleTemplate.template.id}; remove --role or set --role ${roleTemplate.template.id}.`); + } +} + +async function promptAgentModel({ + prompt, + runner, + currentModel, +}: { + prompt: PromptSession; + runner: string; + currentModel: string | null; +}): Promise { + const runnerDefinition = runnerDefinitionFor(runner); + const runnerLabel = runnerDefinition.label; + const normalizedCurrent = normalizeOptionalModel(currentModel ?? ""); + const modelChoices = agentModelChoices(runner); + const choices: PromptChoice[] = [ + { + value: "__default__", + label: `Use ${runnerLabel} default`, + hint: "Recommended. AgentRail lets the local runner choose its configured default model.", + }, + ...modelChoices, + { + value: "__custom__", + label: "Enter a different model", + hint: "Use this if your local runner supports another model name.", + }, + ]; + if (normalizedCurrent && !choices.some((choice) => choice.value === normalizedCurrent)) { + choices.splice(1, 0, { + value: normalizedCurrent, + label: `Keep current (${normalizedCurrent})`, + hint: "Currently configured for this agent.", + }); + } + + const selected = await prompt.select({ + message: `${runnerLabel} model`, + defaultValue: normalizedCurrent ?? "__default__", + choices, + }); + if (selected === "__default__") { + return null; + } + if (selected !== "__custom__") { + return selected; + } + return normalizeOptionalModel(await prompt.input({ + message: `${runnerLabel} model name`, + defaultValue: normalizedCurrent ?? "", + })); +} + +function agentModelChoices(runner: string): PromptChoice[] { + if (runner === "codex") { + return [ + { value: "gpt-5.5", label: "GPT-5.5", hint: "Highest-capability Codex model." }, + { value: "gpt-5.4", label: "GPT-5.4", hint: "Strong default choice for coding work." }, + { value: "gpt-5.4-mini", label: "GPT-5.4 mini", hint: "Lower-cost Codex model." }, + { value: "gpt-5.3-codex", label: "GPT-5.3 Codex", hint: "Coding-optimized Codex model." }, + { value: "gpt-5.3-codex-spark", label: "GPT-5.3 Codex Spark", hint: "Fastest Codex coding model." }, + ]; + } + if (runner === "claude-code") { + return [ + { value: "sonnet", label: "Sonnet (latest)", hint: "Balanced Claude Code model alias." }, + { value: "opus", label: "Opus (latest)", hint: "Higher-capability Claude Code model alias." }, + { value: "haiku", label: "Haiku (latest)", hint: "Faster Claude Code model alias." }, + { value: "claude-opus-4-7", label: "Claude Opus 4.7", hint: "Current highest-capability Claude Code model." }, + { value: "claude-opus-4-6", label: "Claude Opus 4.6", hint: "Pinned Opus model name." }, + { value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5", hint: "Pinned Opus model name." }, + { value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", hint: "Pinned Sonnet model name." }, + { value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5", hint: "Pinned Sonnet model name." }, + { value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5", hint: "Pinned Haiku model name." }, + ]; + } + if (runner === "cursor") { + return [ + { value: "gpt-5", label: "GPT-5", hint: "Cursor Agent documented model example." }, + { value: "sonnet-4", label: "Sonnet 4", hint: "Cursor Agent documented model example." }, + { value: "sonnet-4-thinking", label: "Sonnet 4 Thinking", hint: "Cursor Agent documented reasoning model example." }, + ]; + } + return []; +} + async function showCreateReview({ prompt, inputs, @@ -1721,7 +1797,8 @@ async function showCreateReview({ body: [ `- Agent name: ${inputs.name}`, `- Runner: ${runnerDefinition.label}`, - `- Runner model/profile: ${inputs.model ?? "runner default"}`, + `- Runner model: ${inputs.model ?? `${runnerDefinition.label} default`}`, + `- Role template: ${inputs.roleTemplateName ? `${inputs.roleTemplateName} (${inputs.roleTemplateId})` : "Custom"}`, `- Role: ${describeRole(inputs.role)}`, ...(describeRoleHint(inputs.role) ? [` ${describeRoleHint(inputs.role)}`] : []), `- Permissions: ${inputs.scopes.join(", ")}`, @@ -1771,6 +1848,192 @@ async function resolvePrimaryRepo({ } } +async function resolveCreateRoleTemplate({ + cwd, + flags, + homePath, + prompt, + agentName, +}: { + cwd: string; + flags: AgentCreateFlags; + homePath: string; + prompt: PromptSession | null; + agentName: string; +}): Promise { + if (flags.roleTemplateFile) { + return await installCustomAgentRoleTemplate({ + homePath, + sourcePath: path.resolve(cwd, flags.roleTemplateFile), + }); + } + if (flags.roleTemplateId) { + return await resolveRoleTemplateById({ homePath, id: flags.roleTemplateId }); + } + if (!prompt) { + return null; + } + + const builtInTemplates = await loadBuiltInAgentRoleTemplates(); + const builtInIds = new Set(builtInTemplates.map((entry) => entry.template.id)); + const customTemplates = (await loadInstalledCustomAgentRoleTemplates(homePath)) + .filter((entry) => !builtInIds.has(entry.template.id)); + const templates = [...builtInTemplates, ...customTemplates]; + await describeCreateStep( + prompt, + "Agent role", + `Choose ${agentName}'s operating profile. AgentRail uses this to set routing capabilities and default behavior for this agent.`, + ); + await prompt.note({ + title: "Built-in role templates", + body: builtInTemplates.map(({ template }) => `- ${template.name}: ${template.description}`).join("\n"), + }); + if (customTemplates.length > 0) { + await prompt.note({ + title: "Custom role templates", + body: customTemplates.map(({ template }) => `- ${template.name}: ${template.description}`).join("\n"), + }); + } + const selected = await prompt.select({ + message: `What kind of agent is ${agentName}?`, + defaultValue: "backend-api", + choices: [ + ...builtInTemplates.map(({ template }) => ({ + value: template.id, + label: template.name, + hint: template.description, + })), + ...customTemplates.map(({ template }) => ({ + value: template.id, + label: `${template.name} (custom)`, + hint: template.description, + })), + { + value: "__custom_file__", + label: "Custom YAML file", + hint: "Install a custom role template from a local YAML file.", + }, + { + value: "__manual__", + label: "Manual custom role", + hint: "Answer the role and capability prompts yourself.", + }, + ], + }); + if (selected === "__manual__") { + return null; + } + if (selected === "__custom_file__") { + const sourcePath = await prompt.input({ + message: "Custom role template YAML file", + defaultValue: "", + }); + return await installCustomAgentRoleTemplate({ + homePath, + sourcePath: path.resolve(cwd, sourcePath), + }); + } + const template = templates.find((entry) => entry.template.id === selected); + if (!template) { + throw new Error(`Unknown role template "${selected}".`); + } + return template; +} + +async function resolveUpdateRoleTemplate({ + cwd, + flags, + homePath, +}: { + cwd: string; + flags: AgentUpdateFlags; + homePath: string; +}): Promise { + if (flags.roleTemplateFile) { + return await installCustomAgentRoleTemplate({ + homePath, + sourcePath: path.resolve(cwd, flags.roleTemplateFile), + }); + } + if (flags.roleTemplateId) { + return await resolveRoleTemplateById({ homePath, id: flags.roleTemplateId }); + } + return null; +} + +async function resolveRoleTemplateById({ + homePath, + id, +}: { + homePath: string; + id: string; +}): Promise { + const template = await resolveAgentRoleTemplate({ homePath, id }); + if (!template) { + const knownIds = [ + ...(await loadBuiltInAgentRoleTemplates()).map((entry) => entry.template.id), + ...(await loadInstalledCustomAgentRoleTemplates(homePath)).map((entry) => entry.template.id), + ]; + throw new Error(`Unknown role template "${id}". Use one of: ${knownIds.join(", ")}.`); + } + return template; +} + +async function resolveCreateCapabilityTags({ + flags, + prompt, + agentName, + roleTemplate, +}: { + flags: AgentCreateFlags; + prompt: PromptSession | null; + agentName: string; + roleTemplate: LoadedAgentRoleTemplate | null; +}): Promise { + if (flags.capabilityTags?.length) { + return normalizeTagList(flags.capabilityTags); + } + if (roleTemplate) { + return normalizeTagList(roleTemplate.template.routing.capabilityTags); + } + return await resolveStructuredTags({ + prompt, + values: flags.capabilityTags, + message: `What should ${agentName} be able to help with?`, + description: `Choose the kinds of work ${agentName} should be best at. This helps AgentRail match task types to ${agentName}.`, + options: SKILL_TAG_OPTIONS, + defaultValues: ["backend", "api", "tests"], + }); +} + +async function resolveCreateOwnershipTags({ + flags, + prompt, + agentName, + roleTemplate, +}: { + flags: AgentCreateFlags; + prompt: PromptSession | null; + agentName: string; + roleTemplate: LoadedAgentRoleTemplate | null; +}): Promise { + if (flags.ownershipTags?.length) { + return normalizeTagList(flags.ownershipTags); + } + if (roleTemplate) { + return normalizeTagList(roleTemplate.template.workspace.preferredAreas); + } + return await resolveStructuredTags({ + prompt, + values: flags.ownershipTags, + message: `Which areas should ${agentName} own?`, + description: `Use this when ${agentName} should be preferred for specific parts of the product or codebase, like billing, auth, or integrations.`, + options: OWNERSHIP_TAG_OPTIONS, + required: false, + defaultValues: [], + }); +} + async function resolveStructuredTags({ prompt, values, @@ -1818,12 +2081,18 @@ async function resolveCreateRole({ flags, prompt, agentName, + roleTemplate, }: { flags: AgentCreateFlags; prompt: PromptSession | null; agentName: string; + roleTemplate: LoadedAgentRoleTemplate | null; }): Promise { - if (flags.role) return flags.role; + if (flags.role) { + assertRoleTemplateMatchesRole(roleTemplate, flags.role); + return flags.role; + } + if (roleTemplate) return roleTemplate.template.id; if (!prompt) return "coding_agent"; await describeCreateStep( @@ -2055,23 +2324,6 @@ function renderRunnerCommand(runner: string, envFilePath: string, repoPath: stri return `source ${quotedEnvFilePath} && cd ${quotedRepoPath} && ${runner}`; } -async function readAliasState(aliasPath: string, content: string): Promise<{ existing: string | null; shouldWrite: boolean }> { - try { - const existing = await readFile(aliasPath, "utf8"); - if (!existing.trim()) { - return { existing, shouldWrite: true }; - } - const existingAgentId = parseEnvFile(existing).AGENTRAIL_AGENT_ID; - const nextAgentId = parseEnvFile(content).AGENTRAIL_AGENT_ID; - return { - existing, - shouldWrite: Boolean(existingAgentId) && existingAgentId === nextAgentId, - }; - } catch { - return { existing: null, shouldWrite: true }; - } -} - function validateCapacity(value: number): number { if (!Number.isFinite(value)) { return 1; @@ -2094,8 +2346,11 @@ function renderAgentCreateUsage(): string { " --agent-id ", " --env-file ", " --name ", + " --role-template ", + " --role-template-file ", + " --role ", " --runner ", - " --model ", + " --model ", " --permission-preset ", " --scopes ", " --repo-allowlist ", @@ -2117,7 +2372,11 @@ function renderAgentUpdateUsage(): string { " --env-file ", " --api-key-id ", " --name ", + " --role-template ", + " --role-template-file ", " --role ", + " --runner ", + " --model ", " --permission-preset ", " --scopes ", " --configure-routing", diff --git a/src/cli/agent-runner.ts b/src/cli/agent-runner.ts index ea6592c..0560aa8 100644 --- a/src/cli/agent-runner.ts +++ b/src/cli/agent-runner.ts @@ -1,13 +1,21 @@ import crypto from "node:crypto"; import { spawn, spawnSync } from "node:child_process"; -import { appendFile, copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import { appendFile, copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { AgentRunStore, createRunContextToken, hashRunContextToken, type AgentRunRecord, type AgentRunReportInput, type AgentRunReportStatus, type AgentRunStatus, type AgentRunUserAction } from "../agent-run-store.ts"; +import { resolveAgentRoleTemplate, ROLE_TEMPLATE_ID_PATTERN } from "../agent-role-template.ts"; import { parseSimpleEnv } from "../env-file.ts"; import { buildManagedRunContextEnvelope } from "../managed-run-context.ts"; +import { + buildManagedRunRoleBrief, + buildMissingRoleTemplateBrief, + renderManagedRunRoleBriefForPrompt, + type ManagedRunRoleBrief, +} from "../managed-run-role-brief.ts"; import { resolveManagedRunReclaimPolicy, type ManagedRunReclaimPolicy } from "../managed-run-reclaim-policy.ts"; import { buildManagedRunQueuePlan, isSetupVerificationTask, type ManagedRunQueueEntry, type ManagedRunQueuePlan } from "../managed-run-task-queue.ts"; +import { loadRepoMapForRepo } from "../repo-map.ts"; import { compileRunnerExecutionPlan, hardenProtectedInstructionFiles, @@ -53,6 +61,7 @@ interface AgentEnvValues { AGENTRAIL_AGENT_ID?: string; AGENTRAIL_AGENT_RUNNER?: string; AGENTRAIL_AGENT_MODEL?: string; + AGENTRAIL_AGENT_ROLE_TEMPLATE_ID?: string; AGENTRAIL_RUN_ID?: string; AGENTRAIL_MAX_CONCURRENT_TASKS?: string; AGENTRAIL_REPO_ALLOWLIST?: string; @@ -296,8 +305,17 @@ export async function runAgentRun(argv: string[], options: RunAgentRunnerOptions options.stdout.write(renderAgentRunUsage()); return 0; } - const homePath = resolveAgentRailHome({ cwd: options.cwd, explicitHome: null }); + if (!flags.agentId && !flags.envFile) { + const legacyEnvFile = currentAgentEnvPathForHome(homePath); + if (await fileExists(legacyEnvFile)) { + flags.envFile = legacyEnvFile; + } else { + options.stderr.write("agentrail agent run requires --agent-id or --env-file . AgentRail does not use a default local agent.\n"); + return 1; + } + } + const setupConfig = await readSetupConfigFromHome(homePath); const telemetry = createRunnerTelemetry(homePath, options.telemetry); const envState = await readAgentEnvFile({ @@ -553,9 +571,10 @@ async function executeAgentRun({ const apiKey = envState.values.AGENTRAIL_API_KEY ?? null; const runner = envState.values.AGENTRAIL_AGENT_RUNNER ?? "codex"; const model = normalizeOptionalModel(envState.values.AGENTRAIL_AGENT_MODEL); + const roleTemplateId = normalizeOptionalRoleTemplateId(envState.values.AGENTRAIL_AGENT_ROLE_TEMPLATE_ID); const maxRuns = flags.once ? 1 : flags.maxRuns; if (!agentId || !apiKey) { - stderr.write("agentrail agent run requires AGENTRAIL_AGENT_ID and AGENTRAIL_API_KEY in the agent env file.\n"); + stderr.write("agentrail agent run could not load AGENTRAIL_AGENT_ID and AGENTRAIL_API_KEY for the selected agent. Use --agent-id for ~/.agentrail/agents/.env or --env-file for a custom env file.\n"); return 1; } @@ -674,6 +693,7 @@ async function executeAgentRun({ agentId, runner, model, + roleTemplateId, repo, worktreeRoot, homePath, @@ -808,6 +828,7 @@ async function executeSingleTaskRun({ agentId, runner, model, + roleTemplateId, repo, worktreeRoot, homePath, @@ -828,6 +849,7 @@ async function executeSingleTaskRun({ agentId: string; runner: string; model: string | null; + roleTemplateId: string | null; repo: ConnectedRepo; worktreeRoot: string; homePath: string; @@ -861,6 +883,11 @@ async function executeSingleTaskRun({ const contextPath = path.join(runDir, "context.json"); const runContextToken = createRunContextToken(); const resumeContext = findLatestTaskUserAction(runStore, task.id); + const roleBrief = await resolveManagedRunRoleBrief({ + homePath, + repo, + roleTemplateId, + }); const reviewFeedback = await fetchReviewFeedbackForPrompt({ baseUrl, apiKey, @@ -880,6 +907,7 @@ async function executeSingleTaskRun({ branchName, handoffPath: runnerHandoffPath, contextPath, + roleBrief, ciStatus, reviewFeedback, resumeContext, @@ -901,6 +929,7 @@ async function executeSingleTaskRun({ AGENTRAIL_RUN_CONTEXT_PATH: contextPath, AGENTRAIL_AGENT_RUNNER: runner, ...(model ? { AGENTRAIL_AGENT_MODEL: model } : {}), + ...(roleTemplateId ? { AGENTRAIL_AGENT_ROLE_TEMPLATE_ID: roleTemplateId } : {}), AGENTRAIL_AGENT_RECIPE_PATH: managedRecipePath, AGENTRAIL_HOME: homePath, AGENTRAIL_TASK_ID: task.id, @@ -975,6 +1004,7 @@ async function executeSingleTaskRun({ contextPath, run: running, task, + roleBrief, }); const policyValidation = validateRunnerPolicyPlan(runnerPlan); if (!policyValidation.ok) { @@ -1323,12 +1353,14 @@ async function writeManagedRunContextSnapshot({ contextPath, run, task, + roleBrief, }: { contextPath: string; run: AgentRunRecord; task: TaskDetail; + roleBrief: ManagedRunRoleBrief | null; }): Promise { - await writeFile(contextPath, `${JSON.stringify(buildManagedRunContextEnvelope({ run, taskBody: task }), null, 2)}\n`, "utf8"); + await writeFile(contextPath, `${JSON.stringify(buildManagedRunContextEnvelope({ run, taskBody: task, roleBrief }), null, 2)}\n`, "utf8"); } function defaultManagedRunResultTemplate(): Record { @@ -1839,6 +1871,18 @@ function isNodeErrorWithCode(error: unknown, code: string): boolean { return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === code; } +async function fileExists(filePath: string): Promise { + try { + await stat(filePath); + return true; + } catch (error) { + if (isNodeErrorWithCode(error, "ENOENT")) { + return false; + } + throw error; + } +} + async function readRunnerHandoff({ handoffPath, worktreePath, @@ -2592,6 +2636,43 @@ function resolveRepoForAgent({ }; } +async function resolveManagedRunRoleBrief({ + homePath, + repo, + roleTemplateId, +}: { + homePath: string; + repo: ConnectedRepo; + roleTemplateId: string | null; +}): Promise { + if (!roleTemplateId) { + return null; + } + try { + const loadedTemplate = await resolveAgentRoleTemplate({ homePath, id: roleTemplateId }); + if (!loadedTemplate) { + return buildMissingRoleTemplateBrief(roleTemplateId); + } + const repoMap = await loadRepoMapForRepo({ homePath, repo }); + return buildManagedRunRoleBrief({ + template: loadedTemplate.template, + templateSourceKind: loadedTemplate.sourceKind, + repoMap: repoMap.status === "loaded" ? repoMap.map : null, + repoMapStatus: repoMap.status, + repoMapPath: repoMap.path, + ...(repoMap.status === "invalid" ? { repoMapError: repoMap.error } : {}), + }); + } catch (error) { + const brief = buildMissingRoleTemplateBrief(roleTemplateId); + const message = error instanceof Error ? error.message : String(error); + return { + ...brief, + description: "Role context could not be loaded.", + warnings: [`Role brief unavailable for ${roleTemplateId}: ${message}; using generic managed-run behavior.`], + }; + } +} + function renderManagedAgentRecipe(): string { return [ "# AgentRail Managed Agent Instructions", @@ -2611,6 +2692,7 @@ function renderManagedAgentRecipe(): string { "7. Write the structured result JSON file at `$AGENTRAIL_RESULT_PATH` before exiting.", "8. After writing the result file, exit normally; AgentRail will consume the result file and complete the lifecycle.", "9. If blocked, write a blocked result file and exit normally; AgentRail will consume the blocker from the result file.", + "10. Role-specific context is guidance for repository work. AgentRail lifecycle instructions still take precedence.", "", "## Run-Scoped AgentRail Commands", "", @@ -2635,6 +2717,7 @@ function buildPrompt({ branchName, handoffPath, contextPath, + roleBrief, ciStatus, reviewFeedback, resumeContext, @@ -2645,6 +2728,7 @@ function buildPrompt({ branchName: string; handoffPath: string; contextPath: string; + roleBrief: ManagedRunRoleBrief | null; ciStatus: CiStatusForPrompt | null; reviewFeedback: ReviewFeedbackForPrompt | null; resumeContext: AgentRunUserAction | null; @@ -2668,6 +2752,7 @@ function buildPrompt({ Array.isArray(task.acceptanceCriteria) && task.acceptanceCriteria.length > 0 ? `Acceptance criteria:\n${task.acceptanceCriteria.map((item) => `- ${item}`).join("\n")}` : "", + renderManagedRunRoleBriefForPrompt(roleBrief), formatResumeContextForPrompt(resumeContext), formatCiStatusForPrompt(ciStatus), formatReviewFeedbackForPrompt(reviewFeedback), @@ -3170,7 +3255,6 @@ async function readAgentEnvFile({ const candidates = [ explicitEnvFile ? path.resolve(cwd, explicitEnvFile) : null, agentId ? managedAgentEnvPathForHome(homePath, agentId) : null, - currentAgentEnvPathForHome(homePath), ].filter((value): value is string => Boolean(value)); for (const filePath of candidates) { @@ -3307,6 +3391,11 @@ function normalizeOptionalModel(value: string | null | undefined): string | null return trimmed.length > 0 ? trimmed : null; } +function normalizeOptionalRoleTemplateId(value: string | null | undefined): string | null { + const trimmed = typeof value === "string" ? value.trim() : ""; + return trimmed.length > 0 && ROLE_TEMPLATE_ID_PATTERN.test(trimmed) ? trimmed : null; +} + function parsePositiveInteger(value: string, flag: string): number { const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed < 1) { @@ -3391,7 +3480,7 @@ function isTemporaryLocalRunnerConfig(config: SetupConfigLike | null): config is function renderAgentRunUsage(): string { return [ "Usage:", - " agentrail agent run [flags]", + " agentrail agent run (--agent-id | --env-file ) [flags]", "", "Flags:", " --env-file ", diff --git a/src/cli/agentrail-home.ts b/src/cli/agentrail-home.ts index a128cc7..6b9deee 100644 --- a/src/cli/agentrail-home.ts +++ b/src/cli/agentrail-home.ts @@ -158,6 +158,10 @@ export function recipePathForHome(homePath: string): string { return path.join(homePath, "agent-recipes.md"); } +export function customAgentRoleTemplatesDirForHome(homePath: string): string { + return path.join(homePath, "agent-role-templates"); +} + export async function readSetupConfigFromHome(homePath: string): Promise { try { const content = await readFile(configPathForHome(homePath), "utf8"); diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index ff88506..09176b1 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -3,8 +3,9 @@ import path from "node:path"; import { spawnSync } from "node:child_process"; import { + type ConnectedRepo, configPathForHome, - currentAgentEnvPathForHome, + managedAgentEnvPathForHome, normalizeSetupConfigLike, operatorEnvPathForHome, primaryRepoFromConfig, @@ -17,6 +18,8 @@ import { type CliTelemetryHook, } from "./telemetry-hook.ts"; import { compileRunnerExecutionPlan, validateRunnerPolicyPlan } from "../runner-execution-policy.ts"; +import { resolveAgentRoleTemplate } from "../agent-role-template.ts"; +import { loadRepoMapForRepo } from "../repo-map.ts"; interface Writer { write(chunk: string | Uint8Array): boolean; @@ -69,8 +72,9 @@ interface SetupConfigLike { } interface DoctorCheck { - id: "health" | "auth" | "profile" | "routing" | "ai_routing" | "runner_policy" | "assigned_task_visibility"; + id: "health" | "auth" | "profile" | "routing" | "ai_routing" | "runner_policy" | "repo_map" | "assigned_task_visibility"; ok: boolean; + status?: "pass" | "warn" | "fail"; summary: string; } @@ -82,6 +86,7 @@ interface DoctorInputs { setupApiKey: string | null; setupConfig: SetupConfigLike | null; agentRunner: string; + homePath: string; } interface JsonResponse { @@ -89,10 +94,21 @@ interface JsonResponse { json: T | null; } +type ProfileRoleTemplateLookup = + | { + status: "known"; + roleTemplateId: string | null; + } + | { + status: "unknown"; + reason: string; + }; + interface AgentProfileResponse { data?: { status?: string; repoAllowlist?: string[]; + roleTemplateId?: string | null; }; } @@ -190,7 +206,7 @@ export async function runDoctor( stdout.write( [ "Usage:", - " agentrail doctor [--base-url ] [--api-key ] [--agent-id ] [--repo ] [--setup-api-key ] [--env-file ] [--skip-routing-check]", + " agentrail doctor --agent-id [--base-url ] [--api-key ] [--repo ] [--setup-api-key ] [--env-file ] [--skip-routing-check]", "", ].join("\n"), ); @@ -253,6 +269,7 @@ async function resolveDoctorInputs({ cwd, homePath, explicitEnvFile: flags.envFile, + agentId: flags.agentId, }); const operatorEnvValues = await readOperatorEnvFile(homePath); @@ -290,7 +307,7 @@ async function resolveDoctorInputs({ if (!agentId) missing.push("AGENTRAIL_AGENT_ID"); if (missing.length > 0) { return { - error: `agentrail doctor requires ${missing.join(", ")} in flags, process env, or ~/.agentrail/agent.env.`, + error: `agentrail doctor requires an explicit agent identity: use --agent-id , --env-file , or AGENTRAIL_AGENT_ID plus AGENTRAIL_API_KEY in the process environment. Missing ${missing.join(", ")}.`, }; } @@ -302,6 +319,7 @@ async function resolveDoctorInputs({ setupApiKey, setupConfig: config, agentRunner, + homePath, }; } @@ -424,6 +442,15 @@ async function runDoctorChecks(inputs: DoctorInputs, flags: DoctorFlags): Promis }); failedCheckId = failedCheckId ?? (runnerPolicyReadiness.ok ? null : "runner_policy"); + const repoMapReadiness = await checkRepoMapReadiness(inputs, resolveProfileRoleTemplateLookup({ inputs, profile })); + checks.push({ + id: "repo_map", + ok: repoMapReadiness.ok, + status: repoMapReadiness.status, + summary: repoMapReadiness.summary, + }); + failedCheckId = failedCheckId ?? (repoMapReadiness.ok ? null : "repo_map"); + const setupTaskVisibility = await findVisibleSetupTask(inputs); visibleTaskIdentifier = setupTaskVisibility.visibleTaskIdentifier; const assignedTaskVisibilityOk = setupTaskVisibility.ok; @@ -467,7 +494,7 @@ function renderDoctorReport(report: { const lines = [ report.ok ? "AgentRail doctor passed." : "AgentRail doctor failed.", "", - ...report.checks.map((check) => `- ${check.ok ? "PASS" : "FAIL"} ${check.id}: ${check.summary}`), + ...report.checks.map((check) => `- ${doctorCheckLabel(check)} ${check.id}: ${check.summary}`), ]; if (!report.ok) { @@ -496,6 +523,12 @@ function renderDoctorReport(report: { return `${lines.join("\n")}\n`; } +function doctorCheckLabel(check: DoctorCheck): "PASS" | "WARN" | "FAIL" { + if (check.status === "warn") return "WARN"; + if (check.status === "fail") return "FAIL"; + return check.ok ? "PASS" : "FAIL"; +} + function ruleMatchesDoctorExpectation({ rule, agentId, @@ -709,7 +742,7 @@ function checkClassifierReadiness(config: SetupConfigLike | null): { ok: boolean : "runner default"; return { ok: true, - summary: `AI routing can use "${runner}" via "${executable}" with model/profile ${model}.`, + summary: `AI routing can use "${runner}" via "${executable}" with model ${model}.`, }; } @@ -751,6 +784,117 @@ function checkRunnerPolicyReadiness(inputs: DoctorInputs): { ok: boolean; summar }; } +async function checkRepoMapReadiness( + inputs: DoctorInputs, + profileRoleTemplate: ProfileRoleTemplateLookup, +): Promise<{ ok: boolean; status: "pass" | "warn" | "fail"; summary: string }> { + const repo = findExpectedRepo(inputs.setupConfig, inputs.expectedRepo); + if (!repo) { + return { + ok: true, + status: "warn", + summary: "No connected repo config is available, so doctor could not check a repo map.", + }; + } + + const repoMap = await loadRepoMapForRepo({ + homePath: inputs.homePath, + repo, + }); + if (repoMap.status === "missing") { + return { + ok: true, + status: "warn", + summary: `No repo map found for ${repo.slug}. Re-run \`agentrail init\` or add the repo again to create ${repoMap.path}.`, + }; + } + if (repoMap.status === "invalid") { + return { + ok: false, + status: "fail", + summary: `Repo map ${repoMap.path} is invalid: ${repoMap.error}`, + }; + } + + if (profileRoleTemplate.status === "unknown") { + return { + ok: true, + status: "warn", + summary: `Repo map ${repoMap.path} is valid, but doctor could not verify whether this agent uses a role template: ${profileRoleTemplate.reason}`, + }; + } + + if (!profileRoleTemplate.roleTemplateId) { + return { + ok: true, + status: "pass", + summary: `Repo map ${repoMap.path} is valid. The selected agent does not use a role template.`, + }; + } + + const roleTemplateId = profileRoleTemplate.roleTemplateId; + const roleTemplate = await resolveAgentRoleTemplate({ + homePath: inputs.homePath, + id: roleTemplateId, + }); + if (!roleTemplate) { + return { + ok: true, + status: "warn", + summary: `Repo map ${repoMap.path} is valid, but role template ${roleTemplateId} is not installed locally.`, + }; + } + + const preferredAreas = roleTemplate.template.workspace.preferredAreas; + const missingAreas = preferredAreas.filter((area) => !repoMap.map.areas[area]?.paths.length); + if (missingAreas.length > 0) { + return { + ok: true, + status: "warn", + summary: `Repo map ${repoMap.path} is valid, but ${roleTemplate.template.id} has unmapped preferred areas: ${missingAreas.join(", ")}.`, + }; + } + + return { + ok: true, + status: "pass", + summary: `Repo map ${repoMap.path} covers preferred areas for ${roleTemplate.template.id}.`, + }; +} + +function resolveProfileRoleTemplateLookup({ + inputs, + profile, +}: { + inputs: DoctorInputs; + profile: JsonResponse | null; +}): ProfileRoleTemplateLookup { + if (!inputs.setupApiKey) { + return { + status: "unknown", + reason: "Set AGENTRAIL_OPERATOR_KEY to verify routing profile state.", + }; + } + if (profile?.status === 200 && profile.json?.data) { + return { + status: "known", + roleTemplateId: profile.json.data.roleTemplateId ?? null, + }; + } + return { + status: "unknown", + reason: `Agent profile lookup returned HTTP ${profile?.status ?? "unknown"}.`, + }; +} + +function findExpectedRepo(config: SetupConfigLike | null, expectedRepo: string | null): ConnectedRepo | null { + const repos = config?.repos ?? []; + if (expectedRepo) { + return repos.find((repo) => repo.slug === expectedRepo) ?? null; + } + return repos[0] ?? null; +} + function classifierExecutableForRunner(runner: string): string | null { if (runner === "codex") return "codex"; if (runner === "claude-code") return "claude"; @@ -767,13 +911,15 @@ async function readAgentEnvFiles({ cwd, homePath, explicitEnvFile, + agentId, }: { cwd: string; homePath: string; explicitEnvFile?: string; + agentId?: string; }): Promise> { const candidates = [ - currentAgentEnvPathForHome(homePath), + agentId ? managedAgentEnvPathForHome(homePath, agentId) : null, explicitEnvFile ? path.resolve(cwd, explicitEnvFile) : null, ].filter((value): value is string => Boolean(value)); diff --git a/src/cli/global-management.ts b/src/cli/global-management.ts index cf73c82..e91b7c0 100644 --- a/src/cli/global-management.ts +++ b/src/cli/global-management.ts @@ -12,6 +12,7 @@ import { type ConnectedRepo, type SetupConfigLike, } from "./agentrail-home.ts"; +import { ensureRepoMapForRepo, repoMapPathForHome } from "../repo-map.ts"; interface Writer { write(chunk: string | Uint8Array): boolean; @@ -64,6 +65,7 @@ export async function runRepoCommand(argv: string[], { stdout.write(`${index + 1}. ${repo.slug} — ${toGitHubUrl(repo.slug)}\n`); stdout.write(` path: ${repo.path}\n`); stdout.write(` default branch: ${repo.defaultBranch}\n`); + stdout.write(` repo map: ${repoMapPathForHome(homePath, repo.slug)}\n`); if (repo.circleciProjectSlug) { stdout.write(` CircleCI project: ${repo.circleciProjectSlug}\n`); stdout.write(` CircleCI trigger: ${formatCircleCiTrigger(repo)}\n`); @@ -95,6 +97,12 @@ export async function runRepoCommand(argv: string[], { }; await writeConfig(homePath, nextConfig); stdout.write(`Connected repo ${nextRepo.slug}.\n`); + const repoMap = await ensureRepoMapForRepo({ homePath, repo: nextRepo }); + if (repoMap.status === "created") { + stdout.write(`Wrote repo map ${repoMap.path}${repoMap.reason ? ` (${repoMap.reason})` : ""}.\n`); + } else if (repoMap.status === "skipped") { + stdout.write(`Skipped repo map: ${repoMap.reason}.\n`); + } return 0; } diff --git a/src/cli/index.ts b/src/cli/index.ts index 66c8abe..0e6c13d 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -588,7 +588,6 @@ async function finalizeInit({ operatorBootstrap.operatorKey, "--base-url", baseUrl, - "--set-default-env", ], { cwd: primaryRepo?.path ?? cwd, stdinIsTTY, @@ -815,7 +814,7 @@ function writeUsage(output: Writer) { output.write([ "Usage:", " agentrail init [flags]", - " agentrail doctor [flags]", + " agentrail doctor [--agent-id ] [flags]", " agentrail server start", " agentrail repo add [flags]", " agentrail repo list", @@ -838,7 +837,7 @@ function writeUsage(output: Writer) { " agentrail run actions [--json]", " agentrail agent create [flags]", " agentrail agent update [flags]", - " agentrail agent run [--once] [--agent-id ] [--max-runs ]", + " agentrail agent run --agent-id [--once] [--max-runs ]", " agentrail agent report --status --summary ", " agentrail agent status [--agent-id ] [--json]", " agentrail task source repair --task-id --file [flags]", diff --git a/src/cli/run-context.ts b/src/cli/run-context.ts index e9eb26a..6e72931 100644 --- a/src/cli/run-context.ts +++ b/src/cli/run-context.ts @@ -69,6 +69,16 @@ async function runCurrent(argv: string[], options: RunContextOptions): Promise ${area.paths.join(", ") || "not mapped"}\n`); + } + for (const warning of roleBrief.warnings.slice(0, 2)) { + options.stdout.write(`Role warning: ${warning}\n`); + } + } options.stdout.write(`Available actions: ${context.value.availableActions.join(", ") || "none"}\n`); for (const action of nextActions) { options.stdout.write(`Next: ${action.label}\n`); diff --git a/src/cli/setup-config.ts b/src/cli/setup-config.ts index 3b5b933..e2037ad 100644 --- a/src/cli/setup-config.ts +++ b/src/cli/setup-config.ts @@ -377,6 +377,7 @@ export function buildSetupPlan(config: SetupConfig): string[] { "Write ~/.agentrail/server.env", "Write ~/.agentrail/README.md", "Write ~/.agentrail/operator.env", + "Write ~/.agentrail/repo-maps/.yaml when missing", ]; if (config.exports.markdown.enabled) { diff --git a/src/cli/setup-files.ts b/src/cli/setup-files.ts index 05d467d..0a40681 100644 --- a/src/cli/setup-files.ts +++ b/src/cli/setup-files.ts @@ -2,8 +2,9 @@ import path from "node:path"; import { mkdir, rm, writeFile } from "node:fs/promises"; import type { SetupConfig } from "./setup-config.ts"; -import { agentEnvExamplePathForHome, configPathForHome, currentAgentEnvPathForHome, recipePathForHome, serverEnvPathForHome } from "./agentrail-home.ts"; +import { agentEnvExamplePathForHome, configPathForHome, recipePathForHome, serverEnvPathForHome } from "./agentrail-home.ts"; import { codeReviewPolicyFlagValue, describeCodeReviewPolicy, normalizeCodeReviewPolicy } from "../code-review-policy.ts"; +import { ensureRepoMapForRepo } from "../repo-map.ts"; export interface WriteSetupFilesOptions { homePath?: string; @@ -49,6 +50,13 @@ export async function writeSetupFiles({ await writeFile(readmePath, renderSetupReadme(config), "utf8"); await writeFile(recipePath, renderDefaultRecipe(), "utf8"); + for (const repo of config.repos) { + const repoMap = await ensureRepoMapForRepo({ homePath: resolvedHomePath, repo }); + if (repoMap.status === "created") { + writtenPaths.push(repoMap.path); + } + } + return { writtenPaths }; } @@ -83,7 +91,7 @@ function renderAgentEnvExample(config: SetupConfig, homePath: string): string { return [ "# Generated by `agentrail init`.", "# `agentrail agent create` writes `~/.agentrail/agents/.env` later with mode 0600.", - "# It may also refresh `~/.agentrail/agent.env` as the default local alias for the current agent.", + "# AgentRail does not use a default local agent; pass --agent-id or --env-file when running agent commands.", "# Provider credentials stay blank here because init avoids capturing live secrets in terminal history.", `AGENTRAIL_BASE_URL=${config.server.baseUrl}`, "AGENTRAIL_API_KEY=", @@ -110,7 +118,7 @@ function renderSetupReadme(config: SetupConfig): string { "- `server.env`: local server/runtime env defaults written by setup.", "- `agent.env.example`: placeholder environment file until `agentrail agent create` writes `agents/.env`.", "- `agents/`: generated per-agent env files.", - "- `agent.env`: current-agent alias used by commands like `agentrail doctor`.", + "- `repo-maps/`: editable repo area maps used by role templates.", "- `agent-recipes.md`: default instructions file for new agents.", "- `README.md`: setup notes and next commands.", "", @@ -151,13 +159,15 @@ function renderSetupReadme(config: SetupConfig): string { config.routing.mode === "ai_assist" ? `- Local AI routing classifier timeout: ${Math.round(config.routing.classifier.timeoutMs / 1000)} seconds by default. Slow local runners can raise \`routing.classifier.timeoutMs\` up to 600 seconds in \`config.json\`.` : "", + "- AgentRail writes editable repo maps under `~/.agentrail/repo-maps/` so role templates can refer to abstract areas like `frontend_source`, `tests`, or `ci_configuration` without hard-coding paths.", + "- `agentrail doctor --agent-id ` warns when the selected agent's role template references areas that are missing from the repo map.", "", - "## Local agent access", + "## Local agent safety", "", `- Local agents use the \`${config.runnerPolicy.preset.replace(/_/gu, "-")}\` runner policy by default.`, "- AgentRail strips broad provider credentials from managed child runners and keeps pull request creation, shipping, and rollback inside AgentRail unless you change the policy.", "- Managed local runs use stale-run reclaim and restart-loop protection; repeated runner infrastructure failures are paused for user action instead of retried forever.", - "- `agentrail doctor` reports whether the selected local runner can enforce this policy.", + "- `agentrail doctor --agent-id ` reports whether the selected local runner can enforce this policy.", "", "## Telemetry", "", @@ -167,12 +177,12 @@ function renderSetupReadme(config: SetupConfig): string { "", "1. Run `agentrail agent create` to choose a runner, choose permissions, and create another local agent.", " If the local API is not already running, AgentRail starts a temporary local API to create the agent and then shuts it down.", - " AgentRail writes the runner connection file for you and can remember this as the local agent profile for this machine.", + " AgentRail writes a separate runner connection file for each agent under `~/.agentrail/agents/.env`.", " Make sure you are already signed in to the runner you choose on this machine.", "2. Start the local API with `agentrail server start`; it processes provider polling/webhooks and keeps configured local agents awake.", " The server loads `~/.agentrail/server.env` automatically.", - "3. Run `agentrail doctor`; setup is only complete when `/tasks/mine?status=in_progress&limit=1` returns assigned work for that agent.", - " Use `agentrail agent run --once --env-file ~/.agentrail/agents/.env` only for manual debugging.", + "3. Run `agentrail doctor --agent-id `; setup is only complete when `/tasks/mine?status=in_progress&limit=1` returns assigned work for that agent.", + " Use `agentrail agent run --once --agent-id ` only for manual debugging.", "4. Optionally connect GitHub immediately from the init follow-up prompt, or use `agentrail provider connect github`, `agentrail provider connect circleci`, or `agentrail provider connect linear` later.", "5. After connecting Linear, import issues with `agentrail linear import ENG-123`.", "6. Provider secrets stay out of `config.json` and are written only to `~/.agentrail/provider.env`.", diff --git a/src/cli/setup-wizard.ts b/src/cli/setup-wizard.ts index 08ba80d..109dc67 100644 --- a/src/cli/setup-wizard.ts +++ b/src/cli/setup-wizard.ts @@ -112,10 +112,7 @@ export async function runSetupWizard({ const routingClassifierModel = flags.routingClassifierModel !== undefined ? flags.routingClassifierModel : routingMode === "ai_assist" - ? resolveOptionalPromptValue(await prompt.input({ - message: "AI routing model/profile", - defaultValue: "", - })) + ? await promptClassifierModel(prompt, routingClassifierRunner) : null; const routingConfidenceThreshold = flags.routingConfidenceThreshold ?? 0.8; const routingFallbackBehavior = flags.routingFallbackBehavior ?? (routingMode === "ai_assist" @@ -170,12 +167,12 @@ export async function runSetupWizard({ ...(routingMode === "ai_assist" ? [ `- AI routing runner: ${routingClassifierRunner}`, - `- AI routing model/profile: ${routingClassifierModel ?? "runner default"}`, + `- AI routing model: ${routingClassifierModel ?? `${classifierRunnerLabel(routingClassifierRunner)} default`}`, `- No suitable agent policy: ${describeRoutingFallbackBehavior(routingFallbackBehavior)}`, ] : []), `- Code review before ship: ${describeCodeReviewPolicy(codeReviewPolicy)}`, - `- Local agent access: ${describeRunnerPolicyPreset(runnerPolicyPreset)}`, + `- Local agent safety: ${describeRunnerPolicyPreset(runnerPolicyPreset)}`, `- Markdown export: ${markdownExport ? "enabled" : "disabled"}`, ...renderTelemetryReviewLines(config.telemetry.enabled), "", @@ -205,7 +202,7 @@ export async function runSetupWizard({ "Use `agentrail provider list` to inspect provider status at any time.", "Use `agentrail provider connect github`, `agentrail provider connect circleci`, or `agentrail provider connect linear` when you are ready to connect live providers and choose polling or webhook delivery.", "After connecting Linear, import issues locally with `agentrail linear import ENG-123`.", - "When the API is running, `agentrail doctor` is the final verification step.", + "When the API is running, `agentrail doctor --agent-id ` is the final verification step.", "Use `agentrail server start` whenever you want the local API running outside the wizard.", ].join("\n"), }); @@ -221,22 +218,22 @@ export async function runSetupWizard({ async function promptRunnerPolicyPreset(prompt: PromptSession): Promise { return await prompt.select({ - message: "Local agent access", + message: "How tightly should AgentRail restrict local agents?", defaultValue: "strict", choices: [ { value: "strict", - label: "Strict", + label: "Strict sandbox", hint: "Fail closed unless AgentRail can enforce local runner access to files, network, credentials, and publishing.", }, { value: "balanced", - label: "Balanced", + label: "Balanced sandbox", hint: "Start locked down, but allow configured development exceptions such as package registry access.", }, { value: "advisory", - label: "Advisory", + label: "Advisory only", hint: "Strip secrets and guide the runner, but do not require full local enforcement.", }, { @@ -281,6 +278,77 @@ async function promptClassifierRunner(prompt: PromptSession): Promise { }); } +async function promptClassifierModel(prompt: PromptSession, runner: string): Promise { + const runnerLabel = classifierRunnerLabel(runner); + const selected = await prompt.select({ + message: `${runnerLabel} routing model`, + defaultValue: "__default__", + choices: [ + { + value: "__default__", + label: `Use ${runnerLabel} default`, + hint: "Recommended. AgentRail will let the local runner choose its configured default model.", + }, + ...classifierModelChoices(runner), + { + value: "__custom__", + label: "Enter a different model", + hint: "Use this if your local runner supports another model name.", + }, + ], + }); + if (selected === "__default__") { + return null; + } + if (selected !== "__custom__") { + return selected; + } + return resolveOptionalPromptValue(await prompt.input({ + message: `${runnerLabel} model name`, + defaultValue: "", + })); +} + +function classifierRunnerLabel(runner: string): string { + if (runner === "codex") return "Codex"; + if (runner === "claude-code") return "Claude Code"; + if (runner === "cursor") return "Cursor"; + return "runner"; +} + +function classifierModelChoices(runner: string): Array<{ value: string; label: string; hint: string }> { + if (runner === "codex") { + return [ + { value: "gpt-5.5", label: "GPT-5.5", hint: "Highest-capability Codex routing model." }, + { value: "gpt-5.4", label: "GPT-5.4", hint: "Strong default choice for routing." }, + { value: "gpt-5.4-mini", label: "GPT-5.4 mini", hint: "Lower-cost routing model." }, + { value: "gpt-5.3-codex", label: "GPT-5.3 Codex", hint: "Coding-optimized Codex model." }, + { value: "gpt-5.3-codex-spark", label: "GPT-5.3 Codex Spark", hint: "Fastest Codex routing model." }, + ]; + } + if (runner === "claude-code") { + return [ + { value: "sonnet", label: "Sonnet (latest)", hint: "Balanced Claude Code model alias." }, + { value: "opus", label: "Opus (latest)", hint: "Higher-capability Claude Code model alias." }, + { value: "haiku", label: "Haiku (latest)", hint: "Faster Claude Code model alias." }, + { value: "claude-opus-4-7", label: "Claude Opus 4.7", hint: "Current highest-capability Claude Code model." }, + { value: "claude-opus-4-6", label: "Claude Opus 4.6", hint: "Pinned Opus model name." }, + { value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5", hint: "Pinned Opus model name." }, + { value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", hint: "Pinned Sonnet model name." }, + { value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5", hint: "Pinned Sonnet model name." }, + { value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5", hint: "Pinned Haiku model name." }, + ]; + } + if (runner === "cursor") { + return [ + { value: "gpt-5", label: "GPT-5", hint: "Cursor Agent documented model example." }, + { value: "sonnet-4", label: "Sonnet 4", hint: "Cursor Agent documented model example." }, + { value: "sonnet-4-thinking", label: "Sonnet 4 Thinking", hint: "Cursor Agent documented reasoning model example." }, + ]; + } + return []; +} + async function promptCodeReviewPolicy(prompt: PromptSession): Promise { const selected = await prompt.select({ message: "Should AgentRail wait for code review before shipping PRs?", @@ -334,10 +402,10 @@ function describeRoutingFallbackBehavior(value: RoutingFallbackBehavior): string } function describeRunnerPolicyPreset(value: RunnerPolicyPreset): string { - if (value === "balanced") return "Balanced"; - if (value === "advisory") return "Advisory"; + if (value === "balanced") return "Balanced sandbox"; + if (value === "advisory") return "Advisory only"; if (value === "external_sandbox") return "External sandbox"; - return "Strict"; + return "Strict sandbox"; } function renderTelemetryReviewLines(enabled: boolean): string[] { diff --git a/src/intake-routing-control-plane.ts b/src/intake-routing-control-plane.ts index 6046ed8..0111983 100644 --- a/src/intake-routing-control-plane.ts +++ b/src/intake-routing-control-plane.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import type { AgentTaskQueue } from "./agent-task-queue.ts"; +import { ROLE_TEMPLATE_ID_PATTERN } from "./agent-role-template.ts"; import type { RoutingClassifier, RoutingClassifierCandidate, RoutingClassifierOutput } from "./routing-classifier.ts"; import { TaskLifecycleError } from "./task-lifecycle-errors.ts"; import type { TaskAssignmentSource, TaskProviderIssueSnapshot, TaskRecord } from "./task-store.ts"; @@ -50,6 +51,7 @@ export interface ProviderIssueSnapshot { export interface AgentProfileReplaceRequest { displayName: string; role: string; + roleTemplateId?: string | null; status: "active" | "paused" | "disabled"; capabilityTags: string[]; ownershipTags: string[]; @@ -63,6 +65,7 @@ export interface AgentProfile { agentId: string; displayName: string; role: string; + roleTemplateId: string | null; status: "active" | "paused" | "disabled"; capabilityTags: string[]; ownershipTags: string[]; @@ -369,6 +372,7 @@ const ROUTING_TARGET_FIELDS = new Set(["type", "id"]); const AGENT_PROFILE_FIELDS = new Set([ "displayName", "role", + "roleTemplateId", "status", "capabilityTags", "ownershipTags", @@ -531,6 +535,7 @@ export class RoutingControlPlane { agentId, displayName: payload.displayName, role: payload.role, + roleTemplateId: payload.roleTemplateId ?? null, status: payload.status, capabilityTags: clone(payload.capabilityTags), ownershipTags: clone(payload.ownershipTags), @@ -1677,6 +1682,15 @@ export class RoutingControlPlane { }); } } + if ( + payload.roleTemplateId !== undefined && + payload.roleTemplateId !== null && + (typeof payload.roleTemplateId !== "string" || !ROLE_TEMPLATE_ID_PATTERN.test(payload.roleTemplateId)) + ) { + throw new TaskLifecycleError(400, "validation_error", "Routing agent profile `roleTemplateId` must be a lowercase kebab-case template id.", { + availableActions: ["retry"], + }); + } } } diff --git a/src/managed-run-context.ts b/src/managed-run-context.ts index 74e97b6..1567c1f 100644 --- a/src/managed-run-context.ts +++ b/src/managed-run-context.ts @@ -1,4 +1,5 @@ import type { AgentRunRecord } from "./agent-run-store.ts"; +import type { ManagedRunRoleBrief } from "./managed-run-role-brief.ts"; export interface ManagedRunContextRun { runId: string; @@ -33,6 +34,7 @@ export interface ManagedRunContextEnvelope { run: ManagedRunContextRun; task: ManagedRunContextTask; nextActions: ManagedRunContextAction[]; + roleBrief?: ManagedRunRoleBrief; }; availableActions: string[]; } @@ -92,9 +94,11 @@ export function describeManagedRunAction(action: string): ManagedRunContextActio export function buildManagedRunContextEnvelope({ run, taskBody, + roleBrief, }: { run: AgentRunRecord; taskBody: TaskEnvelope | unknown; + roleBrief?: ManagedRunRoleBrief | null; }): ManagedRunContextEnvelope { const task = isRecord(taskBody) && isRecord((taskBody as TaskEnvelope).data) ? (taskBody as { data: Record }).data @@ -127,6 +131,7 @@ export function buildManagedRunContextEnvelope({ availableActions, }, nextActions: availableActions.map(describeManagedRunAction), + ...(roleBrief ? { roleBrief } : {}), }, availableActions, }; diff --git a/src/managed-run-role-brief.ts b/src/managed-run-role-brief.ts new file mode 100644 index 0000000..d8395d6 --- /dev/null +++ b/src/managed-run-role-brief.ts @@ -0,0 +1,214 @@ +import type { AgentRoleTemplate, AgentRoleTemplateSourceKind } from "./agent-role-template.ts"; +import type { AgentRailRepoMap, RepoMapLoadResult } from "./repo-map.ts"; + +export interface ManagedRunRoleBriefArea { + area: string; + paths: string[]; + notes?: string; +} + +export interface ManagedRunRoleBrief { + templateId: string; + templateName: string; + templateSourceKind: AgentRoleTemplateSourceKind; + description: string; + instructions: string; + preferredAreas: ManagedRunRoleBriefArea[]; + cautionAreas: ManagedRunRoleBriefArea[]; + forbiddenAreas: ManagedRunRoleBriefArea[]; + checks: { + required: string[]; + suggested: string[]; + }; + evidence: { + required: string[]; + whenApplicable: string[]; + }; + escalation: { + askUserWhen: string[]; + }; + commands: AgentRailRepoMap["commands"]; + warnings: string[]; +} + +export type ManagedRunRoleBriefRepoMapStatus = RepoMapLoadResult["status"]; + +const MAX_PATHS_PER_AREA = 6; +const MAX_ITEMS_PER_LIST = 6; +const MAX_INSTRUCTION_CHARS = 600; + +export function buildManagedRunRoleBrief({ + template, + templateSourceKind, + repoMap, + repoMapStatus, + repoMapPath, + repoMapError, +}: { + template: AgentRoleTemplate; + templateSourceKind: AgentRoleTemplateSourceKind; + repoMap: AgentRailRepoMap | null; + repoMapStatus: ManagedRunRoleBriefRepoMapStatus; + repoMapPath: string; + repoMapError?: string; +}): ManagedRunRoleBrief { + const warnings: string[] = []; + if (repoMapStatus === "missing") { + warnings.push(`Repo map missing at ${repoMapPath}; using role areas without concrete paths.`); + } + if (repoMapStatus === "invalid") { + warnings.push(`Repo map invalid at ${repoMapPath}: ${repoMapError ?? "unknown error"}; using role areas without concrete paths.`); + } + + return { + templateId: template.id, + templateName: template.name, + templateSourceKind, + description: truncateOneLine(template.description, 240), + instructions: truncateBlock(template.instructions, MAX_INSTRUCTION_CHARS), + preferredAreas: mapAreas(template.workspace.preferredAreas, repoMap, warnings), + cautionAreas: mapAreas(template.workspace.cautionAreas, repoMap, warnings), + forbiddenAreas: mapAreas(template.workspace.forbiddenAreas, repoMap, warnings), + checks: { + required: limitStrings(template.checks.required), + suggested: limitStrings(template.checks.suggested), + }, + evidence: { + required: limitStrings(template.evidence.required), + whenApplicable: limitStrings(template.evidence.whenApplicable), + }, + escalation: { + askUserWhen: limitStrings(template.escalation.askUserWhen), + }, + commands: { + ...(repoMap?.commands.test?.length ? { test: limitStrings(repoMap.commands.test) } : {}), + ...(repoMap?.commands.lint?.length ? { lint: limitStrings(repoMap.commands.lint) } : {}), + ...(repoMap?.commands.typecheck?.length ? { typecheck: limitStrings(repoMap.commands.typecheck) } : {}), + ...(repoMap?.commands.build?.length ? { build: limitStrings(repoMap.commands.build) } : {}), + }, + warnings: Array.from(new Set(warnings)).slice(0, MAX_ITEMS_PER_LIST), + }; +} + +export function buildMissingRoleTemplateBrief(roleTemplateId: string): ManagedRunRoleBrief { + return { + templateId: roleTemplateId, + templateName: roleTemplateId, + templateSourceKind: "custom", + description: "Role template could not be loaded.", + instructions: "", + preferredAreas: [], + cautionAreas: [], + forbiddenAreas: [], + checks: { + required: [], + suggested: [], + }, + evidence: { + required: [], + whenApplicable: [], + }, + escalation: { + askUserWhen: [], + }, + commands: {}, + warnings: [`Role template ${roleTemplateId} could not be found; using generic managed-run behavior.`], + }; +} + +export function renderManagedRunRoleBriefForPrompt(brief: ManagedRunRoleBrief | null): string { + if (!brief) { + return ""; + } + return [ + "## Agent Role Brief", + "", + `Agent role: ${brief.templateName} (${brief.templateId})`, + `Role source: ${brief.templateSourceKind}`, + `Role purpose: ${brief.description}`, + brief.instructions ? `Role instructions: ${brief.instructions}` : "", + renderAreas("Preferred areas", brief.preferredAreas), + renderAreas("Caution areas", brief.cautionAreas), + renderAreas("Forbidden areas", brief.forbiddenAreas), + renderList("Required checks", brief.checks.required), + renderList("Suggested checks", brief.checks.suggested), + renderList("Required evidence", brief.evidence.required), + renderList("Evidence when applicable", brief.evidence.whenApplicable), + renderList("Ask user when", brief.escalation.askUserWhen), + renderCommands(brief.commands), + renderList("Role brief warnings", brief.warnings), + ].filter(Boolean).join("\n"); +} + +function mapAreas( + areaNames: string[], + repoMap: AgentRailRepoMap | null, + warnings: string[], +): ManagedRunRoleBriefArea[] { + return areaNames.slice(0, MAX_ITEMS_PER_LIST).map((area) => { + const mapped = repoMap?.areas[area]; + if (!mapped) { + warnings.push(`Repo map has no paths for template area ${area}.`); + return { + area, + paths: [], + }; + } + return { + area, + paths: mapped.paths.slice(0, MAX_PATHS_PER_AREA), + ...(mapped.notes ? { notes: truncateOneLine(mapped.notes, 180) } : {}), + }; + }); +} + +function renderAreas(label: string, areas: ManagedRunRoleBriefArea[]): string { + if (areas.length === 0) { + return ""; + } + return [ + `${label}:`, + ...areas.map((area) => { + const paths = area.paths.length > 0 ? area.paths.join(", ") : "not mapped"; + const notes = area.notes ? ` (${area.notes})` : ""; + return `- ${area.area}: ${paths}${notes}`; + }), + ].join("\n"); +} + +function renderList(label: string, values: string[]): string { + if (values.length === 0) { + return ""; + } + return [ + `${label}:`, + ...values.map((value) => `- ${value}`), + ].join("\n"); +} + +function renderCommands(commands: AgentRailRepoMap["commands"]): string { + const lines: string[] = []; + for (const key of ["test", "lint", "typecheck", "build"] as const) { + const values = commands[key]; + if (values?.length) { + lines.push(`- ${key}: ${values.join(" && ")}`); + } + } + return lines.length > 0 ? ["Repo validation commands:", ...lines].join("\n") : ""; +} + +function limitStrings(values: string[]): string[] { + return values.slice(0, MAX_ITEMS_PER_LIST).map((value) => truncateOneLine(value, 220)); +} + +function truncateOneLine(value: string, maxChars: number): string { + return truncateBlock(value.replace(/\s+/gu, " ").trim(), maxChars); +} + +function truncateBlock(value: string, maxChars: number): string { + const trimmed = value.trim(); + if (trimmed.length <= maxChars) { + return trimmed; + } + return `${trimmed.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`; +} diff --git a/src/repo-map.ts b/src/repo-map.ts new file mode 100644 index 0000000..01ed347 --- /dev/null +++ b/src/repo-map.ts @@ -0,0 +1,449 @@ +import { createHash } from "node:crypto"; +import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; + +import type { ConnectedRepo } from "./cli/agentrail-home.ts"; + +export const AGENTRAIL_REPO_MAP_SCHEMA_VERSION = "agentrail.repo-map/v1"; + +export interface RepoMapArea { + paths: string[]; + testCommands?: string[]; + notes?: string; +} + +export interface AgentRailRepoMap { + schemaVersion: typeof AGENTRAIL_REPO_MAP_SCHEMA_VERSION; + areas: Record; + ignoredPaths: string[]; + generatedPaths: string[]; + commands: { + test?: string[]; + lint?: string[]; + typecheck?: string[]; + build?: string[]; + }; +} + +export type RepoMapLoadResult = + | { + status: "loaded"; + path: string; + map: AgentRailRepoMap; + } + | { + status: "missing"; + path: string; + } + | { + status: "invalid"; + path: string; + error: string; + }; + +export interface EnsureRepoMapResult { + path: string; + status: "created" | "exists" | "skipped"; + reason?: string; +} + +const REPO_MAP_EXTENSION = ".yaml"; +const AREA_PATTERN = /^[a-z][a-z0-9_]*$/u; +const TOP_LEVEL_KEYS = new Set(["schemaVersion", "areas", "ignoredPaths", "generatedPaths", "commands"]); +const AREA_KEYS = new Set(["paths", "testCommands", "notes"]); +const COMMAND_KEYS = new Set(["test", "lint", "typecheck", "build"]); + +const AREA_CANDIDATES: Array<{ area: string; candidates: string[]; notes?: string }> = [ + { area: "application_source", candidates: ["src", "app", "lib", "packages", "services"] }, + { area: "backend_source", candidates: ["src", "server", "api", "services", "functions", "packages"] }, + { area: "frontend_source", candidates: ["app", "pages", "src/app", "src/pages", "client", "web", "frontend"] }, + { area: "frontend_components", candidates: ["components", "src/components", "app/components"] }, + { area: "design_system", candidates: ["design-system", "src/design-system", "components/ui", "ui"] }, + { area: "styling", candidates: ["styles", "src/styles", "app/globals.css", "tailwind.config.js", "tailwind.config.ts", "postcss.config.js", "postcss.config.mjs"] }, + { area: "static_assets", candidates: ["public", "assets", "static"] }, + { area: "browser_tests", candidates: ["e2e", "tests/e2e", "playwright.config.ts", "playwright.config.js", "cypress", "cypress.config.ts", "cypress.config.js"] }, + { area: "tests", candidates: ["test", "tests", "__tests__", "spec", "specs"] }, + { area: "test_fixtures", candidates: ["fixtures", "test/fixtures", "tests/fixtures", "__fixtures__"] }, + { area: "automation_scripts", candidates: ["scripts", "bin", "tools"] }, + { area: "api_contracts", candidates: ["docs/api", "openapi", "schema", "schemas", "api"] }, + { area: "provider_integrations", candidates: ["adapters", "integrations", "providers", "src/adapters", "src/providers", "src/integrations"] }, + { area: "sdk_source", candidates: ["sdk", "packages/sdk", "clients", "src/sdk"] }, + { area: "sdk_documentation", candidates: ["docs/sdk.md", "docs/sdk", "sdk/README.md"] }, + { area: "documentation", candidates: ["docs", "README.md", "CONTRIBUTING.md"] }, + { area: "general_documentation", candidates: ["docs", "README.md", "CONTRIBUTING.md"] }, + { area: "readme", candidates: ["README.md"] }, + { area: "changelog", candidates: ["CHANGELOG.md", "CHANGELOG"] }, + { area: "examples", candidates: ["examples", "demo", "samples"] }, + { area: "website_content", candidates: ["website", "landing", "app", "pages", "docs"] }, + { area: "ci_configuration", candidates: [".github/workflows", ".circleci", ".gitlab-ci.yml", "azure-pipelines.yml"] }, + { area: "release_configuration", candidates: [".changeset", "changeset", "release.config.js", "release.config.mjs", ".releaserc", ".releaserc.json"] }, + { area: "package_manifests", candidates: ["package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", "pyproject.toml", "go.mod", "Cargo.toml", "requirements.txt"] }, + { area: "dependency_manifests", candidates: ["package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", "pyproject.toml", "go.mod", "Cargo.toml", "requirements.txt"] }, + { area: "lockfiles", candidates: ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", "poetry.lock", "Cargo.lock", "go.sum"] }, + { area: "deployment_configuration", candidates: ["vercel.json", "netlify.toml", "Dockerfile", "docker-compose.yml", "railway.json", "fly.toml"] }, + { area: "database_migrations", candidates: ["migrations", "prisma/migrations", "db/migrations"] }, + { area: "security_sensitive_code", candidates: ["auth", "src/auth", "middleware.ts", "src/middleware.ts", "server", "api"] }, + { area: "authentication", candidates: ["auth", "src/auth", "app/api/auth", "api/auth"] }, + { area: "authorization", candidates: ["permissions", "policies", "src/permissions", "src/policies", "rbac"] }, + { area: "permission_policy", candidates: ["permissions", "policies", "src/permissions", "src/policies", "rbac"] }, + { area: "webhook_handlers", candidates: ["webhooks", "src/webhooks", "app/api/webhooks", "api/webhooks"] }, + { area: "sandbox_policy", candidates: ["sandbox", "src/sandbox", "runner-execution-policy.ts", "src/runner-execution-policy.ts"] }, + { area: "security_documentation", candidates: ["SECURITY.md", "docs/security"] }, + { area: "logs_and_diagnostics", candidates: ["logs", "log"] }, + { area: "changed_files", candidates: ["src", "test", "tests", "docs"], notes: "Review agents should prefer the actual task diff when AgentRail provides one." }, + { area: "generated_outputs", candidates: ["dist", "build", "coverage", "out", ".next", "generated"] }, + { area: "build_artifacts", candidates: ["dist", "build", "coverage", "out", ".next", "target"] }, + { area: "vendored_dependencies", candidates: ["node_modules", "vendor"] }, + { area: "secrets", candidates: [".env", ".env.local", ".secrets", "secrets"] }, + { area: "environment_files", candidates: [".env", ".env.example", ".env.local", ".env.development", ".env.production"] }, + { area: "production_credentials", candidates: ["secrets", ".secrets"] }, +]; + +const DEFAULT_IGNORED_PATH_CANDIDATES = [".git", ".agentrail", ".agentrail-run", "node_modules"]; +const DEFAULT_GENERATED_PATH_CANDIDATES = ["dist", "build", "coverage", "out", ".next", "target"]; + +export function repoMapsDirForHome(homePath: string): string { + return path.join(homePath, "repo-maps"); +} + +export function repoMapPathForHome(homePath: string, repoSlug: string): string { + return path.join(repoMapsDirForHome(homePath), `${safeRepoMapFileStem(repoSlug)}${REPO_MAP_EXTENSION}`); +} + +export async function ensureRepoMapForRepo({ + homePath, + repo, +}: { + homePath: string; + repo: ConnectedRepo; +}): Promise { + const repoMapPath = repoMapPathForHome(homePath, repo.slug); + const existing = await loadRepoMapFromFile(repoMapPath); + if (existing.status === "loaded") { + return { + path: repoMapPath, + status: "exists", + }; + } + + if (!await isDirectory(repo.path)) { + return { + path: repoMapPath, + status: "skipped", + reason: `Repo path does not exist or is not a directory: ${repo.path}`, + }; + } + + let reason: string | undefined; + if (existing.status === "invalid") { + const backupPath = `${repoMapPath}.invalid.bak`; + await copyFile(repoMapPath, backupPath); + reason = `Replaced invalid repo map; backup written to ${backupPath}.`; + } + + const repoMap = await generateStarterRepoMap(repo.path); + await mkdir(path.dirname(repoMapPath), { recursive: true, mode: 0o700 }); + await writeFile(repoMapPath, renderRepoMapYaml(repoMap), { encoding: "utf8", mode: 0o600 }); + return { + path: repoMapPath, + status: "created", + ...(reason ? { reason } : {}), + }; +} + +export async function loadRepoMapForRepo({ + homePath, + repo, +}: { + homePath: string; + repo: ConnectedRepo; +}): Promise { + return await loadRepoMapFromFile(repoMapPathForHome(homePath, repo.slug)); +} + +export async function loadRepoMapFromFile(sourcePath: string): Promise { + try { + const content = await readFile(sourcePath, "utf8"); + return { + status: "loaded", + path: sourcePath, + map: parseRepoMapYaml(content, sourcePath), + }; + } catch (error) { + if (isNodeErrorWithCode(error, "ENOENT")) { + return { + status: "missing", + path: sourcePath, + }; + } + return { + status: "invalid", + path: sourcePath, + error: errorMessage(error), + }; + } +} + +export async function generateStarterRepoMap(repoRoot: string): Promise { + const areas: Record = {}; + for (const candidate of AREA_CANDIDATES) { + const paths = await existingRepoMapPaths(repoRoot, candidate.candidates); + if (paths.length > 0) { + areas[candidate.area] = { + paths, + ...(candidate.notes ? { notes: candidate.notes } : {}), + }; + } + } + + const commands = await inferRepoCommands(repoRoot); + const repoMap: AgentRailRepoMap = { + schemaVersion: AGENTRAIL_REPO_MAP_SCHEMA_VERSION, + areas, + ignoredPaths: await existingRepoMapPaths(repoRoot, DEFAULT_IGNORED_PATH_CANDIDATES), + generatedPaths: await existingRepoMapPaths(repoRoot, DEFAULT_GENERATED_PATH_CANDIDATES), + commands, + }; + return validateRepoMap(repoMap, ""); +} + +export function parseRepoMapYaml(yaml: string, sourcePath = ""): AgentRailRepoMap { + let parsed: unknown; + try { + parsed = parseYaml(yaml); + } catch (error) { + throw new Error(`Repo map ${sourcePath} is not valid YAML: ${errorMessage(error)}`); + } + return validateRepoMap(parsed, sourcePath); +} + +export function validateRepoMap(value: unknown, sourcePath = ""): AgentRailRepoMap { + const root = requireRecord(value, sourcePath, "repo map"); + rejectUnknownKeys(root, TOP_LEVEL_KEYS, sourcePath, "repo map"); + + const schemaVersion = requireString(root.schemaVersion, sourcePath, "schemaVersion"); + if (schemaVersion !== AGENTRAIL_REPO_MAP_SCHEMA_VERSION) { + throw new Error(`Repo map ${sourcePath} has unsupported schemaVersion ${schemaVersion}.`); + } + + const rawAreas = requireRecord(root.areas, sourcePath, "areas"); + const areas: Record = {}; + for (const [areaName, areaValue] of Object.entries(rawAreas)) { + if (!AREA_PATTERN.test(areaName)) { + throw new Error(`Repo map ${sourcePath} has invalid area ${areaName}. Use lowercase snake_case area names.`); + } + areas[areaName] = validateRepoMapArea(areaValue, sourcePath, `areas.${areaName}`); + } + + return { + schemaVersion: AGENTRAIL_REPO_MAP_SCHEMA_VERSION, + areas, + ignoredPaths: optionalPathArray(root.ignoredPaths, sourcePath, "ignoredPaths"), + generatedPaths: optionalPathArray(root.generatedPaths, sourcePath, "generatedPaths"), + commands: validateCommands(root.commands, sourcePath), + }; +} + +export function renderRepoMapYaml(repoMap: AgentRailRepoMap): string { + return stringifyYaml(validateRepoMap(repoMap, ""), { + lineWidth: 100, + }); +} + +function validateRepoMapArea(value: unknown, sourcePath: string, fieldPath: string): RepoMapArea { + const area = requireRecord(value, sourcePath, fieldPath); + rejectUnknownKeys(area, AREA_KEYS, sourcePath, fieldPath); + return { + paths: requirePathArray(area.paths, sourcePath, `${fieldPath}.paths`), + ...(area.testCommands === undefined + ? {} + : { testCommands: requireCommandArray(area.testCommands, sourcePath, `${fieldPath}.testCommands`) }), + ...(area.notes === undefined + ? {} + : { notes: requireString(area.notes, sourcePath, `${fieldPath}.notes`) }), + }; +} + +function validateCommands(value: unknown, sourcePath: string): AgentRailRepoMap["commands"] { + if (value === undefined) { + return {}; + } + const commands = requireRecord(value, sourcePath, "commands"); + rejectUnknownKeys(commands, COMMAND_KEYS, sourcePath, "commands"); + return { + ...(commands.test === undefined ? {} : { test: requireCommandArray(commands.test, sourcePath, "commands.test") }), + ...(commands.lint === undefined ? {} : { lint: requireCommandArray(commands.lint, sourcePath, "commands.lint") }), + ...(commands.typecheck === undefined ? {} : { typecheck: requireCommandArray(commands.typecheck, sourcePath, "commands.typecheck") }), + ...(commands.build === undefined ? {} : { build: requireCommandArray(commands.build, sourcePath, "commands.build") }), + }; +} + +async function existingRepoMapPaths(repoRoot: string, candidates: string[]): Promise { + const paths: string[] = []; + for (const candidate of candidates) { + const absolutePath = path.join(repoRoot, candidate); + const stats = await statIfExists(absolutePath); + if (!stats) continue; + paths.push(stats.isDirectory() ? `${candidate.replace(/\/+$/u, "")}/**` : candidate); + } + return [...new Set(paths)]; +} + +async function inferRepoCommands(repoRoot: string): Promise { + const packageJson = await readJsonIfExists(path.join(repoRoot, "package.json")); + if (!packageJson || typeof packageJson !== "object" || Array.isArray(packageJson)) { + return {}; + } + + const scripts = (packageJson as { scripts?: unknown }).scripts; + if (!scripts || typeof scripts !== "object" || Array.isArray(scripts)) { + return {}; + } + + const commandPrefix = await packageManagerCommand(repoRoot); + return { + ...("test" in scripts ? { test: [`${commandPrefix} run test`] } : {}), + ...("lint" in scripts ? { lint: [`${commandPrefix} run lint`] } : {}), + ...("typecheck" in scripts ? { typecheck: [`${commandPrefix} run typecheck`] } : {}), + ...("build" in scripts ? { build: [`${commandPrefix} run build`] } : {}), + }; +} + +async function packageManagerCommand(repoRoot: string): Promise<"npm" | "pnpm" | "yarn" | "bun"> { + if (await fileExists(path.join(repoRoot, "pnpm-lock.yaml"))) return "pnpm"; + if (await fileExists(path.join(repoRoot, "yarn.lock"))) return "yarn"; + if (await fileExists(path.join(repoRoot, "bun.lockb"))) return "bun"; + return "npm"; +} + +function optionalPathArray(value: unknown, sourcePath: string, fieldPath: string): string[] { + if (value === undefined) { + return []; + } + return requirePathArray(value, sourcePath, fieldPath); +} + +function requirePathArray(value: unknown, sourcePath: string, fieldPath: string): string[] { + const paths = requireStringArray(value, sourcePath, fieldPath); + for (const candidate of paths) { + validateRelativeRepoPath(candidate, sourcePath, fieldPath); + } + return [...new Set(paths)]; +} + +function requireCommandArray(value: unknown, sourcePath: string, fieldPath: string): string[] { + return requireStringArray(value, sourcePath, fieldPath); +} + +function validateRelativeRepoPath(candidate: string, sourcePath: string, fieldPath: string): void { + if (candidate.includes("\0")) { + throw new Error(`Repo map ${sourcePath} field ${fieldPath} contains an invalid path with a NUL byte.`); + } + if (candidate.includes("\\")) { + throw new Error(`Repo map ${sourcePath} field ${fieldPath} contains path ${candidate}. Use forward slashes.`); + } + if (path.isAbsolute(candidate) || /^[A-Za-z]:[\\/]/u.test(candidate) || candidate.startsWith("~")) { + throw new Error(`Repo map ${sourcePath} field ${fieldPath} contains absolute path ${candidate}. Use repo-relative paths.`); + } + const segments = candidate.split("/"); + if (segments.some((segment) => segment === "..")) { + throw new Error(`Repo map ${sourcePath} field ${fieldPath} contains path ${candidate} that escapes the repo.`); + } + if (segments.some((segment) => segment.length === 0)) { + throw new Error(`Repo map ${sourcePath} field ${fieldPath} contains malformed path ${candidate}.`); + } +} + +function safeRepoMapFileStem(repoSlug: string): string { + const normalized = repoSlug.trim().replace(/^https?:\/\//iu, "").replace(/\.git$/iu, ""); + const safe = normalized.replace(/[^A-Za-z0-9._-]+/gu, "__").replace(/^_+|_+$/gu, "") || "repo"; + const digest = createHash("sha256").update(normalized).digest("hex").slice(0, 12); + return `${safe}--${digest}`; +} + +async function statIfExists(filePath: string) { + try { + return await stat(filePath); + } catch (error) { + if ( + isNodeErrorWithCode(error, "ENOENT") || + isNodeErrorWithCode(error, "ENOTDIR") || + isNodeErrorWithCode(error, "EACCES") + ) { + return null; + } + throw error; + } +} + +async function isDirectory(filePath: string): Promise { + const stats = await statIfExists(filePath); + return Boolean(stats?.isDirectory()); +} + +async function fileExists(filePath: string): Promise { + const stats = await statIfExists(filePath); + return Boolean(stats?.isFile()); +} + +async function readJsonIfExists(filePath: string): Promise { + try { + return JSON.parse(await readFile(filePath, "utf8")) as unknown; + } catch (error) { + if (isNodeErrorWithCode(error, "ENOENT")) { + return null; + } + if (error instanceof SyntaxError) { + return null; + } + throw error; + } +} + +function requireRecord(value: unknown, sourcePath: string, fieldPath: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`Repo map ${sourcePath} field ${fieldPath} must be an object.`); + } + return value as Record; +} + +function requireStringArray(value: unknown, sourcePath: string, fieldPath: string): string[] { + if (!Array.isArray(value)) { + throw new Error(`Repo map ${sourcePath} field ${fieldPath} must be an array of strings.`); + } + for (const item of value) { + if (typeof item !== "string" || item.trim().length === 0) { + throw new Error(`Repo map ${sourcePath} field ${fieldPath} must be an array of non-empty strings.`); + } + } + return [...value]; +} + +function requireString(value: unknown, sourcePath: string, fieldPath: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`Repo map ${sourcePath} field ${fieldPath} must be a non-empty string.`); + } + return value; +} + +function rejectUnknownKeys( + record: Record, + allowedKeys: Set, + sourcePath: string, + fieldPath: string, +): void { + const unknownKeys = Object.keys(record).filter((key) => !allowedKeys.has(key)); + if (unknownKeys.length > 0) { + throw new Error(`Repo map ${sourcePath} field ${fieldPath} has unknown keys: ${unknownKeys.join(", ")}.`); + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === code; +} diff --git a/src/runner-execution-policy.ts b/src/runner-execution-policy.ts index facf33f..691414c 100644 --- a/src/runner-execution-policy.ts +++ b/src/runner-execution-policy.ts @@ -849,7 +849,7 @@ async function walkFilesystemRoot( if (child.name === ".git") continue; const absolutePath = path.join(current.dirPath, child.name); const relativePath = normalizeRelativePath(path.join(current.relativeDir, child.name)); - const pathMatched = matchesAnyPathGlob(relativePath, patterns); + const pathMatched = matchesAnyPathGlob(relativePath, patterns) && !isSafeEnvTemplatePath(relativePath); const matched = pathMatched || (includeMatchedDescendants && current.ancestorMatched); await visitor({ absolutePath, relativePath, matched }); if (child.isDirectory()) { @@ -991,6 +991,11 @@ function isRecoverableInstructionPath(relativePath: string): boolean { return RECOVERABLE_INSTRUCTION_FILE_GLOBS.some((pattern) => matchesPathGlob(relativePath, pattern)); } +function isSafeEnvTemplatePath(relativePath: string): boolean { + const basename = path.posix.basename(normalizeRelativePath(relativePath)).toLowerCase(); + return /^\.env(?:[._-][a-z0-9]+)*\.(?:example|sample|template|dist)$/u.test(basename); +} + function uniquePaths(values: string[]): string[] { const result: string[] = []; const seen = new Set(); diff --git a/src/setup-verification-task.ts b/src/setup-verification-task.ts index 63710c0..3ea5a96 100644 --- a/src/setup-verification-task.ts +++ b/src/setup-verification-task.ts @@ -174,7 +174,8 @@ export function createSetupVerificationTask({ description: [ `Setup verification smoke task for ${profile.displayName} (${agentId}).`, "", - "Use the generated AgentRail API key and confirm `GET /tasks/mine?status=in_progress&limit=1` returns this task.", + "Use `agentrail run current` and confirm the managed run context returns this task.", + "`agentrail doctor` has already verified the generated AgentRail API key can read assigned tasks through `GET /tasks/mine`.", `Source ref: ${sourceRef}`, ].join("\n"), status: "in_progress", @@ -184,9 +185,9 @@ export function createSetupVerificationTask({ name: profile.displayName, }, acceptanceCriteria: [ - `GET /tasks/mine?status=in_progress&limit=1 returns ${identifier}.`, + `agentrail run current returns ${identifier}.`, `The task remains assigned to ${agentId}.`, - "The setup runner can read the task without additional operator intervention.", + "The managed runner can read its run context without additional operator intervention.", ], links: { issue: `agentrail://setup-verification/${agentId}`, diff --git a/test/agent-management.test.ts b/test/agent-management.test.ts index 81be807..c319525 100644 --- a/test/agent-management.test.ts +++ b/test/agent-management.test.ts @@ -6,6 +6,7 @@ import { once } from "node:events"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import test from "node:test"; +import { AGENT_ROLE_TEMPLATE_SCHEMA_VERSION } from "../src/agent-role-template.ts"; import { createServer } from "../src/app.ts"; import { AgentAuthStore } from "../src/agent-auth-store.ts"; import { AgentProfileStore } from "../src/agent-profile-store.ts"; @@ -21,6 +22,69 @@ import { installFakeExecutableOnPath } from "./helpers/fake-executable.ts"; const now = () => new Date("2026-05-06T12:00:00Z"); +function customRoleTemplateYaml(id = "payments-backend"): string { + return `schemaVersion: ${AGENT_ROLE_TEMPLATE_SCHEMA_VERSION} +id: ${id} +name: Payments Backend +description: Handles payments and backend integration work. + +routing: + capabilityTags: + - payments + - backend + - api + preferredLabels: + - payments + taskTypes: + - backend_bug + - integration_change + avoidLabels: + - design + +runtime: + cleanSlate: true + tools: + builtin: + allow: + - read + - edit + mcp: + prefer: + - github + skills: + prefer: + - backend + +instructions: | + Focus on payments backend behavior and preserve public contracts. + +workspace: + preferredAreas: + - payments + - backend_source + cautionAreas: + - migrations + forbiddenAreas: + - secrets + +checks: + required: + - targeted_tests + suggested: + - typecheck_or_build + +evidence: + required: + - changed_files_summary + whenApplicable: + - api_contract_notes + +escalation: + askUserWhen: + - Payment provider credentials are required. +`; +} + test("agent create provisions a managed local agent and doctor passes", async (t) => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-create-")); const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); @@ -59,7 +123,6 @@ test("agent create provisions a managed local agent and doctor passes", async (t "oxnw/agentrail", "--capability-tags", "code,tests,api", - "--set-default-env", ], { cwd: repoRoot, stdinIsTTY: false, @@ -79,12 +142,11 @@ test("agent create provisions a managed local agent and doctor passes", async (t assert.equal(stderr.toString(), ""); const envPath = path.join(homePath, "agents", "agt_builder.env"); - const aliasPath = path.join(homePath, "agent.env"); const envText = await readFile(envPath, "utf8"); - const aliasText = await readFile(aliasPath, "utf8"); assert.match(envText, /AGENTRAIL_API_KEY_ID=akey_/); assert.match(envText, /AGENTRAIL_AGENT_ID=agt_builder/); - assert.equal(aliasText, envText); + assert.match(stdout.toString(), /Run this agent explicitly with: agentrail agent run --agent-id agt_builder/); + await assert.rejects(readFile(path.join(homePath, "agent.env"), "utf8")); const ruleSet = await getJson(harness.baseUrl, "/operator/routing/rule-sets/current", harness.operatorApiKey); assert.equal(ruleSet.status, 200); @@ -106,6 +168,39 @@ test("agent create provisions a managed local agent and doctor passes", async (t assert.doesNotMatch(serialized, /oxnw\/agentrail/); }); +test("agent create rejects the removed default local agent flag", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-create-default-removed-")); + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const stdout = createMemoryWriter(); + const stderr = createMemoryWriter(); + const previousHome = process.env.AGENTRAIL_HOME; + process.env.AGENTRAIL_HOME = homePath; + + t.after(async () => { + await rm(repoRoot, { recursive: true, force: true }); + await rm(homePath, { recursive: true, force: true }); + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + }); + + const exitCode = await runCli([ + "agent", + "create", + "--set-default-env", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout, + stderr, + }); + + assert.equal(exitCode, 1); + assert.match(stderr.toString(), /--set-default-env was removed/i); + assert.match(stderr.toString(), /--agent-id/); + assert.match(stderr.toString(), /--env-file/); +}); + test("agent create auto-generates agent ids and honors explicit env file paths", async (t) => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-create-explicit-env-")); const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); @@ -167,6 +262,233 @@ test("agent create auto-generates agent ids and honors explicit env file paths", await assert.rejects(readFile(path.join(homePath, "agents", `${createdMatch[1]}.env`), "utf8")); }); +test("agent create derives profile metadata from a built-in role template", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-create-role-template-")); + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const harness = await createHarness(); + const stdout = createMemoryWriter(); + const stderr = createMemoryWriter(); + const previousHome = process.env.AGENTRAIL_HOME; + process.env.AGENTRAIL_HOME = homePath; + await installFakeExecutableOnPath(t, homePath, "codex"); + + t.after(async () => { + await rm(repoRoot, { recursive: true, force: true }); + await rm(homePath, { recursive: true, force: true }); + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + await harness.close(); + }); + + await writeSetupRepo(repoRoot, homePath, harness.baseUrl); + + const exitCode = await runCli([ + "agent", + "create", + "--setup-api-key", + harness.operatorApiKey, + "--base-url", + harness.baseUrl, + "--agent-id", + "agt_frontend", + "--name", + "Frontend", + "--runner", + "codex", + "--role-template", + "frontend-ui", + "--scopes", + "tasks:read,tasks:write", + "--repo-allowlist", + "oxnw/agentrail", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout, + stderr, + }); + + assert.equal(exitCode, 0, stderr.toString()); + assert.match(stdout.toString(), /Created agent Frontend \(agt_frontend\)\./); + + const profile = await getJson(harness.baseUrl, "/operator/routing/agent-profiles/agt_frontend", harness.operatorApiKey); + assert.equal(profile.status, 200); + assert.equal(profile.json.data.role, "frontend-ui"); + assert.equal(profile.json.data.roleTemplateId, "frontend-ui"); + assert.ok(profile.json.data.capabilityTags.includes("frontend")); + assert.ok(profile.json.data.capabilityTags.includes("accessibility")); + assert.ok(profile.json.data.ownershipTags.includes("frontend_source")); + + const env = parseEnv(await readFile(path.join(homePath, "agents", "agt_frontend.env"), "utf8")); + assert.equal(env.AGENTRAIL_AGENT_ROLE_TEMPLATE_ID, "frontend-ui"); +}); + +test("agent create installs a custom role template file and reuses it by id", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-create-custom-role-template-")); + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const harness = await createHarness(); + const previousHome = process.env.AGENTRAIL_HOME; + process.env.AGENTRAIL_HOME = homePath; + await installFakeExecutableOnPath(t, homePath, "codex"); + + t.after(async () => { + await rm(repoRoot, { recursive: true, force: true }); + await rm(homePath, { recursive: true, force: true }); + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + await harness.close(); + }); + + await writeSetupRepo(repoRoot, homePath, harness.baseUrl); + const templatePath = path.join(repoRoot, "payments-backend.yaml"); + await writeFile(templatePath, customRoleTemplateYaml(), "utf8"); + + let exitCode = await runCli([ + "agent", + "create", + "--setup-api-key", + harness.operatorApiKey, + "--base-url", + harness.baseUrl, + "--agent-id", + "agt_payments", + "--name", + "Payments", + "--runner", + "codex", + "--role-template-file", + "payments-backend.yaml", + "--scopes", + "tasks:read,tasks:write", + "--repo-allowlist", + "oxnw/agentrail", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout: createMemoryWriter(), + stderr: createMemoryWriter(), + }); + assert.equal(exitCode, 0); + + const installedPath = path.join(homePath, "agent-role-templates", "payments-backend.yaml"); + assert.equal(await readFile(installedPath, "utf8"), customRoleTemplateYaml()); + + const firstProfile = await getJson(harness.baseUrl, "/operator/routing/agent-profiles/agt_payments", harness.operatorApiKey); + assert.equal(firstProfile.status, 200); + assert.equal(firstProfile.json.data.role, "payments-backend"); + assert.equal(firstProfile.json.data.roleTemplateId, "payments-backend"); + assert.deepEqual(firstProfile.json.data.capabilityTags, ["payments", "backend", "api"]); + assert.deepEqual(firstProfile.json.data.ownershipTags, ["payments", "backend_source"]); + + exitCode = await runCli([ + "agent", + "create", + "--setup-api-key", + harness.operatorApiKey, + "--base-url", + harness.baseUrl, + "--agent-id", + "agt_payments_2", + "--name", + "Payments Two", + "--runner", + "codex", + "--role-template", + "payments-backend", + "--scopes", + "tasks:read,tasks:write", + "--repo-allowlist", + "oxnw/agentrail", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout: createMemoryWriter(), + stderr: createMemoryWriter(), + }); + assert.equal(exitCode, 0); + + const secondProfile = await getJson(harness.baseUrl, "/operator/routing/agent-profiles/agt_payments_2", harness.operatorApiKey); + assert.equal(secondProfile.status, 200); + assert.equal(secondProfile.json.data.roleTemplateId, "payments-backend"); + assert.deepEqual(secondProfile.json.data.capabilityTags, ["payments", "backend", "api"]); +}); + +test("agent create rejects role template id and file together", async () => { + const stderr = createMemoryWriter(); + const exitCode = await runCli([ + "agent", + "create", + "--role-template", + "frontend-ui", + "--role-template-file", + "custom.yaml", + ], { + cwd: process.cwd(), + stdinIsTTY: false, + stdoutIsTTY: false, + stdout: createMemoryWriter(), + stderr, + }); + assert.equal(exitCode, 1); + assert.match(stderr.toString(), /Use either --role-template or --role-template-file , not both/u); +}); + +test("agent create rejects conflicting role template and role flags", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-create-role-template-conflict-")); + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const harness = await createHarness(); + const stdout = createMemoryWriter(); + const stderr = createMemoryWriter(); + const previousHome = process.env.AGENTRAIL_HOME; + process.env.AGENTRAIL_HOME = homePath; + + t.after(async () => { + await rm(repoRoot, { recursive: true, force: true }); + await rm(homePath, { recursive: true, force: true }); + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + await harness.close(); + }); + + await writeSetupRepo(repoRoot, homePath, harness.baseUrl); + + const exitCode = await runCli([ + "agent", + "create", + "--setup-api-key", + harness.operatorApiKey, + "--base-url", + harness.baseUrl, + "--agent-id", + "agt_conflict", + "--name", + "Conflict", + "--runner", + "codex", + "--role-template", + "frontend-ui", + "--role", + "coding_agent", + "--scopes", + "tasks:read,tasks:write", + "--repo-allowlist", + "oxnw/agentrail", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout, + stderr, + }); + + assert.equal(exitCode, 1); + assert.match(stderr.toString(), /--role-template frontend-ui sets role to frontend-ui/); + assert.equal(stdout.toString(), ""); +}); + test("agent create normalizes localhost base URLs from the environment", async (t) => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-create-env-url-")); const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); @@ -282,17 +604,14 @@ test("agent create interactive permission preset grants expected scopes", async await installFakeExecutableOnPath(t, homePath, "codex"); const prompt = new ScriptedPromptSession([ { kind: "select", value: "codex" }, - { kind: "input", value: "gpt-test" }, + { kind: "select", value: "gpt-5.4" }, { kind: "input", value: "Builder Ship" }, { kind: "select", value: "read_write_ship" }, - { kind: "select", value: "coding_agent" }, + { kind: "select", value: "backend-api" }, { kind: "input", value: "https://github.com/oxnw/agentrail" }, - { kind: "multiselect", value: ["backend", "api", "tests"] }, - { kind: "multiselect", value: [] }, { kind: "select", value: "1" }, { kind: "input", value: path.join(repoRoot, "AGENTS.md") }, { kind: "confirm", value: true }, - { kind: "confirm", value: true }, ]); t.after(async () => { @@ -326,30 +645,46 @@ test("agent create interactive permission preset grants expected scopes", async assert.match(stdout.toString(), /GitHub repo: https:\/\/github\.com\/oxnw\/agentrail/); const envPath = path.join(homePath, "agents", `${createdMatch[1]}.env`); const env = parseEnv(await readFile(envPath, "utf8")); - assert.equal(env.AGENTRAIL_AGENT_MODEL, "gpt-test"); + assert.equal(env.AGENTRAIL_AGENT_MODEL, "gpt-5.4"); const usage = await getJson(harness.baseUrl, `/agent-api-keys/${env.AGENTRAIL_API_KEY_ID}/usage`, harness.operatorApiKey); assert.equal(usage.status, 200); assert.deepEqual(usage.json.data.scopes, ["ci:read", "events:read", "reviews:read", "ship:write", "tasks:read", "tasks:write"]); assert.match(prompt.notes.map((note) => note.title).join("\n"), /Permissions selected/); - assert.match(prompt.notes.map((note) => note.title).join("\n"), /Role guide/); + assert.match(prompt.notes.map((note) => note.title).join("\n"), /Built-in role templates/); assert.match(prompt.notes.map((note) => note.title).join("\n"), /Review agent setup/); + assert.ok(prompt.selectMessages.includes("Codex model")); assert.match(prompt.notes.map((note) => note.body).join("\n"), /GitHub repo: https:\/\/github\.com\/oxnw\/agentrail/); - assert.match(prompt.notes.map((note) => note.body).join("\n"), /Role: General coding \(coding_agent\)/); - assert.match(prompt.notes.map((note) => note.body).join("\n"), /Best for building and editing code/); + assert.match(prompt.notes.map((note) => note.body).join("\n"), /Runner model: gpt-5\.4/); + assert.doesNotMatch(prompt.notes.map((note) => note.body).join("\n"), /model\/profile/); + assert.match(prompt.notes.map((note) => note.body).join("\n"), /Role template: Backend\/API \(backend-api\)/); + assert.match(prompt.notes.map((note) => note.body).join("\n"), /Role: backend-api/); + assert.match(prompt.notes.map((note) => note.body).join("\n"), /Capabilities: backend, api, server/); assert.match(prompt.notes.map((note) => note.body).join("\n"), /Task capacity: 1/); - assert.ok(prompt.confirmMessages.includes("Use Builder Ship as the local agent profile for this machine?")); + await assert.rejects(readFile(path.join(homePath, "agent.env"), "utf8")); + assert.ok(prompt.confirmMessages.every((message) => !message.includes("local agent profile"))); assert.ok(prompt.confirmMessages.every((message) => !message.includes("agent.env"))); }); -test("agent update rotates scopes, refreshes env files, and updates managed routing", async (t) => { - const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-update-")); +test("agent create interactive uses runner-specific model choices", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-create-runner-model-")); const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); const harness = await createHarness(); const stdout = createMemoryWriter(); const stderr = createMemoryWriter(); const previousHome = process.env.AGENTRAIL_HOME; process.env.AGENTRAIL_HOME = homePath; - await installFakeExecutableOnPath(t, homePath, "codex"); + await installFakeExecutableOnPath(t, homePath, "cursor"); + const prompt = new ScriptedPromptSession([ + { kind: "select", value: "cursor" }, + { kind: "select", value: "sonnet-4-thinking" }, + { kind: "input", value: "Frontend Cursor" }, + { kind: "select", value: "read_write_ship" }, + { kind: "select", value: "frontend-ui" }, + { kind: "input", value: "https://github.com/oxnw/agentrail" }, + { kind: "select", value: "1" }, + { kind: "input", value: path.join(repoRoot, "AGENTS.md") }, + { kind: "confirm", value: true }, + ]); t.after(async () => { await rm(repoRoot, { recursive: true, force: true }); @@ -359,49 +694,147 @@ test("agent update rotates scopes, refreshes env files, and updates managed rout await harness.close(); }); - await writeSetupRepo(repoRoot, homePath, harness.baseUrl); + await writeSetupRepo(repoRoot, homePath, harness.baseUrl, harness.operatorApiKey); - let exitCode = await runCli([ + const exitCode = await runCli([ "agent", "create", - "--setup-api-key", - harness.operatorApiKey, "--base-url", harness.baseUrl, - "--agent-id", - "agt_builder", - "--name", - "Builder", - "--runner", - "codex", - "--scopes", - "tasks:read,tasks:write", - "--repo-allowlist", - "oxnw/agentrail", - "--capability-tags", - "code,tests,api", - "--set-default-env", ], { cwd: repoRoot, - stdinIsTTY: false, - stdoutIsTTY: false, - stdout: createMemoryWriter(), - stderr: createMemoryWriter(), + stdinIsTTY: true, + stdoutIsTTY: true, + stdout, + stderr, + createPrompt: () => prompt, }); - assert.equal(exitCode, 0); - const envPath = path.join(homePath, "agents", "agt_builder.env"); - const originalEnv = parseEnv(await readFile(envPath, "utf8")); + assert.equal(exitCode, 0, stderr.toString()); + const createdMatch = stdout.toString().match(/Created agent .* \((agt_[a-z0-9_]+)\)\./i); + assert.ok(createdMatch); + const env = parseEnv(await readFile(path.join(homePath, "agents", `${createdMatch[1]}.env`), "utf8")); + assert.equal(env.AGENTRAIL_AGENT_RUNNER, "cursor"); + assert.equal(env.AGENTRAIL_AGENT_MODEL, "sonnet-4-thinking"); + assert.ok(prompt.selectMessages.includes("Cursor model")); + assert.match(prompt.notes.map((note) => note.body).join("\n"), /Runner model: sonnet-4-thinking/); +}); - exitCode = await runCli([ - "agent", - "update", - "--setup-api-key", - harness.operatorApiKey, - "--base-url", - harness.baseUrl, - "--agent-id", - "agt_builder", +test("agent create interactive offers documented Claude Code pinned model choices", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-create-claude-model-")); + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const harness = await createHarness(); + const stdout = createMemoryWriter(); + const stderr = createMemoryWriter(); + const previousHome = process.env.AGENTRAIL_HOME; + process.env.AGENTRAIL_HOME = homePath; + await installFakeExecutableOnPath(t, homePath, "claude"); + const prompt = new ScriptedPromptSession([ + { kind: "select", value: "claude-code" }, + { kind: "select", value: "claude-sonnet-4-5-20250929" }, + { kind: "input", value: "Backend Claude" }, + { kind: "select", value: "read_write_ship" }, + { kind: "select", value: "backend-api" }, + { kind: "input", value: "https://github.com/oxnw/agentrail" }, + { kind: "select", value: "1" }, + { kind: "input", value: path.join(repoRoot, "AGENTS.md") }, + { kind: "confirm", value: true }, + ]); + + t.after(async () => { + await rm(repoRoot, { recursive: true, force: true }); + await rm(homePath, { recursive: true, force: true }); + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + await harness.close(); + }); + + await writeSetupRepo(repoRoot, homePath, harness.baseUrl, harness.operatorApiKey); + + const exitCode = await runCli([ + "agent", + "create", + "--base-url", + harness.baseUrl, + ], { + cwd: repoRoot, + stdinIsTTY: true, + stdoutIsTTY: true, + stdout, + stderr, + createPrompt: () => prompt, + }); + + assert.equal(exitCode, 0, stderr.toString()); + const createdMatch = stdout.toString().match(/Created agent .* \((agt_[a-z0-9_]+)\)\./i); + assert.ok(createdMatch); + const env = parseEnv(await readFile(path.join(homePath, "agents", `${createdMatch[1]}.env`), "utf8")); + assert.equal(env.AGENTRAIL_AGENT_RUNNER, "claude-code"); + assert.equal(env.AGENTRAIL_AGENT_MODEL, "claude-sonnet-4-5-20250929"); + assert.ok(prompt.selectMessages.includes("Claude Code model")); + assert.match(prompt.notes.map((note) => note.body).join("\n"), /Runner model: claude-sonnet-4-5-20250929/); +}); + +test("agent update rotates scopes, refreshes env files, and updates managed routing", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-update-")); + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const harness = await createHarness(); + const stdout = createMemoryWriter(); + const stderr = createMemoryWriter(); + const previousHome = process.env.AGENTRAIL_HOME; + process.env.AGENTRAIL_HOME = homePath; + await installFakeExecutableOnPath(t, homePath, "codex"); + + t.after(async () => { + await rm(repoRoot, { recursive: true, force: true }); + await rm(homePath, { recursive: true, force: true }); + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + await harness.close(); + }); + + await writeSetupRepo(repoRoot, homePath, harness.baseUrl); + + let exitCode = await runCli([ + "agent", + "create", + "--setup-api-key", + harness.operatorApiKey, + "--base-url", + harness.baseUrl, + "--agent-id", + "agt_builder", + "--name", + "Builder", + "--runner", + "codex", + "--scopes", + "tasks:read,tasks:write", + "--repo-allowlist", + "oxnw/agentrail", + "--capability-tags", + "code,tests,api", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout: createMemoryWriter(), + stderr: createMemoryWriter(), + }); + assert.equal(exitCode, 0); + + const envPath = path.join(homePath, "agents", "agt_builder.env"); + const originalEnv = parseEnv(await readFile(envPath, "utf8")); + + exitCode = await runCli([ + "agent", + "update", + "--setup-api-key", + harness.operatorApiKey, + "--base-url", + harness.baseUrl, + "--agent-id", + "agt_builder", "--env-file", path.relative(repoRoot, envPath), "--name", @@ -439,8 +872,328 @@ test("agent update rotates scopes, refreshes env files, and updates managed rout assert.equal(ruleSet.status, 200); assert.deepEqual(ruleSet.json.data.rules[0].conditions.labelsAny, ["frontend"]); - const aliasEnv = await readFile(path.join(homePath, "agent.env"), "utf8"); - assert.equal(aliasEnv, await readFile(envPath, "utf8")); + await assert.rejects(readFile(path.join(homePath, "agent.env"), "utf8")); +}); + +test("agent update clears stale role template metadata when role is manually changed", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-update-role-template-clear-")); + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const harness = await createHarness(); + const previousHome = process.env.AGENTRAIL_HOME; + process.env.AGENTRAIL_HOME = homePath; + await installFakeExecutableOnPath(t, homePath, "codex"); + + t.after(async () => { + await rm(repoRoot, { recursive: true, force: true }); + await rm(homePath, { recursive: true, force: true }); + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + await harness.close(); + }); + + await writeSetupRepo(repoRoot, homePath, harness.baseUrl); + + let exitCode = await runCli([ + "agent", + "create", + "--setup-api-key", + harness.operatorApiKey, + "--base-url", + harness.baseUrl, + "--agent-id", + "agt_template_update", + "--name", + "Template Update", + "--runner", + "codex", + "--role-template", + "frontend-ui", + "--scopes", + "tasks:read,tasks:write", + "--repo-allowlist", + "oxnw/agentrail", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout: createMemoryWriter(), + stderr: createMemoryWriter(), + }); + assert.equal(exitCode, 0); + + const envPath = path.join(homePath, "agents", "agt_template_update.env"); + const originalEnv = parseEnv(await readFile(envPath, "utf8")); + exitCode = await runCli([ + "agent", + "update", + "--setup-api-key", + harness.operatorApiKey, + "--base-url", + harness.baseUrl, + "--agent-id", + "agt_template_update", + "--api-key-id", + originalEnv.AGENTRAIL_API_KEY_ID, + "--env-file", + path.relative(repoRoot, envPath), + "--role", + "coding_agent", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout: createMemoryWriter(), + stderr: createMemoryWriter(), + }); + assert.equal(exitCode, 0); + + const profile = await getJson(harness.baseUrl, "/operator/routing/agent-profiles/agt_template_update", harness.operatorApiKey); + assert.equal(profile.status, 200); + assert.equal(profile.json.data.role, "coding_agent"); + assert.equal(profile.json.data.roleTemplateId, null); + + const updatedEnv = parseEnv(await readFile(envPath, "utf8")); + assert.equal(updatedEnv.AGENTRAIL_AGENT_ROLE_TEMPLATE_ID, undefined); +}); + +test("agent update clears previous model when runner changes without a new model", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-update-runner-model-")); + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const harness = await createHarness(); + const previousHome = process.env.AGENTRAIL_HOME; + process.env.AGENTRAIL_HOME = homePath; + await installFakeExecutableOnPath(t, homePath, "codex"); + await installFakeExecutableOnPath(t, homePath, "claude-code"); + + t.after(async () => { + await rm(repoRoot, { recursive: true, force: true }); + await rm(homePath, { recursive: true, force: true }); + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + await harness.close(); + }); + + await writeSetupRepo(repoRoot, homePath, harness.baseUrl); + + let exitCode = await runCli([ + "agent", + "create", + "--setup-api-key", + harness.operatorApiKey, + "--base-url", + harness.baseUrl, + "--agent-id", + "agt_runner_model_update", + "--name", + "Runner Model Update", + "--runner", + "codex", + "--model", + "gpt-5.4", + "--scopes", + "tasks:read,tasks:write", + "--repo-allowlist", + "oxnw/agentrail", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout: createMemoryWriter(), + stderr: createMemoryWriter(), + }); + assert.equal(exitCode, 0); + + const envPath = path.join(homePath, "agents", "agt_runner_model_update.env"); + const originalEnv = parseEnv(await readFile(envPath, "utf8")); + assert.equal(originalEnv.AGENTRAIL_AGENT_MODEL, "gpt-5.4"); + + exitCode = await runCli([ + "agent", + "update", + "--setup-api-key", + harness.operatorApiKey, + "--base-url", + harness.baseUrl, + "--agent-id", + "agt_runner_model_update", + "--api-key-id", + originalEnv.AGENTRAIL_API_KEY_ID, + "--env-file", + path.relative(repoRoot, envPath), + "--runner", + "claude-code", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout: createMemoryWriter(), + stderr: createMemoryWriter(), + }); + assert.equal(exitCode, 0); + + const updatedEnv = parseEnv(await readFile(envPath, "utf8")); + assert.equal(updatedEnv.AGENTRAIL_AGENT_RUNNER, "claude-code"); + assert.equal(updatedEnv.AGENTRAIL_AGENT_MODEL, undefined); +}); + +test("agent update rejects conflicting role template and role flags", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-update-role-template-conflict-")); + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const harness = await createHarness(); + const stderr = createMemoryWriter(); + const previousHome = process.env.AGENTRAIL_HOME; + process.env.AGENTRAIL_HOME = homePath; + await installFakeExecutableOnPath(t, homePath, "codex"); + + t.after(async () => { + await rm(repoRoot, { recursive: true, force: true }); + await rm(homePath, { recursive: true, force: true }); + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + await harness.close(); + }); + + await writeSetupRepo(repoRoot, homePath, harness.baseUrl); + + let exitCode = await runCli([ + "agent", + "create", + "--setup-api-key", + harness.operatorApiKey, + "--base-url", + harness.baseUrl, + "--agent-id", + "agt_update_conflict", + "--name", + "Update Conflict", + "--runner", + "codex", + "--scopes", + "tasks:read,tasks:write", + "--repo-allowlist", + "oxnw/agentrail", + "--capability-tags", + "code", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout: createMemoryWriter(), + stderr: createMemoryWriter(), + }); + assert.equal(exitCode, 0); + + const envPath = path.join(homePath, "agents", "agt_update_conflict.env"); + const originalEnv = parseEnv(await readFile(envPath, "utf8")); + exitCode = await runCli([ + "agent", + "update", + "--setup-api-key", + harness.operatorApiKey, + "--base-url", + harness.baseUrl, + "--agent-id", + "agt_update_conflict", + "--api-key-id", + originalEnv.AGENTRAIL_API_KEY_ID, + "--env-file", + path.relative(repoRoot, envPath), + "--role-template", + "frontend-ui", + "--role", + "coding_agent", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout: createMemoryWriter(), + stderr, + }); + + assert.equal(exitCode, 1); + assert.match(stderr.toString(), /--role-template frontend-ui sets role to frontend-ui/); +}); + +test("agent update installs and applies a custom role template file", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-update-custom-role-template-")); + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const harness = await createHarness(); + const previousHome = process.env.AGENTRAIL_HOME; + process.env.AGENTRAIL_HOME = homePath; + await installFakeExecutableOnPath(t, homePath, "codex"); + + t.after(async () => { + await rm(repoRoot, { recursive: true, force: true }); + await rm(homePath, { recursive: true, force: true }); + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + await harness.close(); + }); + + await writeSetupRepo(repoRoot, homePath, harness.baseUrl); + let exitCode = await runCli([ + "agent", + "create", + "--setup-api-key", + harness.operatorApiKey, + "--base-url", + harness.baseUrl, + "--agent-id", + "agt_update_custom_template", + "--name", + "Update Custom Template", + "--runner", + "codex", + "--scopes", + "tasks:read,tasks:write", + "--repo-allowlist", + "oxnw/agentrail", + "--capability-tags", + "code", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout: createMemoryWriter(), + stderr: createMemoryWriter(), + }); + assert.equal(exitCode, 0); + + const templatePath = path.join(repoRoot, "payments-backend.yaml"); + await writeFile(templatePath, customRoleTemplateYaml(), "utf8"); + const envPath = path.join(homePath, "agents", "agt_update_custom_template.env"); + const originalEnv = parseEnv(await readFile(envPath, "utf8")); + + exitCode = await runCli([ + "agent", + "update", + "--setup-api-key", + harness.operatorApiKey, + "--base-url", + harness.baseUrl, + "--agent-id", + "agt_update_custom_template", + "--api-key-id", + originalEnv.AGENTRAIL_API_KEY_ID, + "--env-file", + path.relative(repoRoot, envPath), + "--role-template-file", + "payments-backend.yaml", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout: createMemoryWriter(), + stderr: createMemoryWriter(), + }); + assert.equal(exitCode, 0); + + const profile = await getJson(harness.baseUrl, "/operator/routing/agent-profiles/agt_update_custom_template", harness.operatorApiKey); + assert.equal(profile.status, 200); + assert.equal(profile.json.data.role, "payments-backend"); + assert.equal(profile.json.data.roleTemplateId, "payments-backend"); + assert.deepEqual(profile.json.data.capabilityTags, ["payments", "backend", "api"]); + assert.deepEqual(profile.json.data.ownershipTags, ["payments", "backend_source"]); }); test("agent update replays the same rotation request and later accepts a different update", async (t) => { @@ -480,7 +1233,6 @@ test("agent update replays the same rotation request and later accepts a differe "oxnw/agentrail", "--capability-tags", "code,tests,api", - "--set-default-env", ], { cwd: repoRoot, stdinIsTTY: false, @@ -752,6 +1504,7 @@ function createMemoryWriter() { class ScriptedPromptSession implements PromptSession { readonly notes: Array<{ title?: string; body: string }> = []; readonly messages: string[] = []; + readonly selectMessages: string[] = []; readonly confirmMessages: string[] = []; readonly #steps: Array<{ kind: "select" | "multiselect" | "confirm" | "input" | "secret"; value: string | boolean | string[] }>; @@ -760,6 +1513,7 @@ class ScriptedPromptSession implements PromptSession { } async select(options: { message: string; choices: PromptChoice[]; defaultValue?: string }): Promise { + this.selectMessages.push(options.message); const step = this.#next("select"); const allowed = new Set(options.choices.map((choice) => choice.value)); assert.ok(allowed.has(String(step.value))); diff --git a/test/agent-profile-store.test.ts b/test/agent-profile-store.test.ts index 34556b0..81f001b 100644 --- a/test/agent-profile-store.test.ts +++ b/test/agent-profile-store.test.ts @@ -40,10 +40,28 @@ test("AgentProfileStore persists profiles to file and loads on restart", async ( const profile = store2.getAgentProfile("agt_cto"); assert.ok(profile); assert.equal(profile?.displayName, "CTO"); + assert.equal(profile?.roleTemplateId, null); assert.equal(profile?.source, "operator_admin"); assert.equal(profile?.updatedBy, "agt_operator"); }); +test("AgentProfileStore persists role template ids", async (t) => { + const storagePath = tmpPath(); + t.after(() => { + fs.rmSync(storagePath, { force: true }); + }); + + const store1 = new AgentProfileStore({ now, storagePath }); + store1.replaceAgentProfile("agt_backend", makePayload({ + displayName: "Backend", + role: "backend-api", + roleTemplateId: "backend-api", + }), "agt_operator", "idemp-template"); + + const store2 = new AgentProfileStore({ now, storagePath }); + assert.equal(store2.getAgentProfile("agt_backend")?.roleTemplateId, "backend-api"); +}); + test("AgentProfileStore returns null for unknown agent", () => { const store = new AgentProfileStore({ now }); assert.equal(store.getAgentProfile("agt_unknown"), null); @@ -81,6 +99,14 @@ test("AgentProfileStore validates payload is an object", () => { ); }); +test("AgentProfileStore rejects invalid role template ids", () => { + const store = new AgentProfileStore({ now }); + assert.throws( + () => store.replaceAgentProfile("agt_cto", makePayload({ roleTemplateId: "Backend/API" }), "agt_operator"), + (e: any) => e.statusCode === 400 + ); +}); + test("AgentProfileStore replaces existing profile and updates metadata", () => { const store = new AgentProfileStore({ now }); const p1 = store.replaceAgentProfile("agt_cto", makePayload(), "agt_operator"); diff --git a/test/agent-role-template.test.ts b/test/agent-role-template.test.ts index 32523b9..af15fa7 100644 --- a/test/agent-role-template.test.ts +++ b/test/agent-role-template.test.ts @@ -7,9 +7,12 @@ import path from "node:path"; import { AGENT_ROLE_TEMPLATE_SCHEMA_VERSION, getBuiltInAgentRoleTemplate, + installCustomAgentRoleTemplate, loadAgentRoleTemplatesFromDir, loadBuiltInAgentRoleTemplates, + loadInstalledCustomAgentRoleTemplates, parseAgentRoleTemplateYaml, + resolveAgentRoleTemplate, } from "../src/agent-role-template.ts"; const EXPECTED_BUILT_IN_TEMPLATE_IDS = [ @@ -95,6 +98,7 @@ test("loads all built-in agent role templates", async () => { const loaded = await loadBuiltInAgentRoleTemplates(); assert.deepEqual(loaded.map((entry) => entry.template.id), EXPECTED_BUILT_IN_TEMPLATE_IDS); assert.ok(loaded.every((entry) => entry.sourcePath.endsWith(".yaml"))); + assert.ok(loaded.every((entry) => entry.sourceKind === "built_in")); assert.ok(loaded.every((entry) => entry.template.runtime.cleanSlate)); }); @@ -159,3 +163,76 @@ test("rejects duplicate template ids in a directory", async (t) => { /Duplicate agent role template id duplicate-role/u, ); }); + +test("loads installed custom templates from AgentRail home", async (t) => { + const homePath = makeTemplateDir(t); + const templateDir = path.join(homePath, "agent-role-templates"); + fs.mkdirSync(templateDir, { recursive: true }); + fs.writeFileSync(path.join(templateDir, "payments-backend.yaml"), templateYaml("payments-backend"), "utf8"); + + const loaded = await loadInstalledCustomAgentRoleTemplates(homePath); + assert.deepEqual(loaded.map((entry) => entry.template.id), ["payments-backend"]); + assert.equal(loaded[0]?.sourceKind, "custom"); +}); + +test("missing custom template directory resolves to an empty list", async (t) => { + const homePath = makeTemplateDir(t); + const loaded = await loadInstalledCustomAgentRoleTemplates(homePath); + assert.deepEqual(loaded, []); +}); + +test("resolves built-in templates before installed custom templates", async (t) => { + const homePath = makeTemplateDir(t); + const templateDir = path.join(homePath, "agent-role-templates"); + fs.mkdirSync(templateDir, { recursive: true }); + fs.writeFileSync(path.join(templateDir, "payments-backend.yaml"), templateYaml("payments-backend"), "utf8"); + + const builtIn = await resolveAgentRoleTemplate({ homePath, id: "frontend-ui" }); + const custom = await resolveAgentRoleTemplate({ homePath, id: "payments-backend" }); + const missing = await resolveAgentRoleTemplate({ homePath, id: "missing-role" }); + + assert.equal(builtIn?.sourceKind, "built_in"); + assert.equal(custom?.sourceKind, "custom"); + assert.equal(custom?.template.id, "payments-backend"); + assert.equal(missing, null); +}); + +test("installs a custom template and rejects built-in id collisions", async (t) => { + const homePath = makeTemplateDir(t); + const sourceDir = makeTemplateDir(t); + const sourcePath = path.join(sourceDir, "payments.yaml"); + fs.writeFileSync(sourcePath, templateYaml("payments-backend"), "utf8"); + + const installed = await installCustomAgentRoleTemplate({ homePath, sourcePath }); + assert.equal(installed.template.id, "payments-backend"); + assert.equal(installed.sourceKind, "custom"); + assert.equal(installed.sourcePath, path.join(homePath, "agent-role-templates", "payments-backend.yaml")); + assert.equal(fs.readFileSync(installed.sourcePath, "utf8"), templateYaml("payments-backend")); + + const conflictingSourcePath = path.join(sourceDir, "frontend.yaml"); + fs.writeFileSync(conflictingSourcePath, templateYaml("frontend-ui"), "utf8"); + await assert.rejects( + () => installCustomAgentRoleTemplate({ homePath, sourcePath: conflictingSourcePath }), + /conflicts with built-in template/u, + ); +}); + +test("installing a custom template enforces private permissions on existing paths", async (t) => { + const homePath = makeTemplateDir(t); + const sourceDir = makeTemplateDir(t); + const templateDir = path.join(homePath, "agent-role-templates"); + const installedPath = path.join(templateDir, "payments-backend.yaml"); + const sourcePath = path.join(sourceDir, "payments.yaml"); + fs.mkdirSync(templateDir, { recursive: true, mode: 0o777 }); + fs.writeFileSync(installedPath, templateYaml("payments-backend"), { mode: 0o644 }); + fs.chmodSync(templateDir, 0o777); + fs.chmodSync(installedPath, 0o644); + fs.writeFileSync(sourcePath, templateYaml("payments-backend"), "utf8"); + + await installCustomAgentRoleTemplate({ homePath, sourcePath }); + + if (process.platform !== "win32") { + assert.equal(fs.statSync(templateDir).mode & 0o777, 0o700); + assert.equal(fs.statSync(installedPath).mode & 0o777, 0o600); + } +}); diff --git a/test/agent-runner.test.ts b/test/agent-runner.test.ts index d49ab67..ff95789 100644 --- a/test/agent-runner.test.ts +++ b/test/agent-runner.test.ts @@ -15,6 +15,7 @@ import { defaultPrepareWorktree, runAgentReport } from "../src/cli/agent-runner. import { runCli } from "../src/cli/index.ts"; import { createSetupConfig } from "../src/cli/setup-config.ts"; import { writeSetupFiles } from "../src/cli/setup-files.ts"; +import { AGENTRAIL_REPO_MAP_SCHEMA_VERSION, repoMapPathForHome } from "../src/repo-map.ts"; import { TaskEventStore } from "../src/task-event-store.ts"; import type { TelemetryEventName, TelemetryProperties } from "../src/telemetry.ts"; import { createMemoryWriter } from "./helpers/memory-writer.ts"; @@ -53,6 +54,83 @@ interface CapturedTelemetryEvent { properties: TelemetryProperties; } +test("agent run requires an explicit agent identity when no legacy env exists", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-run-explicit-")); + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const stdout = createMemoryWriter(); + const stderr = createMemoryWriter(); + const previousHome = process.env.AGENTRAIL_HOME; + process.env.AGENTRAIL_HOME = homePath; + + t.after(async () => { + await rm(repoRoot, { recursive: true, force: true }); + await rm(homePath, { recursive: true, force: true }); + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + }); + + const exitCode = await runCli(["agent", "run", "--once"], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout, + stderr, + }); + + assert.equal(exitCode, 1); + assert.match(stderr.toString(), /requires --agent-id or --env-file /); +}); + +test("agent run falls back to legacy current agent env", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-run-legacy-")); + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const stdout = createMemoryWriter(); + const stderr = createMemoryWriter(); + const previousHome = process.env.AGENTRAIL_HOME; + process.env.AGENTRAIL_HOME = homePath; + + t.after(async () => { + await rm(repoRoot, { recursive: true, force: true }); + await rm(homePath, { recursive: true, force: true }); + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + }); + + await writeFile(path.join(homePath, "agent.env"), [ + "AGENTRAIL_API_KEY=ar_live_legacy", + "AGENTRAIL_AGENT_ID=agt_legacy", + "", + ].join("\n"), { mode: 0o600 }); + + const exitCode = await runCli(["agent", "run", "--once"], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout, + stderr, + }); + + assert.equal(exitCode, 1); + assert.doesNotMatch(stderr.toString(), /requires --agent-id or --env-file /); + assert.match(stderr.toString(), /requires AGENTRAIL_BASE_URL/u); +}); + +test("agent run usage shows both accepted identity inputs", async () => { + const stdout = createMemoryWriter(); + const stderr = createMemoryWriter(); + + const exitCode = await runCli(["agent", "run", "--help"], { + cwd: process.cwd(), + stdinIsTTY: false, + stdoutIsTTY: false, + stdout, + stderr, + }); + + assert.equal(exitCode, 0, stderr.toString()); + assert.match(stdout.toString(), /agentrail agent run \(--agent-id \| --env-file \) \[flags\]/); +}); + function readPullRequestPayload(payload: unknown): PullRequestPayload["pullRequest"] { if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { return undefined; @@ -141,12 +219,34 @@ test("agent run --once starts assigned work, records the run, and writes markdow AGENTRAIL_API_KEY: harness.apiKey, AGENTRAIL_AGENT_ID: "agt_runner", AGENTRAIL_AGENT_RUNNER: "codex", + AGENTRAIL_AGENT_ROLE_TEMPLATE_ID: "frontend-ui", AGENTRAIL_MAX_CONCURRENT_TASKS: "1", AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", AGENTRAIL_AGENT_RECIPE_PATH: staleRecipePath, }); - - const exitCode = await runCli(["agent", "run", "--once"], { + const repoMapPath = repoMapPathForHome(homePath, "oxnw/agentrail"); + await mkdir(path.dirname(repoMapPath), { recursive: true }); + await writeFile(repoMapPath, [ + `schemaVersion: ${AGENTRAIL_REPO_MAP_SCHEMA_VERSION}`, + "areas:", + " frontend_source:", + " paths:", + " - app", + " frontend_components:", + " paths:", + " - components", + " styling:", + " paths:", + " - styles", + "ignoredPaths: []", + "generatedPaths: []", + "commands:", + " test:", + " - npm test", + "", + ].join("\n"), "utf8"); + + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -177,6 +277,7 @@ test("agent run --once starts assigned work, records the run, and writes markdow assert.equal(env.CIRCLECI_TOKEN, undefined); assert.equal(env.LINEAR_API_KEY, undefined); assert.equal(env.AGENTRAIL_AGENT_ID, "agt_runner"); + assert.equal(env.AGENTRAIL_AGENT_ROLE_TEMPLATE_ID, "frontend-ui"); assert.equal(env.AGENTRAIL_RESULT_PATH, handoffPath); assert.equal(env.AGENTRAIL_HANDOFF_PATH, handoffPath); assert.match(env.AGENTRAIL_RUN_ID ?? "", /^run_/u); @@ -188,10 +289,15 @@ test("agent run --once starts assigned work, records the run, and writes markdow const context = JSON.parse(await readFile(runContextPath, "utf8")); assert.equal(context.data.run.runId, env.AGENTRAIL_RUN_ID); assert.equal(context.data.task.id, task.id); + assert.equal(context.data.roleBrief.templateId, "frontend-ui"); + assert.equal(context.data.roleBrief.templateName, "Frontend/UI"); + assert.deepEqual(context.data.roleBrief.preferredAreas[0].paths, ["app"]); assert.deepEqual(context.availableActions, ["submit"]); assert.ok(recipePath, "managed recipePath must be defined"); assert.notEqual(recipePath, staleRecipePath); const recipeText = await readFile(recipePath, "utf8"); + assert.match(recipeText, /Role-specific context is guidance for repository work/); + assert.match(recipeText, /AgentRail lifecycle instructions still take precedence/); assert.doesNotMatch(recipeText, /GET \/tasks\/mine/); assert.doesNotMatch(recipeText, /GET \/tasks\/\{taskId\}\/ci-status/); assert.doesNotMatch(recipeText, /GET \/tasks\/\{taskId\}\/review-feedback/); @@ -270,6 +376,10 @@ test("agent run --once starts assigned work, records the run, and writes markdow const promptText = await readFile(runs[0].promptPath, "utf8"); assert.match(promptText, /AGEA-RUN-001/); + assert.match(promptText, /## Agent Role Brief/); + assert.match(promptText, /Agent role: Frontend\/UI \(frontend-ui\)/); + assert.match(promptText, /frontend_source: app/); + assert.match(promptText, /frontend_components: components/); assert.match(promptText, /Recipe file:/); assert.match(promptText, /Result file:/); assert.match(promptText, /Run context file:/); @@ -390,7 +500,7 @@ test("agent run observes telemetry off while the runner process stays alive", as }); let launchCount = 0; - const exitCode = await runCli(["agent", "run", "--max-runs", "2"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--max-runs", "2"], { cwd: repoRoot, stdout, stderr, @@ -608,7 +718,7 @@ test("agent run waits on task events instead of sleeping and polling when idle", return globalThis.fetch(input, init); }; - const runPromise = runCli(["agent", "run", "--max-runs", "1"], { + const runPromise = runCli(["agent", "run", "--agent-id", "agt_runner", "--max-runs", "1"], { cwd: repoRoot, stdout, stderr, @@ -744,7 +854,7 @@ test("agent run without --once stays alive for later task events", async (t) => return globalThis.fetch(input, init); }; - const runPromise = runCli(["agent", "run", "--poll-interval", "1"], { + const runPromise = runCli(["agent", "run", "--agent-id", "agt_runner", "--poll-interval", "1"], { cwd: repoRoot, stdout, stderr, @@ -883,7 +993,7 @@ test("agent run falls back to polling when a task event arrives before the strea return globalThis.fetch(input, init); }; - const runPromise = runCli(["agent", "run", "--max-runs", "1", "--poll-interval", "1"], { + const runPromise = runCli(["agent", "run", "--agent-id", "agt_runner", "--max-runs", "1", "--poll-interval", "1"], { cwd: repoRoot, stdout, stderr, @@ -1019,7 +1129,7 @@ test("agent run wakes on review changes requested events", async (t) => { return globalThis.fetch(input, init); }; - const runPromise = runCli(["agent", "run", "--max-runs", "1"], { + const runPromise = runCli(["agent", "run", "--agent-id", "agt_runner", "--max-runs", "1"], { cwd: repoRoot, stdout, stderr, @@ -1192,7 +1302,7 @@ test("agent run wakes on CI failure events and includes compact CI failure conte return globalThis.fetch(input, init); }; - const runPromise = runCli(["agent", "run", "--max-runs", "1"], { + const runPromise = runCli(["agent", "run", "--agent-id", "agt_runner", "--max-runs", "1"], { cwd: repoRoot, stdout, stderr, @@ -1323,7 +1433,7 @@ test("agent run treats no-change CI retry as successful when CI recovers before }, }); - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -1472,7 +1582,7 @@ test("agent run does not replay a stale start response after blocker retry", asy }, })); - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -1574,7 +1684,7 @@ test("agent run skips setup verification tasks and starts real assigned work ins AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", }); - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -1648,7 +1758,7 @@ test("agent run starts setup verification when no normal task is runnable", asyn AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", }); - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -1768,7 +1878,7 @@ test("agent run fetches later task pages before deciding the queue is empty", as return globalThis.fetch(input, init); }; - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -1854,7 +1964,7 @@ test("agent run consumes a completion handoff reported through the managed local AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", }); - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -1961,7 +2071,7 @@ test("agent run default workspace keeps git metadata and runner mailbox inside t AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", }); - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -2402,7 +2512,7 @@ test("agent run preserves locally reported blockers without requiring a handoff AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", }); - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -2494,7 +2604,7 @@ test("agent run consumes local report files written by managed runners", async ( AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", }); - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -2582,7 +2692,7 @@ test("agent run honors local blocked reports even when the runner fails", async AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", }); - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -2712,7 +2822,7 @@ test("agent run respects max concurrent task capacity from the agent env", async AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", }); - const exitCode = await runCli(["agent", "run", "--once", "--json"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once", "--json"], { cwd: repoRoot, stdout, stderr, @@ -2781,7 +2891,7 @@ test("agent run --once --json includes queue visibility when no tasks are runnab AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", }); - const exitCode = await runCli(["agent", "run", "--once", "--json"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once", "--json"], { cwd: repoRoot, stdout, stderr, @@ -2883,7 +2993,7 @@ test("agent run reclaims a stale active run and starts the task again", async (t AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", }); - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -2983,7 +3093,7 @@ test("agent run blocks a task after repeated managed-run infrastructure failures AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", }); - const exitCode = await runCli(["agent", "run", "--once", "--json"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once", "--json"], { cwd: repoRoot, stdout, stderr, @@ -3081,7 +3191,7 @@ test("agent run does not overwrite an existing awaiting-user blocker after recla AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", }); - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -3138,7 +3248,7 @@ test("agent run marks the run failed when worktree setup fails before launch", a AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", }); - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -3209,7 +3319,7 @@ test("agent run restores protected instruction file modes when runner launch fai let hardenedPath = ""; - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -3287,7 +3397,7 @@ test("agent run marks a zero-exit runner as failed when it does not write a hand AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", }); - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -3358,7 +3468,7 @@ test("agent run records a user handoff without submitting the task", async (t) = AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", }); - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -3440,7 +3550,7 @@ test("agent run records blocked result file metadata on the run record", async ( AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", }); - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -3552,7 +3662,7 @@ test("agent run blocks Cursor in strict mode when runner policy cannot be enforc AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", }); - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -3621,7 +3731,7 @@ test("agent run blocks before launching Codex when denied read files are present AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", }); - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -3707,7 +3817,7 @@ test("agent run emits runner_failed when a consumed handoff leaves task state un AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", }); - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -3803,7 +3913,7 @@ test("agent run blocks after Codex creates denied write files", async (t) => { AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", }); - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -3910,7 +4020,7 @@ test("agent run uses Cursor external sandbox wrappers instead of GUI fallback", AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", }); - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -4014,7 +4124,7 @@ test("agent run starts a resolved task with a historical awaiting_user run", asy AGENTRAIL_REPO_ALLOWLIST: "oxnw/agentrail", }); - const exitCode = await runCli(["agent", "run", "--once"], { + const exitCode = await runCli(["agent", "run", "--agent-id", "agt_runner", "--once"], { cwd: repoRoot, stdout, stderr, @@ -4079,6 +4189,34 @@ test("run current and run actions read managed run context from a local snapshot label: "Finish the code change, leave worktree changes in place, write the result file, then report completion.", }, ], + roleBrief: { + templateId: "frontend-ui", + templateName: "Frontend/UI", + templateSourceKind: "built_in", + description: "Handles frontend work.", + instructions: "Follow existing UI patterns.", + preferredAreas: [ + { + area: "frontend_source", + paths: ["app"], + }, + ], + cautionAreas: [], + forbiddenAreas: [], + checks: { + required: [], + suggested: [], + }, + evidence: { + required: [], + whenApplicable: [], + }, + escalation: { + askUserWhen: [], + }, + commands: {}, + warnings: [], + }, }, availableActions: ["submit"], }; @@ -4104,6 +4242,8 @@ test("run current and run actions read managed run context from a local snapshot assert.equal(currentExitCode, 0, currentStderr.toString()); assert.match(currentStdout.toString(), /Run: run_cli/); assert.match(currentStdout.toString(), /Task: AGEA-CLI Show current run/); + assert.match(currentStdout.toString(), /Role: Frontend\/UI \(frontend-ui\)/); + assert.match(currentStdout.toString(), /Preferred: frontend_source -> app/); assert.match(currentStdout.toString(), /Available actions: submit/); const actionsStdout = createMemoryWriter(); @@ -4400,7 +4540,10 @@ function makeStoredRun(overrides: Partial & { runId: string }): async function writeAgentEnv(homePath: string, values: Record) { const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`); - await writeFile(path.join(homePath, "agent.env"), `${lines.join("\n")}\n`, { mode: 0o600 }); + const agentId = values.AGENTRAIL_AGENT_ID ?? "agt_runner"; + const envPath = path.join(homePath, "agents", `${agentId}.env`); + await mkdir(path.dirname(envPath), { recursive: true }); + await writeFile(envPath, `${lines.join("\n")}\n`, { mode: 0o600 }); } async function waitForCapturedTelemetryCount(captureRequests: unknown[], expectedCount: number) { diff --git a/test/docs-contract.test.js b/test/docs-contract.test.js index 2ff6067..4155618 100644 --- a/test/docs-contract.test.js +++ b/test/docs-contract.test.js @@ -47,9 +47,9 @@ test("local setup CLI contract defines two-phase setup and runner next commands" "agentrail server start", "agentrail agent create", "agentrail agent connect", - "agentrail doctor", + "agentrail doctor --agent-id agt_codex_local", "GET /tasks/mine?status=in_progress&limit=1", - "source .agentrail/agent.env && cd /path/to/target-repo && codex", + "agentrail agent run --agent-id agt_codex_local --once", 'claude --append-system-prompt-file "$AGENTRAIL_AGENT_RECIPE_PATH"', "cursor /path/to/target-repo", ]) { diff --git a/test/helpers/setup-doctor-fixture.ts b/test/helpers/setup-doctor-fixture.ts index 3d2c9c9..e37f190 100644 --- a/test/helpers/setup-doctor-fixture.ts +++ b/test/helpers/setup-doctor-fixture.ts @@ -11,7 +11,7 @@ import { RoutingControlPlane } from "../../src/intake-routing-control-plane.ts"; import { RoutingRuleStore } from "../../src/routing-rule-store.ts"; import { TaskEventStore } from "../../src/task-event-store.ts"; import { createSetupConfig, type DetectedRepoContext } from "../../src/cli/setup-config.ts"; -import { currentAgentEnvPathForHome, operatorEnvPathForHome, recipePathForHome } from "../../src/cli/agentrail-home.ts"; +import { managedAgentEnvPathForHome, operatorEnvPathForHome, recipePathForHome } from "../../src/cli/agentrail-home.ts"; import { writeSetupFiles } from "../../src/cli/setup-files.ts"; const now = () => new Date("2026-05-06T00:00:00Z"); @@ -159,9 +159,10 @@ export async function writeDoctorRepo({ homePath, config, }); - await mkdir(homePath, { recursive: true }); + const agentEnvPath = managedAgentEnvPathForHome(homePath, agentId); + await mkdir(path.dirname(agentEnvPath), { recursive: true }); await writeFile( - currentAgentEnvPathForHome(homePath), + agentEnvPath, [ `AGENTRAIL_BASE_URL=${baseUrl}`, `AGENTRAIL_API_KEY=${agentApiKey}`, diff --git a/test/init-local-bootstrap.test.ts b/test/init-local-bootstrap.test.ts index 38032a3..97c9118 100644 --- a/test/init-local-bootstrap.test.ts +++ b/test/init-local-bootstrap.test.ts @@ -10,6 +10,7 @@ import { runCli } from "../src/cli/index.ts"; import { withTemporaryLocalServer } from "../src/cli/local-bootstrap.ts"; import { createSetupConfig } from "../src/cli/setup-config.ts"; import { AgentAuthStore } from "../src/agent-auth-store.ts"; +import { repoMapPathForHome } from "../src/repo-map.ts"; import { installFakeExecutableOnPath } from "./helpers/fake-executable.ts"; test("init creates local operator state and writes local setup env files", async (t) => { @@ -59,11 +60,13 @@ test("init creates local operator state and writes local setup env files", async const operatorEnv = await readFile(path.join(agentrailHome, "operator.env"), "utf8"); const authStore = await readFile(path.join(agentrailHome, "stores", "agent-auth.json"), "utf8"); const serverEnv = await readFile(path.join(agentrailHome, "server.env"), "utf8"); + const repoMap = await readFile(repoMapPathForHome(agentrailHome, "oxnw/agentrail"), "utf8"); assert.match(operatorEnv, /AGENTRAIL_OPERATOR_KEY=ar_live_/); assert.match(operatorEnv, /AGENTRAIL_OPERATOR_KEY_ID=akey_/); assert.match(authStore, /agt_operator/); assert.match(serverEnv, new RegExp(`AGENTRAIL_AGENT_AUTH_STORE_PATH=${escapeForRegExp(path.join(agentrailHome, "stores", "agent-auth.json"))}`)); + assert.match(repoMap, /schemaVersion: agentrail\.repo-map\/v1/u); }); test("standalone agent create starts a temporary local server when the configured server is stopped", async (t) => { @@ -125,7 +128,6 @@ test("standalone agent create starts a temporary local server when the configure "oxnw/agentrail", "--capability-tags", "code,tests", - "--set-default-env", ], { cwd: repoRoot, stdinIsTTY: false, @@ -142,6 +144,74 @@ test("standalone agent create starts a temporary local server when the configure assert.match(envText, new RegExp(`AGENTRAIL_BASE_URL=${escapeForRegExp(baseUrl)}`)); }); +test("repo add writes a repo map for the added local repo", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-repo-add-primary-")); + const addedRepo = await mkdtemp(path.join(os.tmpdir(), "agentrail-repo-add-added-")); + const agentrailHome = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const stdout = createMemoryWriter(); + const stderr = createMemoryWriter(); + const previousHome = process.env.AGENTRAIL_HOME; + process.env.AGENTRAIL_HOME = agentrailHome; + await writeFile(path.join(addedRepo, "package.json"), JSON.stringify({ scripts: { test: "node --test" } }), "utf8"); + + t.after(async () => { + await rm(repoRoot, { recursive: true, force: true }); + await rm(addedRepo, { recursive: true, force: true }); + await rm(agentrailHome, { recursive: true, force: true }); + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + }); + + const initExitCode = await runCli([ + "init", + "--yes", + "--mode", + "server", + "--provider-mode", + "disabled", + "--repo", + repoRoot, + "--base-url", + "http://127.0.0.1:3000", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout, + stderr, + detectRepoContext: async () => ({ + repoPath: repoRoot, + remoteSlug: "oxnw/agentrail", + defaultBranch: "main", + gitIgnoreHasAgentrail: true, + }), + }); + assert.equal(initExitCode, 0, stderr.toString()); + + const addStdout = createMemoryWriter(); + const addStderr = createMemoryWriter(); + const addExitCode = await runCli([ + "repo", + "add", + "--repo", + addedRepo, + "--slug", + "oxnw/agentrail-added", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout: addStdout, + stderr: addStderr, + }); + + assert.equal(addExitCode, 0, addStderr.toString()); + assert.match(addStdout.toString(), /Wrote repo map/); + const repoMap = await readFile(repoMapPathForHome(agentrailHome, "oxnw/agentrail-added"), "utf8"); + assert.match(repoMap, /package_manifests:/u); + assert.match(repoMap, /commands:/u); +}); + test("temporary local verification reuses an already-running healthy server", async (t) => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-init-reuse-server-")); const server = http.createServer((request, response) => { diff --git a/test/intake-routing-control-plane.test.ts b/test/intake-routing-control-plane.test.ts index c75a517..6481b2b 100644 --- a/test/intake-routing-control-plane.test.ts +++ b/test/intake-routing-control-plane.test.ts @@ -1435,6 +1435,25 @@ test("RoutingControlPlane rejects malformed agent profiles before storing them", assert.equal(routing.getAgentProfile("agt_bad"), null); }); +test("RoutingControlPlane stores role template ids on agent profiles", () => { + const { routing } = createControlPlane(); + + routing.replaceAgentProfile("agt_backend", { + displayName: "Backend", + role: "backend-api", + roleTemplateId: "backend-api", + status: "active", + capabilityTags: ["backend", "api"], + ownershipTags: ["backend_source"], + repoAllowlist: ["oxnw/agentrail"], + maxConcurrentTasks: 1, + sourceRef: "AGEA-99", + changeReason: "seed profile with template", + }, "agt_router"); + + assert.equal(routing.getAgentProfile("agt_backend")?.roleTemplateId, "backend-api"); +}); + test("RoutingControlPlane rejects agent profile payloads outside the published contract", () => { const { routing } = createControlPlane(); @@ -1462,6 +1481,18 @@ test("RoutingControlPlane rejects agent profile payloads outside the published c sourceRef: "AGEA-99", changeReason: "malformed profile", }, + { + displayName: "Bad Profile", + role: "engineer", + roleTemplateId: "Backend/API", + status: "active", + capabilityTags: [], + ownershipTags: [], + repoAllowlist: [], + maxConcurrentTasks: 1, + sourceRef: "AGEA-99", + changeReason: "malformed profile", + }, { displayName: "Bad Profile", role: "engineer", diff --git a/test/managed-run-role-brief.test.ts b/test/managed-run-role-brief.test.ts new file mode 100644 index 0000000..d16d3eb --- /dev/null +++ b/test/managed-run-role-brief.test.ts @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { AgentRoleTemplate } from "../src/agent-role-template.ts"; +import { AGENT_ROLE_TEMPLATE_SCHEMA_VERSION } from "../src/agent-role-template.ts"; +import { + buildManagedRunRoleBrief, + renderManagedRunRoleBriefForPrompt, +} from "../src/managed-run-role-brief.ts"; +import type { AgentRailRepoMap } from "../src/repo-map.ts"; +import { AGENTRAIL_REPO_MAP_SCHEMA_VERSION } from "../src/repo-map.ts"; + +function template(overrides: Partial = {}): AgentRoleTemplate { + return { + schemaVersion: AGENT_ROLE_TEMPLATE_SCHEMA_VERSION, + id: "frontend-ui", + name: "Frontend/UI", + description: "Implements product UI changes.", + routing: { + capabilityTags: ["frontend", "ui"], + preferredLabels: ["frontend"], + taskTypes: ["ui"], + avoidLabels: [], + }, + runtime: { + cleanSlate: true, + tools: { + builtin: {}, + mcp: {}, + skills: {}, + }, + }, + instructions: "Prefer small UI changes, preserve existing design patterns, and validate visible behavior.", + workspace: { + preferredAreas: ["frontend_source", "frontend_components", "styling"], + cautionAreas: ["api_contracts"], + forbiddenAreas: ["secrets"], + }, + checks: { + required: ["Run the smallest relevant UI validation."], + suggested: ["Use browser verification when visual behavior changes."], + }, + evidence: { + required: ["List changed UI files."], + whenApplicable: ["Attach screenshot notes when browser verification is used."], + }, + escalation: { + askUserWhen: ["The requested design is ambiguous."], + }, + ...overrides, + }; +} + +function repoMap(overrides: Partial = {}): AgentRailRepoMap { + return { + schemaVersion: AGENTRAIL_REPO_MAP_SCHEMA_VERSION, + areas: { + frontend_source: { paths: ["app", "src/app"] }, + frontend_components: { paths: ["components", "src/components"] }, + styling: { paths: ["styles", "tailwind.config.ts"] }, + api_contracts: { paths: ["docs/api"] }, + secrets: { paths: [".env"] }, + }, + ignoredPaths: [".git", "node_modules"], + generatedPaths: ["dist", "coverage"], + commands: { + test: ["npm test"], + typecheck: ["npm run typecheck"], + }, + ...overrides, + }; +} + +test("buildManagedRunRoleBrief maps role areas to concrete repo paths", () => { + const brief = buildManagedRunRoleBrief({ + template: template(), + templateSourceKind: "built_in", + repoMap: repoMap(), + repoMapStatus: "loaded", + repoMapPath: "/tmp/agentrail/repo-maps/owner-repo.yaml", + }); + + assert.equal(brief.templateId, "frontend-ui"); + assert.equal(brief.templateName, "Frontend/UI"); + assert.deepEqual(brief.preferredAreas.map((area) => area.area), [ + "frontend_source", + "frontend_components", + "styling", + ]); + assert.deepEqual(brief.preferredAreas[0]?.paths, ["app", "src/app"]); + assert.deepEqual(brief.cautionAreas[0]?.paths, ["docs/api"]); + assert.deepEqual(brief.forbiddenAreas[0]?.paths, [".env"]); + assert.deepEqual(brief.commands.test, ["npm test"]); + assert.deepEqual(brief.warnings, []); +}); + +test("buildManagedRunRoleBrief keeps unmapped template areas as warnings", () => { + const brief = buildManagedRunRoleBrief({ + template: template({ + workspace: { + preferredAreas: ["frontend_source", "unknown_area"], + cautionAreas: [], + forbiddenAreas: [], + }, + }), + templateSourceKind: "custom", + repoMap: repoMap(), + repoMapStatus: "loaded", + repoMapPath: "/tmp/agentrail/repo-maps/owner-repo.yaml", + }); + + assert.deepEqual(brief.preferredAreas.map((area) => area.area), ["frontend_source", "unknown_area"]); + assert.deepEqual(brief.preferredAreas[1]?.paths, []); + assert.match(brief.warnings.join("\n"), /unknown_area/); +}); + +test("buildManagedRunRoleBrief handles missing repo maps without failing", () => { + const brief = buildManagedRunRoleBrief({ + template: template(), + templateSourceKind: "built_in", + repoMap: null, + repoMapStatus: "missing", + repoMapPath: "/tmp/agentrail/repo-maps/owner-repo.yaml", + }); + + assert.equal(brief.templateId, "frontend-ui"); + assert.deepEqual(brief.preferredAreas[0]?.paths, []); + assert.match(brief.warnings.join("\n"), /Repo map missing/); +}); + +test("renderManagedRunRoleBriefForPrompt is compact and includes mapped paths", () => { + const brief = buildManagedRunRoleBrief({ + template: template(), + templateSourceKind: "built_in", + repoMap: repoMap(), + repoMapStatus: "loaded", + repoMapPath: "/tmp/agentrail/repo-maps/owner-repo.yaml", + }); + + const rendered = renderManagedRunRoleBriefForPrompt(brief); + + assert.match(rendered, /Agent role: Frontend\/UI \(frontend-ui\)/); + assert.match(rendered, /Preferred areas:/); + assert.match(rendered, /frontend_source: app, src\/app/); + assert.match(rendered, /Caution areas:/); + assert.match(rendered, /api_contracts: docs\/api/); + assert.match(rendered, /Forbidden areas:/); + assert.match(rendered, /secrets: \.env/); + assert.match(rendered, /Evidence when applicable:/); + assert.match(rendered, /Attach screenshot notes when browser verification is used\./); + assert.ok(rendered.length < 2500); +}); diff --git a/test/repo-map.test.ts b/test/repo-map.test.ts new file mode 100644 index 0000000..0a60bc6 --- /dev/null +++ b/test/repo-map.test.ts @@ -0,0 +1,152 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + AGENTRAIL_REPO_MAP_SCHEMA_VERSION, + ensureRepoMapForRepo, + generateStarterRepoMap, + parseRepoMapYaml, + repoMapPathForHome, +} from "../src/repo-map.ts"; + +function makeTempDir(t: test.TestContext, prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + t.after(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + return dir; +} + +test("generates a conservative starter repo map from existing repo structure", async (t) => { + const repoRoot = makeTempDir(t, "agentrail-repo-map-"); + fs.mkdirSync(path.join(repoRoot, "app"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, "components", "ui"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, "test", "fixtures"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, ".github", "workflows"), { recursive: true }); + fs.writeFileSync(path.join(repoRoot, "README.md"), "# Example\n", "utf8"); + fs.writeFileSync(path.join(repoRoot, "package.json"), JSON.stringify({ + scripts: { + test: "node --test", + lint: "eslint .", + typecheck: "tsc --noEmit", + build: "tsc -p tsconfig.build.json", + }, + }), "utf8"); + + const repoMap = await generateStarterRepoMap(repoRoot); + + assert.equal(repoMap.schemaVersion, AGENTRAIL_REPO_MAP_SCHEMA_VERSION); + assert.deepEqual(repoMap.areas.frontend_source?.paths, ["app/**"]); + assert.deepEqual(repoMap.areas.frontend_components?.paths, ["components/**"]); + assert.deepEqual(repoMap.areas.design_system?.paths, ["components/ui/**"]); + assert.deepEqual(repoMap.areas.tests?.paths, ["test/**"]); + assert.deepEqual(repoMap.areas.ci_configuration?.paths, [".github/workflows/**"]); + assert.deepEqual(repoMap.areas.readme?.paths, ["README.md"]); + assert.deepEqual(repoMap.commands.test, ["npm run test"]); + assert.deepEqual(repoMap.commands.lint, ["npm run lint"]); + assert.deepEqual(repoMap.commands.typecheck, ["npm run typecheck"]); + assert.deepEqual(repoMap.commands.build, ["npm run build"]); +}); + +test("repo map parser rejects unsafe paths and unknown keys", () => { + assert.throws( + () => parseRepoMapYaml(`schemaVersion: ${AGENTRAIL_REPO_MAP_SCHEMA_VERSION} +areas: + frontend_source: + paths: + - ../src +ignoredPaths: [] +generatedPaths: [] +commands: {} +`), + /escapes the repo/u, + ); + + assert.throws( + () => parseRepoMapYaml(`schemaVersion: ${AGENTRAIL_REPO_MAP_SCHEMA_VERSION} +areas: + frontend_source: + paths: + - src/** + owners: + - team-ui +ignoredPaths: [] +generatedPaths: [] +commands: {} +`), + /unknown keys: owners/u, + ); +}); + +test("ensureRepoMapForRepo creates missing map and preserves existing map", async (t) => { + const repoRoot = makeTempDir(t, "agentrail-repo-map-repo-"); + const homePath = makeTempDir(t, "agentrail-repo-map-home-"); + fs.mkdirSync(path.join(repoRoot, "src"), { recursive: true }); + const repo = { + path: repoRoot, + slug: "oxnw/agentrail", + defaultBranch: "main", + }; + + const created = await ensureRepoMapForRepo({ homePath, repo }); + const existing = await ensureRepoMapForRepo({ homePath, repo }); + const repoMapPath = repoMapPathForHome(homePath, repo.slug); + + assert.equal(created.status, "created"); + assert.equal(created.path, repoMapPath); + assert.equal(existing.status, "exists"); + assert.equal(fs.existsSync(repoMapPath), true); + const contents = fs.readFileSync(repoMapPath, "utf8"); + assert.match(contents, /schemaVersion: agentrail\.repo-map\/v1/u); + assert.match(contents, /application_source:/u); +}); + +test("ensureRepoMapForRepo backs up and regenerates an invalid map", async (t) => { + const repoRoot = makeTempDir(t, "agentrail-repo-map-repo-"); + const homePath = makeTempDir(t, "agentrail-repo-map-home-"); + fs.mkdirSync(path.join(repoRoot, "src"), { recursive: true }); + const repo = { + path: repoRoot, + slug: "oxnw/agentrail", + defaultBranch: "main", + }; + const repoMapPath = repoMapPathForHome(homePath, repo.slug); + fs.mkdirSync(path.dirname(repoMapPath), { recursive: true }); + const invalidMap = [ + "schemaVersion: agentrail.repo-map/v1", + "areas:", + " application_source:", + " paths:", + " - ../src", + "ignoredPaths: []", + "generatedPaths: []", + "commands: {}", + "", + ].join("\n"); + fs.writeFileSync(repoMapPath, invalidMap, "utf8"); + + const repaired = await ensureRepoMapForRepo({ homePath, repo }); + const repairedContents = fs.readFileSync(repoMapPath, "utf8"); + const backupPath = `${repoMapPath}.invalid.bak`; + + assert.equal(repaired.status, "created"); + assert.equal(repaired.path, repoMapPath); + assert.match(repaired.reason ?? "", /Replaced invalid repo map/u); + assert.equal(fs.readFileSync(backupPath, "utf8"), invalidMap); + assert.match(repairedContents, /schemaVersion: agentrail\.repo-map\/v1/u); + assert.match(repairedContents, /application_source:/u); + assert.doesNotMatch(repairedContents, /\.\.\/src/u); +}); + +test("repoMapPathForHome uses collision-resistant stems for similar slugs", (t) => { + const homePath = makeTempDir(t, "agentrail-repo-map-home-"); + const first = repoMapPathForHome(homePath, "foo/bar__baz"); + const second = repoMapPathForHome(homePath, "foo__bar/baz"); + + assert.notEqual(first, second); + assert.match(path.basename(first), /^foo__bar__baz--[a-f0-9]{12}\.yaml$/u); + assert.match(path.basename(second), /^foo__bar__baz--[a-f0-9]{12}\.yaml$/u); +}); diff --git a/test/runner-execution-policy.test.ts b/test/runner-execution-policy.test.ts index 0611c62..dd8b706 100644 --- a/test/runner-execution-policy.test.ts +++ b/test/runner-execution-policy.test.ts @@ -140,6 +140,37 @@ test("Codex filesystem preflight blocks denied files that the sandbox cannot hid assert.deepEqual(result.matches, [".env"]); }); +test("Codex filesystem preflight allows env template files but still blocks real env files", async (t) => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "agentrail-policy-env-template-")); + const worktreePath = path.join(tempDir, "worktree"); + const runDir = path.join(tempDir, "run"); + t.after(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + await mkdir(path.join(worktreePath, "config"), { recursive: true }); + await mkdir(runDir, { recursive: true }); + await writeFile(path.join(worktreePath, ".env.example"), "TOKEN=example\n", "utf8"); + await writeFile(path.join(worktreePath, "config", ".env.sample"), "TOKEN=sample\n", "utf8"); + + const plan = compileRunnerExecutionPlan({ + ...basePlanInput, + runner: "codex", + worktreePath, + runDir, + recipePath: path.join(runDir, "agent-recipes.md"), + }); + + const allowed = await validateRunnerPolicyFilesystemPreflight(plan); + assert.equal(allowed.ok, true); + + await writeFile(path.join(worktreePath, ".env.local"), "SECRET=value\n", "utf8"); + const blocked = await validateRunnerPolicyFilesystemPreflight(plan); + + assert.equal(blocked.ok, false); + assert.equal(blocked.reason, "runner_policy_denied_files_present"); + assert.deepEqual(blocked.matches, [".env.local"]); +}); + test("Codex filesystem post-run validation blocks denied write path changes", async (t) => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "agentrail-policy-denied-postrun-")); const worktreePath = path.join(tempDir, "worktree"); diff --git a/test/setup-doctor.test.ts b/test/setup-doctor.test.ts index b4cf09f..2bbffe4 100644 --- a/test/setup-doctor.test.ts +++ b/test/setup-doctor.test.ts @@ -14,6 +14,7 @@ import { writeDoctorRepo, } from "./helpers/setup-doctor-fixture.ts"; import { currentAgentEnvPathForHome } from "../src/cli/agentrail-home.ts"; +import { repoMapPathForHome } from "../src/repo-map.ts"; import { createMemoryWriter } from "./helpers/memory-writer.ts"; let previousDoctorPath: string | undefined; @@ -63,7 +64,7 @@ test("agentrail doctor fails when setup state exists but no assigned onboarding repoAllowlist: harness.repoAllowlist, }); - const exitCode = await runCli(["doctor"], { + const exitCode = await runCli(["doctor", "--agent-id", harness.agentId], { cwd: repoRoot, stdinIsTTY: false, stdoutIsTTY: false, @@ -121,7 +122,7 @@ test("agentrail doctor does not pass on an unrelated assigned in-progress task", repoAllowlist: harness.repoAllowlist, }); - const exitCode = await runCli(["doctor"], { + const exitCode = await runCli(["doctor", "--agent-id", harness.agentId], { cwd: repoRoot, stdinIsTTY: false, stdoutIsTTY: false, @@ -135,7 +136,7 @@ test("agentrail doctor does not pass on an unrelated assigned in-progress task", assert.equal(stdout.toString(), ""); }); -test("agentrail doctor honors an explicit --env-file over the shared current-agent alias", async (t) => { +test("agentrail doctor honors an explicit --env-file over a legacy default env file", async (t) => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-doctor-explicit-env-")); const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); const stdout = createMemoryWriter(); @@ -234,6 +235,179 @@ test("agentrail doctor honors an explicit --env-file over the shared current-age assert.doesNotMatch(serialized, /agt_setup/); }); +test("agentrail doctor warns when a role template has unmapped preferred repo areas", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-doctor-repo-map-warn-")); + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const stdout = createMemoryWriter(); + const stderr = createMemoryWriter(); + const previousSetupApiKey = process.env.AGENTRAIL_SETUP_API_KEY; + const previousHome = process.env.AGENTRAIL_HOME; + let harness: SetupDoctorHarness | null = null; + t.after(createCleanup({ + previousSetupApiKey, + previousHome, + repoRoot, + homePath, + getHarness: () => harness, + })); + process.env.AGENTRAIL_HOME = homePath; + + harness = await createSetupDoctorHarness(); + process.env.AGENTRAIL_SETUP_API_KEY = harness.operatorApiKey; + + await seedSetupVerificationTask({ + baseUrl: harness.baseUrl, + operatorApiKey: harness.operatorApiKey, + agentId: harness.agentId, + }); + + await writeDoctorRepo({ + repoRoot, + homePath, + baseUrl: harness.baseUrl, + agentApiKey: harness.agentApiKey, + agentId: harness.agentId, + repoAllowlist: harness.repoAllowlist, + }); + await upsertDoctorProfileRoleTemplate({ + baseUrl: harness.baseUrl, + operatorApiKey: harness.operatorApiKey, + agentId: harness.agentId, + repoAllowlist: harness.repoAllowlist, + roleTemplateId: "frontend-ui", + }); + + const exitCode = await runCli(["doctor", "--agent-id", harness.agentId], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout, + stderr, + }); + + assert.equal(exitCode, 0, stderr.toString()); + assert.match(stdout.toString(), /WARN repo_map/i); + assert.match(stdout.toString(), /frontend-ui has unmapped preferred areas/i); + assert.equal(stderr.toString(), ""); +}); + +test("agentrail doctor warns instead of passing repo map when profile lookup is unknown", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-doctor-repo-map-profile-unknown-")); + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const stdout = createMemoryWriter(); + const stderr = createMemoryWriter(); + const previousSetupApiKey = process.env.AGENTRAIL_SETUP_API_KEY; + const previousHome = process.env.AGENTRAIL_HOME; + let harness: SetupDoctorHarness | null = null; + t.after(createCleanup({ + previousSetupApiKey, + previousHome, + repoRoot, + homePath, + getHarness: () => harness, + })); + process.env.AGENTRAIL_HOME = homePath; + + harness = await createSetupDoctorHarness(); + await seedSetupVerificationTask({ + baseUrl: harness.baseUrl, + operatorApiKey: harness.operatorApiKey, + agentId: harness.agentId, + }); + await writeDoctorRepo({ + repoRoot, + homePath, + baseUrl: harness.baseUrl, + agentApiKey: harness.agentApiKey, + agentId: harness.agentId, + repoAllowlist: harness.repoAllowlist, + }); + + const exitCode = await runCli([ + "doctor", + "--agent-id", + harness.agentId, + "--setup-api-key", + "ar_live_invalid", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout, + stderr, + }); + + assert.equal(exitCode, 1); + assert.match(stderr.toString(), /FAIL profile/i); + assert.match(stderr.toString(), /WARN repo_map/i); + assert.match(stderr.toString(), /could not verify whether this agent uses a role template/i); + assert.equal(stdout.toString(), ""); +}); + +test("agentrail doctor fails when the repo map is invalid", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-doctor-repo-map-invalid-")); + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const stdout = createMemoryWriter(); + const stderr = createMemoryWriter(); + const previousSetupApiKey = process.env.AGENTRAIL_SETUP_API_KEY; + const previousHome = process.env.AGENTRAIL_HOME; + let harness: SetupDoctorHarness | null = null; + t.after(createCleanup({ + previousSetupApiKey, + previousHome, + repoRoot, + homePath, + getHarness: () => harness, + })); + process.env.AGENTRAIL_HOME = homePath; + + harness = await createSetupDoctorHarness(); + process.env.AGENTRAIL_SETUP_API_KEY = harness.operatorApiKey; + + await seedSetupVerificationTask({ + baseUrl: harness.baseUrl, + operatorApiKey: harness.operatorApiKey, + agentId: harness.agentId, + }); + + await writeDoctorRepo({ + repoRoot, + homePath, + baseUrl: harness.baseUrl, + agentApiKey: harness.agentApiKey, + agentId: harness.agentId, + repoAllowlist: harness.repoAllowlist, + }); + await writeFile( + repoMapPathForHome(homePath, harness.repoAllowlist[0] ?? "oxnw/agentrail"), + [ + "schemaVersion: agentrail.repo-map/v1", + "areas:", + " frontend_source:", + " paths:", + " - ../src", + "ignoredPaths: []", + "generatedPaths: []", + "commands: {}", + "", + ].join("\n"), + "utf8", + ); + + const exitCode = await runCli(["doctor", "--agent-id", harness.agentId], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout, + stderr, + }); + + assert.equal(exitCode, 1); + assert.match(stderr.toString(), /FAIL repo_map/i); + assert.match(stderr.toString(), /escapes the repo/i); + assert.equal(stdout.toString(), ""); +}); + test("agentrail doctor --config respects explicit telemetry opt-out on production path", async (t) => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-doctor-config-telemetry-")); const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); @@ -326,6 +500,7 @@ test("agentrail doctor --config respects explicit telemetry opt-out on productio }); assert.equal(exitCode, 1); + assert.match(stderr.toString(), /requires an explicit agent identity/u); assert.match(stderr.toString(), /AGENTRAIL_API_KEY, AGENTRAIL_AGENT_ID/u); assert.equal(stdout.toString(), ""); assert.equal(telemetryBodies.length, 0); @@ -382,7 +557,7 @@ test("agentrail doctor verifies AI routing can launch the configured local runne routingClassifierModel: "gpt-test", }); - const exitCode = await runCli(["doctor"], { + const exitCode = await runCli(["doctor", "--agent-id", harness.agentId], { cwd: repoRoot, stdinIsTTY: false, stdoutIsTTY: false, @@ -437,7 +612,7 @@ test("agentrail doctor fails runner policy when the selected runner executable i repoAllowlist: harness.repoAllowlist, }); - const exitCode = await runCli(["doctor"], { + const exitCode = await runCli(["doctor", "--agent-id", harness.agentId], { cwd: repoRoot, stdinIsTTY: false, stdoutIsTTY: false, @@ -489,7 +664,7 @@ test("agentrail doctor fails AI routing when no local executable mapping exists" routingClassifierRunner: "custom", }); - const exitCode = await runCli(["doctor"], { + const exitCode = await runCli(["doctor", "--agent-id", harness.agentId], { cwd: repoRoot, stdinIsTTY: false, stdoutIsTTY: false, @@ -548,3 +723,40 @@ async function installFakeExecutable(binDir: string, name: string): Promise { + const response = await fetch(`${baseUrl}/operator/routing/agent-profiles/${agentId}`, { + method: "PUT", + headers: { + authorization: `Bearer ${operatorApiKey}`, + "content-type": "application/json", + "idempotency-key": `profile-role-template:${agentId}:${roleTemplateId}`, + }, + body: JSON.stringify({ + displayName: "Setup Agent", + role: roleTemplateId, + roleTemplateId, + status: "active", + capabilityTags: ["frontend", "ui"], + ownershipTags: ["frontend_source"], + repoAllowlist, + maxConcurrentTasks: 1, + sourceRef: "agentrail-doctor:test", + changeReason: "Seed setup doctor role template state.", + }), + }); + const bodyText = await response.text(); + assert.equal(response.status, 200, bodyText); +} diff --git a/test/setup-files.test.ts b/test/setup-files.test.ts index 8a7c386..f783bc8 100644 --- a/test/setup-files.test.ts +++ b/test/setup-files.test.ts @@ -7,6 +7,7 @@ import test from "node:test"; import { createSetupConfig, type DetectedRepoContext } from "../src/cli/setup-config.ts"; import { writeSetupFiles } from "../src/cli/setup-files.ts"; +import { repoMapPathForHome } from "../src/repo-map.ts"; test("writeSetupFiles creates local setup files without agent secrets", async (t) => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-setup-")); @@ -41,10 +42,11 @@ test("writeSetupFiles creates local setup files without agent secrets", async (t const envPath = path.join(homePath, "agent.env"); const readmePath = path.join(homePath, "README.md"); const recipePath = path.join(homePath, "agent-recipes.md"); + const repoMapPath = repoMapPathForHome(homePath, "oxnw/agentrail"); assert.deepEqual( result.writtenPaths.sort(), - [configPath, envExamplePath, serverEnvPath, readmePath, recipePath].sort(), + [configPath, envExamplePath, serverEnvPath, readmePath, recipePath, repoMapPath].sort(), ); const configText = await readFile(configPath, "utf8"); @@ -53,6 +55,7 @@ test("writeSetupFiles creates local setup files without agent secrets", async (t const serverEnv = await readFile(serverEnvPath, "utf8"); const readme = await readFile(readmePath, "utf8"); const recipe = await readFile(recipePath, "utf8"); + const repoMap = await readFile(repoMapPath, "utf8"); assert.equal(configBody.mode, "server"); assert.equal(configBody.server.baseUrl, "http://127.0.0.1:3000"); @@ -79,9 +82,15 @@ test("writeSetupFiles creates local setup files without agent secrets", async (t assert.match(readme, /choose a runner, choose permissions, and create another local agent/i); assert.match(readme, /temporary local API/i); assert.match(readme, /already signed in to the runner you choose on this machine/i); - assert.match(readme, /local agent profile for this machine/i); + assert.match(readme, /separate runner connection file for each agent/i); + assert.match(readme, /repo-maps/i); + assert.match(readme, /editable repo maps/i); + assert.match(readme, /abstract areas like `frontend_source`, `tests`, or `ci_configuration`/i); + assert.match(readme, /role template references areas that are missing from the repo map/i); + assert.doesNotMatch(readme, /local agent profile for this machine/i); assert.match(readme, /server loads `~\/\.agentrail\/server\.env` automatically/i); - assert.match(readme, /agentrail doctor/i); + assert.match(readme, /agentrail doctor --agent-id /i); + assert.match(readme, /agentrail agent run --once --agent-id /i); assert.match(readme, /setup is only complete when `\/tasks\/mine\?status=in_progress&limit=1` returns assigned work/i); assert.match(readme, /agents\/\.env/i); assert.match(readme, /First connected repo/i); @@ -95,7 +104,7 @@ test("writeSetupFiles creates local setup files without agent secrets", async (t assert.doesNotMatch(readme, /Linear should send webhooks to/i); assert.match(readme, /Provider secrets stay out of `config\.json`/i); assert.match(readme, /Rules-only routing is enabled/i); - assert.match(readme, /Local agent access/i); + assert.match(readme, /Local agent safety/i); assert.match(readme, /`strict` runner policy/i); assert.match(readme, /strips broad provider credentials/i); assert.match(readme, /AgentRail strips broad provider credentials/i); @@ -129,6 +138,7 @@ test("writeSetupFiles creates local setup files without agent secrets", async (t // Safety rules. assert.match(recipe, /Do not expose secrets in logs, prompts, commits, or final summaries\./); + assert.match(repoMap, /schemaVersion: agentrail\.repo-map\/v1/); }); test("writeSetupFiles documents ai-assisted routing", async (t) => { diff --git a/test/setup-onboarding-e2e.test.ts b/test/setup-onboarding-e2e.test.ts index 8c42a44..ab8d163 100644 --- a/test/setup-onboarding-e2e.test.ts +++ b/test/setup-onboarding-e2e.test.ts @@ -54,7 +54,7 @@ test("agentrail doctor passes after the full local onboarding smoke seeds profil agentId: harness.agentId, }); - const exitCode = await runCli(["doctor"], { + const exitCode = await runCli(["doctor", "--agent-id", harness.agentId], { cwd: repoRoot, stdinIsTTY: false, stdoutIsTTY: false, diff --git a/test/setup-wizard.test.ts b/test/setup-wizard.test.ts index 2b8de40..e141fc3 100644 --- a/test/setup-wizard.test.ts +++ b/test/setup-wizard.test.ts @@ -79,7 +79,7 @@ test("runCli starts the guided setup wizard in TTY mode by default", async () => assert.match(prompt.notes[0]?.body ?? "", /Provider mode: real/); assert.match(prompt.notes[0]?.body ?? "", /Routing mode: Rules only/); assert.match(prompt.notes[0]?.body ?? "", /Code review before ship: Follow GitHub repository rules/); - assert.match(prompt.notes[0]?.body ?? "", /Local agent access: Strict/); + assert.match(prompt.notes[0]?.body ?? "", /Local agent safety: Strict sandbox/); assert.match(prompt.notes[0]?.body ?? "", /Local API base URL: http:\/\/127\.0\.0\.1:4100/); assert.match(prompt.notes[0]?.body ?? "", /Telemetry: enabled/); assert.match(prompt.notes[0]?.body ?? "", /anonymous product telemetry to improve setup and agent reliability/i); @@ -128,7 +128,7 @@ test("runCli asks how AI routing should handle tasks without a suitable agent", { kind: "input", value: "http://127.0.0.1:3000" }, { kind: "select", value: "ai_assist" }, { kind: "select", value: "codex" }, - { kind: "input", value: "" }, + { kind: "select", value: "gpt-5.4" }, { kind: "select", value: "require_suitable_agent" }, { kind: "select", value: "github_rules" }, { kind: "select", value: "strict" }, @@ -168,15 +168,117 @@ test("runCli asks how AI routing should handle tasks without a suitable agent", assert.equal(exitCode, 0, stderr.toString()); assert.equal(stderr.toString(), ""); + assert.equal(prompt.interactions[5]?.message, "AI routing runner"); + assert.equal(prompt.interactions[6]?.message, "Codex routing model"); assert.equal(prompt.interactions[7]?.message, "When AI routing cannot find a suitable agent"); assert.equal(prompt.interactions[8]?.message, "Should AgentRail wait for code review before shipping PRs?"); - assert.equal(prompt.interactions[9]?.message, "Local agent access"); + assert.equal(prompt.interactions[9]?.message, "How tightly should AgentRail restrict local agents?"); assert.match(prompt.notes[0]?.body ?? "", /Routing mode: Use AI to route tasks to the right agents/); + assert.match(prompt.notes[0]?.body ?? "", /AI routing model: gpt-5\.4/); assert.match(prompt.notes[0]?.body ?? "", /No suitable agent policy: Require a suitable agent/); assert.match(prompt.notes[0]?.body ?? "", /Leave the task unassigned and show what agent skills or ownership areas are missing/i); + assert.equal(writes[0]?.config.routing.classifier.model, "gpt-5.4"); assert.equal(writes[0]?.config.routing.classifier.fallbackBehavior, "require_suitable_agent"); }); +test("runCli offers Claude Code model choices for AI routing", async () => { + const agentrailHome = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const previousHome = process.env.AGENTRAIL_HOME; + process.env.AGENTRAIL_HOME = agentrailHome; + const stdout = createMemoryWriter(); + const stderr = createMemoryWriter(); + const writes: Array<{ homePath?: string; repoRoot?: string; config: SetupConfig }> = []; + const prompt = new ScriptedPromptSession([ + { kind: "input", value: detectedRepo.repoPath }, + { kind: "input", value: `https://github.com/${detectedRepo.remoteSlug}` }, + { kind: "input", value: detectedRepo.defaultBranch }, + { kind: "input", value: "http://127.0.0.1:3000" }, + { kind: "select", value: "ai_assist" }, + { kind: "select", value: "claude-code" }, + { kind: "select", value: "claude-opus-4-5-20251101" }, + { kind: "select", value: "require_suitable_agent" }, + { kind: "select", value: "github_rules" }, + { kind: "select", value: "strict" }, + { kind: "confirm", value: false }, + { kind: "confirm", value: true }, + { kind: "confirm", value: false }, + { kind: "confirm", value: false }, + ]); + + const exitCode = await runCli(["init"], { + cwd: detectedRepo.repoPath, + stdinIsTTY: true, + stdoutIsTTY: true, + stdout, + stderr, + detectRepoContext: async () => detectedRepo, + createPrompt: () => prompt, + writeSetupFiles: async ({ homePath, repoRoot, config }) => { + writes.push({ homePath, repoRoot, config }); + return { writtenPaths: [] }; + }, + }); + + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + await rm(agentrailHome, { recursive: true, force: true }); + + assert.equal(exitCode, 0, stderr.toString()); + assert.equal(prompt.interactions[6]?.message, "Claude Code routing model"); + assert.equal(writes[0]?.config.routing.classifier.runner, "claude-code"); + assert.equal(writes[0]?.config.routing.classifier.model, "claude-opus-4-5-20251101"); + assert.match(prompt.notes[0]?.body ?? "", /AI routing model: claude-opus-4-5-20251101/); +}); + +test("runCli offers Cursor model choices for AI routing", async () => { + const agentrailHome = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const previousHome = process.env.AGENTRAIL_HOME; + process.env.AGENTRAIL_HOME = agentrailHome; + const stdout = createMemoryWriter(); + const stderr = createMemoryWriter(); + const writes: Array<{ homePath?: string; repoRoot?: string; config: SetupConfig }> = []; + const prompt = new ScriptedPromptSession([ + { kind: "input", value: detectedRepo.repoPath }, + { kind: "input", value: `https://github.com/${detectedRepo.remoteSlug}` }, + { kind: "input", value: detectedRepo.defaultBranch }, + { kind: "input", value: "http://127.0.0.1:3000" }, + { kind: "select", value: "ai_assist" }, + { kind: "select", value: "cursor" }, + { kind: "select", value: "sonnet-4-thinking" }, + { kind: "select", value: "require_suitable_agent" }, + { kind: "select", value: "github_rules" }, + { kind: "select", value: "strict" }, + { kind: "confirm", value: false }, + { kind: "confirm", value: true }, + { kind: "confirm", value: false }, + { kind: "confirm", value: false }, + ]); + + const exitCode = await runCli(["init"], { + cwd: detectedRepo.repoPath, + stdinIsTTY: true, + stdoutIsTTY: true, + stdout, + stderr, + detectRepoContext: async () => detectedRepo, + createPrompt: () => prompt, + writeSetupFiles: async ({ homePath, repoRoot, config }) => { + writes.push({ homePath, repoRoot, config }); + return { writtenPaths: [] }; + }, + }); + + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + await rm(agentrailHome, { recursive: true, force: true }); + + assert.equal(exitCode, 0, stderr.toString()); + assert.equal(prompt.interactions[6]?.message, "Cursor routing model"); + assert.equal(writes[0]?.config.routing.classifier.runner, "cursor"); + assert.equal(writes[0]?.config.routing.classifier.model, "sonnet-4-thinking"); + assert.match(prompt.notes[0]?.body ?? "", /AI routing model: sonnet-4-thinking/); +}); + test("runCli can connect GitHub during init with a hidden token prompt and shows provider follow-up commands", async () => { const agentrailHome = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); const repoPath = await mkdtemp(path.join(os.tmpdir(), "agentrail-init-repo-")); From d5f3f2a13ba06d2c47c91cc9730ea5af3a15f267 Mon Sep 17 00:00:00 2001 From: Onyeka Nwamba Date: Sun, 24 May 2026 04:50:23 +0100 Subject: [PATCH 2/7] feat: add AI-assisted repo map profiling --- README.md | 4 + defaults/agent-roles/README.md | 5 +- docs/quick-start.md | 5 + src/cli/agentrail-home.ts | 47 +++- src/cli/global-management.ts | 46 ++- src/cli/index.ts | 42 ++- src/cli/setup-config.ts | 57 +++- src/cli/setup-files.ts | 76 ++++- src/cli/setup-wizard.ts | 48 +++- src/repo-map-profiler.ts | 450 ++++++++++++++++++++++++++++++ src/repo-map.ts | 14 +- test/init-local-bootstrap.test.ts | 73 +++++ test/repo-map-profiler.test.ts | 191 +++++++++++++ test/setup-files.test.ts | 118 +++++++- test/setup-wizard.test.ts | 76 ++++- 15 files changed, 1232 insertions(+), 20 deletions(-) create mode 100644 src/repo-map-profiler.ts create mode 100644 test/repo-map-profiler.test.ts diff --git a/README.md b/README.md index 3584cca..5b786c0 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,10 @@ During `agentrail init` and `agentrail repo add`, AgentRail also creates an editable repo map under `~/.agentrail/repo-maps/.yaml`. Role templates use repo-agnostic areas such as `frontend_source`, `tests`, and `ci_configuration`; the repo map translates those areas to the actual folders in your project. +The starter map is generated deterministically. During interactive setup you can +also let a local Codex, Claude Code, or Cursor runner propose a richer map; +AgentRail validates the proposal, shows you the summary and warnings, and falls +back to the starter map if the local runner is unavailable or uncertain. When a managed agent starts work, AgentRail combines the selected role template with the repo map for that repository and passes a compact role brief to the diff --git a/defaults/agent-roles/README.md b/defaults/agent-roles/README.md index 2eaffaf..696e404 100644 --- a/defaults/agent-roles/README.md +++ b/defaults/agent-roles/README.md @@ -24,7 +24,10 @@ areas to actual paths during `agentrail init` or a later template-tailoring step `agentrail init` and `agentrail repo add` create editable repo maps under `~/.agentrail/repo-maps/.yaml`. Keep concrete paths there, not in role -templates. +templates. The starter map is deterministic by default, and interactive setup +can optionally ask a local Codex, Claude Code, or Cursor runner to propose a +richer map. AgentRail validates the proposal before saving it and keeps the +starter map when the local runner is unavailable or uncertain. When a managed agent starts work, AgentRail combines the selected role template with the repo map for that repository and passes a compact role brief to the diff --git a/docs/quick-start.md b/docs/quick-start.md index 0b98ac2..9b3607e 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -115,6 +115,11 @@ AgentRail creates editable repo maps under `~/.agentrail/repo-maps/` during `init` and `repo add`. These maps connect template areas like `frontend_source`, `tests`, and `ci_configuration` to the actual paths in your repo. +The starter map is generated deterministically. During interactive setup you can +optionally improve it with a local Codex, Claude Code, or Cursor runner. No +hosted model API key is needed; AgentRail validates the proposal, shows a +summary before saving it, and falls back to the starter map when the local +runner is unavailable or uncertain. Agent role templates are used at run time. When a managed agent starts work, AgentRail combines the selected role template with the repo map for that diff --git a/src/cli/agentrail-home.ts b/src/cli/agentrail-home.ts index 6b9deee..9debf06 100644 --- a/src/cli/agentrail-home.ts +++ b/src/cli/agentrail-home.ts @@ -5,7 +5,12 @@ import { readFile, writeFile } from "node:fs/promises"; import { DEFAULT_ROUTING_CLASSIFIER_TIMEOUT_MS } from "../routing-classifier-config.ts"; import { normalizeRunnerExecutionPolicy, type RunnerExecutionPolicyLike } from "../runner-execution-policy.ts"; import { normalizeCodeReviewPolicy, type CodeReviewPolicy } from "../code-review-policy.ts"; -import { normalizeTelemetryHost, normalizeTelemetryInstallId } from "./setup-config.ts"; +import { + DEFAULT_REPO_MAP_PROFILER_CONFIDENCE_THRESHOLD, + DEFAULT_REPO_MAP_PROFILER_TIMEOUT_MS, + normalizeTelemetryHost, + normalizeTelemetryInstallId, +} from "./setup-config.ts"; import { syncTelemetryIdentityFromConfig } from "./telemetry-identity.ts"; export interface ConnectedRepo { @@ -80,6 +85,16 @@ export interface SetupConfigLike { timeoutMs?: number; }; }; + repoMaps?: { + generationMode?: string; + profiler?: { + kind?: string; + runner?: string; + model?: string | null; + timeoutMs?: number; + confidenceThreshold?: number; + }; + }; telemetry?: { enabled?: boolean; installId?: string; @@ -196,6 +211,7 @@ export function normalizeSetupConfigLike(config: SetupConfigLike | null): SetupC const normalizedProviders = normalizeProviders(config.providers); const normalizedPersistence = normalizePersistence(config.persistence); const normalizedRouting = normalizeRouting(config.routing); + const normalizedRepoMaps = normalizeRepoMaps(config.repoMaps, normalizedRouting); const telemetry = normalizeTelemetry(config.telemetry); const runnerPolicy = normalizeRunnerExecutionPolicy(config.runnerPolicy); if (Array.isArray(config.repos)) { @@ -204,6 +220,7 @@ export function normalizeSetupConfigLike(config: SetupConfigLike | null): SetupC persistence: normalizedPersistence, providers: normalizedProviders, routing: normalizedRouting, + repoMaps: normalizedRepoMaps, telemetry, runnerPolicy, repos: config.repos @@ -235,6 +252,7 @@ export function normalizeSetupConfigLike(config: SetupConfigLike | null): SetupC persistence: normalizedPersistence, providers: normalizedProviders, routing: normalizedRouting, + repoMaps: normalizedRepoMaps, telemetry, runnerPolicy, repos: [{ @@ -251,6 +269,7 @@ export function normalizeSetupConfigLike(config: SetupConfigLike | null): SetupC persistence: normalizedPersistence, providers: normalizedProviders, routing: normalizedRouting, + repoMaps: normalizedRepoMaps, telemetry, runnerPolicy, repos: [], @@ -354,6 +373,32 @@ function normalizeRouting(routing: SetupConfigLike["routing"]): NonNullable, +): NonNullable { + const profiler = repoMaps?.profiler ?? {}; + const model = typeof profiler.model === "string" && profiler.model.trim().length > 0 + ? profiler.model.trim() + : routing.classifier?.model ?? null; + return { + generationMode: repoMaps?.generationMode === "ai_assist" ? "ai_assist" : "deterministic", + profiler: { + kind: "local_runner", + runner: typeof profiler.runner === "string" && profiler.runner.trim() + ? profiler.runner.trim() + : routing.classifier?.runner ?? "codex", + model, + timeoutMs: Number.isInteger(profiler.timeoutMs) && Number(profiler.timeoutMs) > 0 + ? Number(profiler.timeoutMs) + : DEFAULT_REPO_MAP_PROFILER_TIMEOUT_MS, + confidenceThreshold: typeof profiler.confidenceThreshold === "number" && Number.isFinite(profiler.confidenceThreshold) + ? Math.min(1, Math.max(0, profiler.confidenceThreshold)) + : DEFAULT_REPO_MAP_PROFILER_CONFIDENCE_THRESHOLD, + }, + }; +} + function normalizeRoutingFallbackBehaviorLike(value: unknown): "require_suitable_agent" | "assign_closest_match" { const normalized = typeof value === "string" ? value.trim().replace(/-/gu, "_") : ""; if (normalized === "assign_closest_match") { diff --git a/src/cli/global-management.ts b/src/cli/global-management.ts index e91b7c0..bf14cee 100644 --- a/src/cli/global-management.ts +++ b/src/cli/global-management.ts @@ -12,7 +12,9 @@ import { type ConnectedRepo, type SetupConfigLike, } from "./agentrail-home.ts"; -import { ensureRepoMapForRepo, repoMapPathForHome } from "../repo-map.ts"; +import { ensureRepoMapForRepo, generateStarterRepoMap, repoMapPathForHome } from "../repo-map.ts"; +import { proposeRepoMapWithLocalRunner } from "../repo-map-profiler.ts"; +import type { RepoMapGenerationMode } from "./setup-config.ts"; interface Writer { write(chunk: string | Uint8Array): boolean; @@ -97,7 +99,37 @@ export async function runRepoCommand(argv: string[], { }; await writeConfig(homePath, nextConfig); stdout.write(`Connected repo ${nextRepo.slug}.\n`); - const repoMap = await ensureRepoMapForRepo({ homePath, repo: nextRepo }); + const repoMapMode = flags.repoMapGenerationMode ?? (config.repoMaps?.generationMode === "ai_assist" ? "ai_assist" : "deterministic"); + const repoMap = await ensureRepoMapForRepo({ + homePath, + repo: nextRepo, + createRepoMap: repoMapMode === "ai_assist" + ? async () => { + const deterministicMap = await generateStarterRepoMap(nextRepo.path); + const proposal = await proposeRepoMapWithLocalRunner({ + repoRoot: nextRepo.path, + repoSlug: nextRepo.slug, + deterministicMap, + config: { + runner: config.repoMaps?.profiler?.runner ?? "codex", + model: config.repoMaps?.profiler?.model ?? null, + timeoutMs: config.repoMaps?.profiler?.timeoutMs ?? 120_000, + confidenceThreshold: config.repoMaps?.profiler?.confidenceThreshold ?? 0.65, + }, + }); + if (proposal.status === "accepted" && proposal.source === "ai") { + return { + repoMap: proposal.repoMap, + reason: "Created from local AI repo-map proposal.", + }; + } + return { + repoMap: deterministicMap, + reason: `Created from starter map. Local AI repo-map proposal was not used: ${proposal.reason ?? proposal.warnings[0] ?? "unknown reason"}.`, + }; + } + : undefined, + }); if (repoMap.status === "created") { stdout.write(`Wrote repo map ${repoMap.path}${repoMap.reason ? ` (${repoMap.reason})` : ""}.\n`); } else if (repoMap.status === "skipped") { @@ -204,6 +236,7 @@ interface RepoFlags { repo?: string; slug?: string; defaultBranch?: string; + repoMapGenerationMode?: RepoMapGenerationMode; } function parseRepoFlags(argv: string[]): RepoFlags { @@ -220,6 +253,15 @@ function parseRepoFlags(argv: string[]): RepoFlags { case "--default-branch": flags.defaultBranch = nextValue(argv, ++index, arg); break; + case "--repo-map-mode": + { + const rawValue = nextValue(argv, ++index, arg).replace(/-/gu, "_"); + if (rawValue !== "deterministic" && rawValue !== "ai_assist") { + throw new Error("--repo-map-mode must be one of: deterministic, ai-assist."); + } + flags.repoMapGenerationMode = rawValue; + } + break; default: throw new Error(`Unknown flag "${arg}".`); } diff --git a/src/cli/index.ts b/src/cli/index.ts index 0e6c13d..a3256c0 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -17,7 +17,7 @@ import { createPromptSession, PromptCancelledError, type PromptSession } from ". import { runProviderCommand } from "./provider-management.ts"; import { detectRepoContext } from "./repo-detection.ts"; import { buildInitCommand, createSetupConfig, normalizeRoutingFallbackBehavior, validateSafeDefaults, type DetectedRepoContext, type SetupConfig } from "./setup-config.ts"; -import { writeSetupFiles, type WriteSetupFilesResult } from "./setup-files.ts"; +import { writeSetupFiles, type RepoMapProfilerHook, type WriteSetupFilesResult } from "./setup-files.ts"; import { runTaskResolveBlocker, runTaskResume } from "./task-blocker.ts"; import { runTaskSourceRepair } from "./task-source-repair.ts"; import { runTelemetryCommand } from "./telemetry-management.ts"; @@ -62,6 +62,8 @@ export interface RunCliOptions { homePath?: string; repoRoot?: string; config: ReturnType; + repoMapProfiler?: RepoMapProfilerHook | null; + onRepoMapProposal?: Parameters[0]["onRepoMapProposal"]; }) => Promise; } @@ -538,6 +540,8 @@ async function finalizeInit({ homePath?: string; repoRoot?: string; config: ReturnType; + repoMapProfiler?: RepoMapProfilerHook | null; + onRepoMapProposal?: Parameters[0]["onRepoMapProposal"]; }) => Promise; telemetry?: CliTelemetryHook; }): Promise { @@ -554,6 +558,22 @@ async function finalizeInit({ await writeFiles({ homePath: resolveAgentRailHome({ cwd, explicitHome: null }), config, + onRepoMapProposal: prompt + ? async (proposal) => { + await prompt.note({ + title: "Repo map proposal", + body: [ + `Confidence: ${proposal.confidence === null ? "unknown" : proposal.confidence.toFixed(2)}`, + `Summary: ${proposal.summary}`, + proposal.warnings.length > 0 ? `Warnings: ${proposal.warnings.join("; ")}` : "Warnings: none", + ].join("\n"), + }); + return await prompt.confirm({ + message: "Save this repo map?", + defaultValue: true, + }) ? "accept" : "reject"; + } + : undefined, }); const homePath = resolveAgentRailHome({ cwd, explicitHome: null }); @@ -758,6 +778,18 @@ function parseInitArgs(argv: string[]): InitFlags { flags.routingFallbackBehavior = normalizeRoutingFallbackBehavior(rawValue); } break; + case "--repo-map-mode": + { + const rawValue = nextValue(argv, ++index, arg).replace(/-/gu, "_"); + flags.repoMapGenerationMode = readEnum(rawValue, ["deterministic", "ai_assist"], arg); + } + break; + case "--repo-map-profiler-runner": + flags.repoMapProfilerRunner = nextValue(argv, ++index, arg); + break; + case "--repo-map-profiler-model": + flags.repoMapProfilerModel = nextValue(argv, ++index, arg); + break; case "--runner-policy": { const rawValue = nextValue(argv, ++index, arg).replace(/-/gu, "_"); @@ -814,9 +846,12 @@ function writeUsage(output: Writer) { output.write([ "Usage:", " agentrail init [flags]", + " --repo-map-mode ", + " --repo-map-profiler-runner ", + " --repo-map-profiler-model ", " agentrail doctor [--agent-id ] [flags]", " agentrail server start", - " agentrail repo add [flags]", + " agentrail repo add [flags] [--repo-map-mode ]", " agentrail repo list", " agentrail repo remove --repo ", " agentrail config show", @@ -852,6 +887,9 @@ function writeUsage(output: Writer) { " --markdown-export", " --telemetry", " --no-telemetry", + " --repo-map-mode ", + " --repo-map-profiler-runner ", + " --repo-map-profiler-model ", " --code-review-policy ", " --runner-policy ", " --event-types ", diff --git a/src/cli/setup-config.ts b/src/cli/setup-config.ts index e2037ad..3aedfdc 100644 --- a/src/cli/setup-config.ts +++ b/src/cli/setup-config.ts @@ -14,6 +14,7 @@ export type PersistenceKind = "file" | "memory"; export type InteractionMode = "interactive" | "non_interactive" | "print_only"; export type RoutingMode = "rules_only" | "ai_assist"; export type RoutingFallbackBehavior = "require_suitable_agent" | "assign_closest_match"; +export type RepoMapGenerationMode = "deterministic" | "ai_assist"; export type TelemetryProvider = "posthog"; export interface TelemetryConfig { @@ -111,6 +112,16 @@ export interface SetupConfig { timeoutMs: number; }; }; + repoMaps: { + generationMode: RepoMapGenerationMode; + profiler: { + kind: "local_runner"; + runner: "codex" | "claude-code" | "cursor" | "custom" | string; + model: string | null; + timeoutMs: number; + confidenceThreshold: number; + }; + }; telemetry: TelemetryConfig; managedRuns?: { startingStaleAfterMs?: number; @@ -144,6 +155,11 @@ export interface CreateSetupConfigOptions { routingClassifierModel?: string | null; routingConfidenceThreshold?: number; routingFallbackBehavior?: RoutingFallbackBehavior; + repoMapGenerationMode?: RepoMapGenerationMode; + repoMapProfilerRunner?: "codex" | "claude-code" | "cursor" | "custom" | string; + repoMapProfilerModel?: string | null; + repoMapProfilerTimeoutMs?: number; + repoMapProfilerConfidenceThreshold?: number; codeReviewPolicy?: CodeReviewPolicy; runnerPolicy?: RunnerExecutionPolicyLike; telemetryEnabled?: boolean; @@ -158,6 +174,8 @@ export interface SafetyValidation { const DEFAULT_HOST = "127.0.0.1"; const DEFAULT_PORT = 3000; +export const DEFAULT_REPO_MAP_PROFILER_TIMEOUT_MS = 120_000; +export const DEFAULT_REPO_MAP_PROFILER_CONFIDENCE_THRESHOLD = 0.65; export const DEFAULT_TELEMETRY_HOST = "https://eu.i.posthog.com"; export function createTelemetryInstallId(): string { @@ -206,6 +224,11 @@ export function createSetupConfig({ routingClassifierModel = null, routingConfidenceThreshold = 0.8, routingFallbackBehavior = "require_suitable_agent", + repoMapGenerationMode = "deterministic", + repoMapProfilerRunner, + repoMapProfilerModel, + repoMapProfilerTimeoutMs = DEFAULT_REPO_MAP_PROFILER_TIMEOUT_MS, + repoMapProfilerConfidenceThreshold = DEFAULT_REPO_MAP_PROFILER_CONFIDENCE_THRESHOLD, codeReviewPolicy, runnerPolicy, telemetryEnabled, @@ -281,12 +304,24 @@ export function createSetupConfig({ classifier: { kind: "local_runner", runner: routingClassifierRunner, - model: routingClassifierModel?.trim() ? routingClassifierModel.trim() : null, + model: normalizeOptionalModel(routingClassifierModel), confidenceThreshold: normalizeConfidenceThreshold(routingConfidenceThreshold), fallbackBehavior: normalizeRoutingFallbackBehavior(routingFallbackBehavior), timeoutMs: DEFAULT_ROUTING_CLASSIFIER_TIMEOUT_MS, }, }, + repoMaps: { + generationMode: repoMapGenerationMode, + profiler: { + kind: "local_runner", + runner: repoMapProfilerRunner ?? routingClassifierRunner, + model: repoMapProfilerModel !== undefined + ? normalizeOptionalModel(repoMapProfilerModel) + : normalizeOptionalModel(routingClassifierModel), + timeoutMs: normalizeTimeoutMs(repoMapProfilerTimeoutMs, DEFAULT_REPO_MAP_PROFILER_TIMEOUT_MS), + confidenceThreshold: normalizeConfidenceThreshold(repoMapProfilerConfidenceThreshold), + }, + }, telemetry: { enabled: telemetryEnabled ?? true, installId: normalizeTelemetryInstallId(telemetryInstallId), @@ -366,6 +401,13 @@ export function buildInitCommand(config: SetupConfig): string { parts.push(`--routing-confidence-threshold ${config.routing.classifier.confidenceThreshold}`); parts.push(`--routing-no-suitable-agent ${toFlagValue(config.routing.classifier.fallbackBehavior)}`); } + if (config.repoMaps.generationMode !== "deterministic") { + parts.push("--repo-map-mode ai-assist"); + parts.push(`--repo-map-profiler-runner ${quoteShell(config.repoMaps.profiler.runner)}`); + if (config.repoMaps.profiler.model) { + parts.push(`--repo-map-profiler-model ${quoteShell(config.repoMaps.profiler.model)}`); + } + } return parts.join(" "); } @@ -377,7 +419,9 @@ export function buildSetupPlan(config: SetupConfig): string[] { "Write ~/.agentrail/server.env", "Write ~/.agentrail/README.md", "Write ~/.agentrail/operator.env", - "Write ~/.agentrail/repo-maps/.yaml when missing", + config.repoMaps.generationMode === "ai_assist" + ? "Ask a local AI runner for a repo-map proposal, then write ~/.agentrail/repo-maps/.yaml" + : "Write ~/.agentrail/repo-maps/.yaml when missing", ]; if (config.exports.markdown.enabled) { @@ -438,6 +482,15 @@ function normalizeConfidenceThreshold(value: number): number { return Math.min(1, Math.max(0, value)); } +function normalizeTimeoutMs(value: number, fallback: number): number { + if (!Number.isFinite(value) || value <= 0) return fallback; + return Math.round(value); +} + +function normalizeOptionalModel(value: string | null | undefined): string | null { + return value?.trim() ? value.trim() : null; +} + export function normalizeRoutingFallbackBehavior(value: unknown): RoutingFallbackBehavior { const normalized = typeof value === "string" ? value.trim().replace(/-/gu, "_") : ""; if (normalized === "assign_closest_match") { diff --git a/src/cli/setup-files.ts b/src/cli/setup-files.ts index 0a40681..acd348e 100644 --- a/src/cli/setup-files.ts +++ b/src/cli/setup-files.ts @@ -4,22 +4,34 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import type { SetupConfig } from "./setup-config.ts"; import { agentEnvExamplePathForHome, configPathForHome, recipePathForHome, serverEnvPathForHome } from "./agentrail-home.ts"; import { codeReviewPolicyFlagValue, describeCodeReviewPolicy, normalizeCodeReviewPolicy } from "../code-review-policy.ts"; -import { ensureRepoMapForRepo } from "../repo-map.ts"; +import { ensureRepoMapForRepo, generateStarterRepoMap, type AgentRailRepoMap } from "../repo-map.ts"; +import { proposeRepoMapWithLocalRunner, type RepoMapProfilerResult } from "../repo-map-profiler.ts"; export interface WriteSetupFilesOptions { homePath?: string; repoRoot?: string; config: SetupConfig; + repoMapProfiler?: RepoMapProfilerHook | null; + onRepoMapProposal?: (proposal: RepoMapProfilerResult) => Promise<"accept" | "reject">; } export interface WriteSetupFilesResult { writtenPaths: string[]; } +export type RepoMapProfilerHook = (input: { + repoRoot: string; + repoSlug: string; + deterministicMap: AgentRailRepoMap; + config: SetupConfig["repoMaps"]["profiler"]; +}) => Promise; + export async function writeSetupFiles({ homePath, repoRoot, config, + repoMapProfiler, + onRepoMapProposal, }: WriteSetupFilesOptions): Promise { const resolvedHomePath = homePath ?? repoRoot; if (!resolvedHomePath) { @@ -51,7 +63,19 @@ export async function writeSetupFiles({ await writeFile(recipePath, renderDefaultRecipe(), "utf8"); for (const repo of config.repos) { - const repoMap = await ensureRepoMapForRepo({ homePath: resolvedHomePath, repo }); + const repoMap = await ensureRepoMapForRepo({ + homePath: resolvedHomePath, + repo, + createRepoMap: config.repoMaps.generationMode === "ai_assist" + ? async () => await createRepoMapWithProfiler({ + repoRoot: repo.path, + repoSlug: repo.slug, + config, + repoMapProfiler, + onRepoMapProposal, + }) + : undefined, + }); if (repoMap.status === "created") { writtenPaths.push(repoMap.path); } @@ -60,6 +84,51 @@ export async function writeSetupFiles({ return { writtenPaths }; } +async function createRepoMapWithProfiler({ + repoRoot, + repoSlug, + config, + repoMapProfiler, + onRepoMapProposal, +}: { + repoRoot: string; + repoSlug: string; + config: SetupConfig; + repoMapProfiler?: RepoMapProfilerHook | null; + onRepoMapProposal?: (proposal: RepoMapProfilerResult) => Promise<"accept" | "reject">; +}) { + const deterministicMap = await generateStarterRepoMap(repoRoot); + const profiler = repoMapProfiler ?? (async () => await proposeRepoMapWithLocalRunner({ + repoRoot, + repoSlug, + deterministicMap, + config: config.repoMaps.profiler, + })); + const proposal = await profiler({ + repoRoot, + repoSlug, + deterministicMap, + config: config.repoMaps.profiler, + }); + if (proposal.status === "accepted" && proposal.source === "ai") { + const decision = onRepoMapProposal ? await onRepoMapProposal(proposal) : "accept"; + if (decision === "accept") { + return { + repoMap: proposal.repoMap, + reason: "Created from local AI repo-map proposal.", + }; + } + return { + repoMap: deterministicMap, + reason: "Created from starter map because the local AI repo-map proposal was rejected.", + }; + } + return { + repoMap: deterministicMap, + reason: `Created from starter map. Local AI repo-map proposal was not used: ${proposal.reason ?? proposal.warnings[0] ?? "unknown reason"}.`, + }; +} + function renderServerEnv(config: SetupConfig, homePath: string): string { const lines = [ "# Generated by `agentrail init`.", @@ -159,6 +228,9 @@ function renderSetupReadme(config: SetupConfig): string { config.routing.mode === "ai_assist" ? `- Local AI routing classifier timeout: ${Math.round(config.routing.classifier.timeoutMs / 1000)} seconds by default. Slow local runners can raise \`routing.classifier.timeoutMs\` up to 600 seconds in \`config.json\`.` : "", + config.repoMaps.generationMode === "ai_assist" + ? `- Repo maps can optionally be improved with a local AI runner. This setup uses ${config.repoMaps.profiler.runner}${config.repoMaps.profiler.model ? ` (${config.repoMaps.profiler.model})` : ""} to propose a map, validates it, and falls back to the starter map if the proposal is not usable.` + : "- Starter repo maps are generated deterministically from the repo structure. No local AI runner is used unless you opt in.", "- AgentRail writes editable repo maps under `~/.agentrail/repo-maps/` so role templates can refer to abstract areas like `frontend_source`, `tests`, or `ci_configuration` without hard-coding paths.", "- `agentrail doctor --agent-id ` warns when the selected agent's role template references areas that are missing from the repo map.", "", diff --git a/src/cli/setup-wizard.ts b/src/cli/setup-wizard.ts index 109dc67..0a2182c 100644 --- a/src/cli/setup-wizard.ts +++ b/src/cli/setup-wizard.ts @@ -6,6 +6,7 @@ import { type DetectedRepoContext, type PersistenceKind, type ProviderMode, + type RepoMapGenerationMode, type RoutingFallbackBehavior, type RoutingMode, type SetupConfig, @@ -32,6 +33,9 @@ export interface InitFlags { routingClassifierModel?: string | null; routingConfidenceThreshold?: number; routingFallbackBehavior?: RoutingFallbackBehavior; + repoMapGenerationMode?: RepoMapGenerationMode; + repoMapProfilerRunner?: string; + repoMapProfilerModel?: string | null; repo?: string; repoAllowlist?: string[]; defaultBranch?: string; @@ -118,6 +122,15 @@ export async function runSetupWizard({ const routingFallbackBehavior = flags.routingFallbackBehavior ?? (routingMode === "ai_assist" ? await promptNoSuitableAgentPolicy(prompt) : "require_suitable_agent"); + const repoMapGenerationMode = flags.repoMapGenerationMode ?? await promptRepoMapGenerationMode(prompt); + const repoMapProfilerRunner = flags.repoMapProfilerRunner ?? (repoMapGenerationMode === "ai_assist" + ? await promptLocalAiRunner(prompt, "Repo map AI runner") + : routingClassifierRunner); + const repoMapProfilerModel = flags.repoMapProfilerModel !== undefined + ? flags.repoMapProfilerModel + : repoMapGenerationMode === "ai_assist" + ? await promptLocalAiModel(prompt, repoMapProfilerRunner, "repo map") + : null; const codeReviewPolicy = flags.codeReviewPolicy ?? await promptCodeReviewPolicy(prompt); const runnerPolicyPreset = flags.runnerPolicyPreset ?? await promptRunnerPolicyPreset(prompt); const markdownExport = flags.markdownExport ?? await prompt.confirm({ @@ -141,6 +154,9 @@ export async function runSetupWizard({ routingClassifierModel, routingConfidenceThreshold, routingFallbackBehavior, + repoMapGenerationMode, + repoMapProfilerRunner, + repoMapProfilerModel, codeReviewPolicy, runnerPolicy: normalizeRunnerExecutionPolicy({ preset: runnerPolicyPreset }), telemetryEnabled: flags.telemetryEnabled ?? existingConfig?.telemetry?.enabled, @@ -171,6 +187,13 @@ export async function runSetupWizard({ `- No suitable agent policy: ${describeRoutingFallbackBehavior(routingFallbackBehavior)}`, ] : []), + `- Repo map: ${repoMapGenerationMode === "ai_assist" ? "Improve with local AI" : "Use starter map"}`, + ...(repoMapGenerationMode === "ai_assist" + ? [ + `- Repo map AI runner: ${repoMapProfilerRunner}`, + `- Repo map AI model: ${repoMapProfilerModel ?? `${classifierRunnerLabel(repoMapProfilerRunner)} default`}`, + ] + : []), `- Code review before ship: ${describeCodeReviewPolicy(codeReviewPolicy)}`, `- Local agent safety: ${describeRunnerPolicyPreset(runnerPolicyPreset)}`, `- Markdown export: ${markdownExport ? "enabled" : "disabled"}`, @@ -266,8 +289,12 @@ async function promptRoutingMode(prompt: PromptSession): Promise { } async function promptClassifierRunner(prompt: PromptSession): Promise { + return await promptLocalAiRunner(prompt, "AI routing runner"); +} + +async function promptLocalAiRunner(prompt: PromptSession, message: string): Promise { return await prompt.select({ - message: "AI routing runner", + message, defaultValue: "codex", choices: [ { value: "codex", label: "Codex", hint: "Use the local Codex CLI." }, @@ -279,9 +306,13 @@ async function promptClassifierRunner(prompt: PromptSession): Promise { } async function promptClassifierModel(prompt: PromptSession, runner: string): Promise { + return await promptLocalAiModel(prompt, runner, "routing"); +} + +async function promptLocalAiModel(prompt: PromptSession, runner: string, purpose: string): Promise { const runnerLabel = classifierRunnerLabel(runner); const selected = await prompt.select({ - message: `${runnerLabel} routing model`, + message: `${runnerLabel} ${purpose} model`, defaultValue: "__default__", choices: [ { @@ -304,11 +335,19 @@ async function promptClassifierModel(prompt: PromptSession, runner: string): Pro return selected; } return resolveOptionalPromptValue(await prompt.input({ - message: `${runnerLabel} model name`, + message: `${runnerLabel} ${purpose} model name`, defaultValue: "", })); } +async function promptRepoMapGenerationMode(prompt: PromptSession): Promise { + const shouldUseAi = await prompt.confirm({ + message: "Improve the repo map with local AI?", + defaultValue: false, + }); + return shouldUseAi ? "ai_assist" : "deterministic"; +} + function classifierRunnerLabel(runner: string): string { if (runner === "codex") return "Codex"; if (runner === "claude-code") return "Claude Code"; @@ -545,6 +584,9 @@ export function createSetupConfigFromFlags({ routingClassifierModel: flags.routingClassifierModel, routingConfidenceThreshold: flags.routingConfidenceThreshold, routingFallbackBehavior: flags.routingFallbackBehavior, + repoMapGenerationMode: flags.repoMapGenerationMode, + repoMapProfilerRunner: flags.repoMapProfilerRunner, + repoMapProfilerModel: flags.repoMapProfilerModel, codeReviewPolicy: flags.codeReviewPolicy, runnerPolicy: flags.runnerPolicyPreset ? normalizeRunnerExecutionPolicy({ preset: flags.runnerPolicyPreset }) diff --git a/src/repo-map-profiler.ts b/src/repo-map-profiler.ts new file mode 100644 index 0000000..90e7f19 --- /dev/null +++ b/src/repo-map-profiler.ts @@ -0,0 +1,450 @@ +import { spawn } from "node:child_process"; +import { readdir, readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +import { + AGENTRAIL_REPO_MAP_SCHEMA_VERSION, + generateStarterRepoMap, + renderRepoMapYaml, + validateRepoMap, + type AgentRailRepoMap, +} from "./repo-map.ts"; + +export interface RepoMapProfilerConfig { + runner: string; + model: string | null; + timeoutMs: number; + confidenceThreshold: number; +} + +export interface RepoMapProfilerInput { + repoRoot: string; + repoSlug?: string; + config: RepoMapProfilerConfig; + deterministicMap?: AgentRailRepoMap; + spawnProcess?: typeof spawn; +} + +export interface RepoMapProfileScan { + rootFiles: string[]; + tree: string[]; + packageScripts: Record; + detectedCommands: AgentRailRepoMap["commands"]; +} + +export interface RepoMapProfilerResult { + status: "accepted" | "fallback"; + source: "ai" | "deterministic"; + repoMap: AgentRailRepoMap; + confidence: number | null; + summary: string; + warnings: string[]; + reason?: string; +} + +const MAX_TREE_DEPTH = 3; +const MAX_TREE_ENTRIES = 400; +const MAX_STDIO_BYTES = 512 * 1024; +const SKIPPED_DIRS = new Set([ + ".agentrail", + ".agentrail-run", + ".git", + ".hg", + ".next", + ".svn", + "build", + "coverage", + "dist", + "node_modules", + "out", + "target", + "vendor", +]); + +export async function scanRepoForRepoMapProfile(repoRoot: string, deterministicMap?: AgentRailRepoMap): Promise { + const rootEntries = await safeReadDir(repoRoot); + const rootFiles = rootEntries + .filter((entry) => !isSecretLikePath(entry.name)) + .map((entry) => `${entry.name}${entry.isDirectory() ? "/" : ""}`) + .sort((left, right) => left.localeCompare(right)); + const tree: string[] = []; + await collectTree(repoRoot, "", 0, tree); + const packageScripts = await readPackageScripts(repoRoot); + const detectedCommands = deterministicMap?.commands ?? (await generateStarterRepoMap(repoRoot)).commands; + return { + rootFiles, + tree, + packageScripts, + detectedCommands, + }; +} + +export function buildRepoMapProfilerPrompt({ + repoSlug, + deterministicMap, + scan, +}: { + repoSlug: string; + deterministicMap: AgentRailRepoMap; + scan: RepoMapProfileScan; +}): string { + return [ + "You are AgentRail's local repo-map profiler.", + "Improve the starter repo map using only the bounded repository summary below.", + "Do not invent files. Use repo-relative paths only. Do not include secrets, env files, source code, diffs, logs, or raw provider payloads.", + "Return only JSON. Do not include markdown or commentary.", + "", + "JSON schema:", + JSON.stringify({ + confidence: 0.0, + summary: "Short explanation of what changed", + warnings: ["Low-confidence or missing repo areas"], + repoMap: { + schemaVersion: AGENTRAIL_REPO_MAP_SCHEMA_VERSION, + areas: { + application_source: { + paths: ["src/**"], + testCommands: ["npm run test"], + notes: "Short area note", + }, + }, + ignoredPaths: [".git", "node_modules"], + generatedPaths: ["dist"], + commands: { + test: ["npm run test"], + lint: ["npm run lint"], + typecheck: ["npm run typecheck"], + build: ["npm run build"], + }, + }, + }, null, 2), + "", + "Repo:", + JSON.stringify({ slug: repoSlug }, null, 2), + "", + "Starter repo map:", + renderRepoMapYaml(deterministicMap), + "", + "Bounded repo summary:", + JSON.stringify(scan, null, 2), + ].join("\n"); +} + +export async function proposeRepoMapWithLocalRunner({ + repoRoot, + repoSlug = repoRoot, + config, + deterministicMap, + spawnProcess = spawn, +}: RepoMapProfilerInput): Promise { + const fallbackMap = deterministicMap ?? await generateStarterRepoMap(repoRoot); + try { + const scan = await scanRepoForRepoMapProfile(repoRoot, fallbackMap); + const prompt = buildRepoMapProfilerPrompt({ + repoSlug, + deterministicMap: fallbackMap, + scan, + }); + const { executable, args } = repoMapProfilerLaunchCommand(config); + const output = await runRepoMapProfilerProcess({ + spawnProcess, + executable, + args, + prompt, + timeoutMs: config.timeoutMs, + }); + return await parseRepoMapProfilerOutput(output, { + repoRoot, + deterministicMap: fallbackMap, + confidenceThreshold: config.confidenceThreshold, + }); + } catch (error) { + return { + status: "fallback", + source: "deterministic", + repoMap: fallbackMap, + confidence: null, + summary: "Local AI repo-map profiling was not used.", + warnings: [errorMessage(error)], + reason: errorMessage(error), + }; + } +} + +export async function parseRepoMapProfilerOutput( + raw: string, + { + repoRoot, + deterministicMap, + confidenceThreshold, + }: { + repoRoot: string; + deterministicMap: AgentRailRepoMap; + confidenceThreshold: number; + }, +): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(extractJsonPayload(raw)); + } catch (error) { + return fallbackResult(deterministicMap, `Local AI repo-map output was not valid JSON: ${errorMessage(error)}`); + } + if (!isRecord(parsed)) { + return fallbackResult(deterministicMap, "Local AI repo-map output must be a JSON object."); + } + const confidence = typeof parsed.confidence === "number" && Number.isFinite(parsed.confidence) + ? parsed.confidence + : null; + if (confidence === null || confidence < 0 || confidence > 1) { + return fallbackResult(deterministicMap, "Local AI repo-map output confidence must be a number between 0 and 1."); + } + const summary = typeof parsed.summary === "string" && parsed.summary.trim() + ? parsed.summary.trim() + : "Local AI proposed a repo map."; + const warnings = stringArray(parsed.warnings); + let repoMap: AgentRailRepoMap; + try { + repoMap = validateRepoMap(parsed.repoMap, ""); + await validateRepoMapPathsExist(repoRoot, repoMap); + } catch (error) { + return fallbackResult(deterministicMap, `Local AI repo-map proposal was invalid: ${errorMessage(error)}`); + } + if (confidence < confidenceThreshold) { + return fallbackResult(deterministicMap, `Local AI repo-map confidence ${confidence.toFixed(2)} is below ${confidenceThreshold.toFixed(2)}.`); + } + return { + status: "accepted", + source: "ai", + repoMap, + confidence, + summary, + warnings, + reason: `Local AI repo-map proposal accepted: ${summary}`, + }; +} + +export function repoMapProfilerLaunchCommand(config: RepoMapProfilerConfig): { executable: string; args: string[] } { + const modelArgs = config.model ? ["--model", config.model] : []; + if (config.runner === "claude-code") { + return { executable: "claude", args: ["--print", ...modelArgs] }; + } + if (config.runner === "cursor") { + return { executable: "cursor-agent", args: ["--print", ...modelArgs] }; + } + if (config.runner === "custom") { + throw new Error("Custom repo-map profiler commands are not configured yet."); + } + return { + executable: "codex", + args: ["-a", "never", "exec", "--sandbox", "read-only", ...modelArgs, "-"], + }; +} + +async function collectTree(repoRoot: string, relativeDir: string, depth: number, output: string[]): Promise { + if (depth > MAX_TREE_DEPTH || output.length >= MAX_TREE_ENTRIES) return; + const absoluteDir = path.join(repoRoot, relativeDir); + const entries = await safeReadDir(absoluteDir); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (output.length >= MAX_TREE_ENTRIES) return; + const relativePath = toPosixPath(path.join(relativeDir, entry.name)); + if (shouldSkipTreeEntry(entry.name, relativePath, entry.isDirectory())) continue; + output.push(`${relativePath}${entry.isDirectory() ? "/" : ""}`); + if (entry.isDirectory()) { + await collectTree(repoRoot, relativePath, depth + 1, output); + } + } +} + +async function safeReadDir(directoryPath: string) { + try { + return await readdir(directoryPath, { withFileTypes: true }); + } catch { + return []; + } +} + +async function readPackageScripts(repoRoot: string): Promise> { + try { + const parsed = JSON.parse(await readFile(path.join(repoRoot, "package.json"), "utf8")) as { scripts?: unknown }; + if (!parsed.scripts || typeof parsed.scripts !== "object" || Array.isArray(parsed.scripts)) { + return {}; + } + const scripts: Record = {}; + for (const [name, command] of Object.entries(parsed.scripts)) { + if (typeof command === "string") { + scripts[name] = command; + } + } + return scripts; + } catch { + return {}; + } +} + +function shouldSkipTreeEntry(name: string, relativePath: string, isDirectory: boolean): boolean { + if (isSecretLikePath(relativePath)) return true; + if (isDirectory && SKIPPED_DIRS.has(name)) return true; + return false; +} + +function isSecretLikePath(relativePath: string): boolean { + const normalized = relativePath.toLowerCase(); + const basename = path.posix.basename(normalized); + return ( + basename === ".env" || + basename.startsWith(".env.") || + basename.includes("secret") || + basename.includes("token") || + basename.endsWith(".pem") || + basename.endsWith(".key") || + basename.endsWith(".p12") || + basename.endsWith(".pfx") + ); +} + +async function validateRepoMapPathsExist(repoRoot: string, repoMap: AgentRailRepoMap): Promise { + const generatedPrefixes = new Set(repoMap.generatedPaths.map(repoPathPrefix)); + for (const [areaName, area] of Object.entries(repoMap.areas)) { + for (const repoPath of area.paths) { + const prefix = repoPathPrefix(repoPath); + if (!prefix || generatedPrefixes.has(prefix)) continue; + if (!await pathExists(path.join(repoRoot, prefix))) { + throw new Error(`area ${areaName} references missing path ${repoPath}`); + } + } + } +} + +function repoPathPrefix(value: string): string { + const segments = value.split("/"); + const concrete: string[] = []; + for (const segment of segments) { + if (!segment || segment.includes("*") || segment.includes("?") || segment.includes("[")) break; + concrete.push(segment); + } + return concrete.join("/"); +} + +async function pathExists(filePath: string): Promise { + try { + await stat(filePath); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR" || code === "EACCES") { + return false; + } + throw error; + } +} + +function fallbackResult(deterministicMap: AgentRailRepoMap, reason: string): RepoMapProfilerResult { + return { + status: "fallback", + source: "deterministic", + repoMap: deterministicMap, + confidence: null, + summary: "Local AI repo-map profiling was not used.", + warnings: [reason], + reason, + }; +} + +function extractJsonPayload(raw: string): string { + const trimmed = raw.trim(); + const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/iu); + if (fenced?.[1]) return fenced[1].trim(); + const firstObject = trimmed.indexOf("{"); + const lastObject = trimmed.lastIndexOf("}"); + if (firstObject !== -1 && lastObject > firstObject) { + return trimmed.slice(firstObject, lastObject + 1); + } + return trimmed; +} + +async function runRepoMapProfilerProcess({ + spawnProcess, + executable, + args, + prompt, + timeoutMs, +}: { + spawnProcess: typeof spawn; + executable: string; + args: string[]; + prompt: string; + timeoutMs: number; +}): Promise { + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + return await new Promise((resolve, reject) => { + let settled = false; + const child = spawnProcess(executable, args, { + stdio: "pipe", + env: stripAgentRailSecrets(process.env), + }); + const timeout = setTimeout(() => { + child.kill("SIGTERM"); + if (!settled) { + settled = true; + reject(new Error(`Repo-map profiler timed out after ${timeoutMs}ms.`)); + } + }, timeoutMs); + child.stdout.on("data", chunk => { + if (Buffer.concat(stdoutChunks).length < MAX_STDIO_BYTES) stdoutChunks.push(Buffer.from(chunk)); + }); + child.stderr.on("data", chunk => { + if (Buffer.concat(stderrChunks).length < MAX_STDIO_BYTES) stderrChunks.push(Buffer.from(chunk)); + }); + child.on("error", error => { + clearTimeout(timeout); + if (!settled) { + settled = true; + reject(error); + } + }); + child.on("close", code => { + clearTimeout(timeout); + if (settled) return; + settled = true; + const stdout = Buffer.concat(stdoutChunks).toString("utf8").trim(); + const stderr = Buffer.concat(stderrChunks).toString("utf8").trim(); + if (code !== 0) { + reject(new Error(stderr || stdout || `Repo-map profiler exited with code ${code}.`)); + return; + } + resolve(stdout || stderr); + }); + child.stdin.end(prompt); + }); +} + +function stripAgentRailSecrets(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const next: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(env)) { + const normalizedKey = key.toUpperCase(); + if (normalizedKey.includes("TOKEN") || normalizedKey.includes("KEY") || normalizedKey.includes("SECRET")) continue; + next[key] = value; + } + return next; +} + +function stringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .filter((item): item is string => typeof item === "string" && item.trim().length > 0) + .map((item) => item.trim()) + .slice(0, 20); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function toPosixPath(value: string): string { + return value.split(path.sep).join("/"); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/repo-map.ts b/src/repo-map.ts index 01ed347..3865286 100644 --- a/src/repo-map.ts +++ b/src/repo-map.ts @@ -49,6 +49,11 @@ export interface EnsureRepoMapResult { reason?: string; } +export interface RepoMapCreationResult { + repoMap: AgentRailRepoMap; + reason?: string; +} + const REPO_MAP_EXTENSION = ".yaml"; const AREA_PATTERN = /^[a-z][a-z0-9_]*$/u; const TOP_LEVEL_KEYS = new Set(["schemaVersion", "areas", "ignoredPaths", "generatedPaths", "commands"]); @@ -115,9 +120,11 @@ export function repoMapPathForHome(homePath: string, repoSlug: string): string { export async function ensureRepoMapForRepo({ homePath, repo, + createRepoMap, }: { homePath: string; repo: ConnectedRepo; + createRepoMap?: (repoPath: string) => Promise; }): Promise { const repoMapPath = repoMapPathForHome(homePath, repo.slug); const existing = await loadRepoMapFromFile(repoMapPath); @@ -143,7 +150,12 @@ export async function ensureRepoMapForRepo({ reason = `Replaced invalid repo map; backup written to ${backupPath}.`; } - const repoMap = await generateStarterRepoMap(repo.path); + const created = await (createRepoMap + ? createRepoMap(repo.path) + : generateStarterRepoMap(repo.path)); + const repoMap = "repoMap" in created ? created.repoMap : created; + const createReason = "repoMap" in created ? created.reason : undefined; + reason = [reason, createReason].filter(Boolean).join(" "); await mkdir(path.dirname(repoMapPath), { recursive: true, mode: 0o700 }); await writeFile(repoMapPath, renderRepoMapYaml(repoMap), { encoding: "utf8", mode: 0o600 }); return { diff --git a/test/init-local-bootstrap.test.ts b/test/init-local-bootstrap.test.ts index 97c9118..0fa0288 100644 --- a/test/init-local-bootstrap.test.ts +++ b/test/init-local-bootstrap.test.ts @@ -212,6 +212,79 @@ test("repo add writes a repo map for the added local repo", async (t) => { assert.match(repoMap, /commands:/u); }); +test("repo add uses configured AI repo-map profiling and falls back safely", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-repo-add-ai-primary-")); + const addedRepo = await mkdtemp(path.join(os.tmpdir(), "agentrail-repo-add-ai-added-")); + const agentrailHome = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const stdout = createMemoryWriter(); + const stderr = createMemoryWriter(); + const previousHome = process.env.AGENTRAIL_HOME; + process.env.AGENTRAIL_HOME = agentrailHome; + await writeFile(path.join(addedRepo, "package.json"), JSON.stringify({ scripts: { test: "node --test" } }), "utf8"); + + t.after(async () => { + await rm(repoRoot, { recursive: true, force: true }); + await rm(addedRepo, { recursive: true, force: true }); + await rm(agentrailHome, { recursive: true, force: true }); + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + }); + + const initExitCode = await runCli([ + "init", + "--yes", + "--mode", + "server", + "--provider-mode", + "disabled", + "--repo", + repoRoot, + "--base-url", + "http://127.0.0.1:3000", + "--repo-map-mode", + "ai-assist", + "--repo-map-profiler-runner", + "custom", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout, + stderr, + detectRepoContext: async () => ({ + repoPath: repoRoot, + remoteSlug: "oxnw/agentrail", + defaultBranch: "main", + gitIgnoreHasAgentrail: true, + }), + }); + assert.equal(initExitCode, 0, stderr.toString()); + + const addStdout = createMemoryWriter(); + const addStderr = createMemoryWriter(); + const addExitCode = await runCli([ + "repo", + "add", + "--repo", + addedRepo, + "--slug", + "oxnw/agentrail-added-ai", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout: addStdout, + stderr: addStderr, + }); + + assert.equal(addExitCode, 0, addStderr.toString()); + assert.match(addStdout.toString(), /Wrote repo map/); + assert.match(addStdout.toString(), /Local AI repo-map proposal was not used/); + const repoMap = await readFile(repoMapPathForHome(agentrailHome, "oxnw/agentrail-added-ai"), "utf8"); + assert.match(repoMap, /package_manifests:/u); + assert.match(repoMap, /commands:/u); +}); + test("temporary local verification reuses an already-running healthy server", async (t) => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-init-reuse-server-")); const server = http.createServer((request, response) => { diff --git a/test/repo-map-profiler.test.ts b/test/repo-map-profiler.test.ts new file mode 100644 index 0000000..1d3a709 --- /dev/null +++ b/test/repo-map-profiler.test.ts @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { AGENTRAIL_REPO_MAP_SCHEMA_VERSION, generateStarterRepoMap } from "../src/repo-map.ts"; +import { + buildRepoMapProfilerPrompt, + parseRepoMapProfilerOutput, + repoMapProfilerLaunchCommand, + scanRepoForRepoMapProfile, +} from "../src/repo-map-profiler.ts"; + +function makeTempDir(t: test.TestContext, prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + t.after(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + return dir; +} + +test("scanRepoForRepoMapProfile excludes secret-like files and reads package scripts", async (t) => { + const repoRoot = makeTempDir(t, "agentrail-repo-profile-"); + fs.mkdirSync(path.join(repoRoot, "app"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, "node_modules", "dep"), { recursive: true }); + fs.writeFileSync(path.join(repoRoot, ".env"), "SECRET=value\n", "utf8"); + fs.writeFileSync(path.join(repoRoot, "token.txt"), "secret\n", "utf8"); + fs.writeFileSync(path.join(repoRoot, "package.json"), JSON.stringify({ + scripts: { + test: "node --test", + build: "tsc", + }, + }), "utf8"); + + const scan = await scanRepoForRepoMapProfile(repoRoot); + + assert.ok(scan.tree.includes("app/")); + assert.equal(scan.tree.some((entry) => entry.includes(".env")), false); + assert.equal(scan.tree.some((entry) => entry.includes("node_modules")), false); + assert.equal(scan.tree.some((entry) => entry.includes("token")), false); + assert.deepEqual(scan.packageScripts, { + test: "node --test", + build: "tsc", + }); +}); + +test("buildRepoMapProfilerPrompt includes starter map and bounded repo summary", async (t) => { + const repoRoot = makeTempDir(t, "agentrail-repo-profile-"); + fs.mkdirSync(path.join(repoRoot, "src"), { recursive: true }); + fs.writeFileSync(path.join(repoRoot, "package.json"), JSON.stringify({ scripts: { test: "node --test" } }), "utf8"); + const deterministicMap = await generateStarterRepoMap(repoRoot); + const scan = await scanRepoForRepoMapProfile(repoRoot, deterministicMap); + + const prompt = buildRepoMapProfilerPrompt({ + repoSlug: "oxnw/example", + deterministicMap, + scan, + }); + + assert.match(prompt, /AgentRail's local repo-map profiler/u); + assert.match(prompt, /schemaVersion: agentrail\.repo-map\/v1/u); + assert.match(prompt, /"packageScripts"/u); + assert.match(prompt, /node --test/u); +}); + +test("parseRepoMapProfilerOutput accepts valid high-confidence repo maps", async (t) => { + const repoRoot = makeTempDir(t, "agentrail-repo-profile-"); + fs.mkdirSync(path.join(repoRoot, "app"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, "test"), { recursive: true }); + const deterministicMap = await generateStarterRepoMap(repoRoot); + + const result = await parseRepoMapProfilerOutput(JSON.stringify({ + confidence: 0.91, + summary: "Mapped the app and test folders.", + warnings: [], + repoMap: { + schemaVersion: AGENTRAIL_REPO_MAP_SCHEMA_VERSION, + areas: { + frontend_source: { + paths: ["app/**"], + notes: "App source.", + }, + tests: { + paths: ["test/**"], + testCommands: ["npm run test"], + }, + }, + ignoredPaths: [".git"], + generatedPaths: [], + commands: { + test: ["npm run test"], + }, + }, + }), { + repoRoot, + deterministicMap, + confidenceThreshold: 0.65, + }); + + assert.equal(result.status, "accepted"); + assert.equal(result.source, "ai"); + assert.equal(result.confidence, 0.91); + assert.deepEqual(result.repoMap.areas.frontend_source?.paths, ["app/**"]); +}); + +test("parseRepoMapProfilerOutput falls back on invalid JSON, unsafe paths, missing paths, and low confidence", async (t) => { + const repoRoot = makeTempDir(t, "agentrail-repo-profile-"); + fs.mkdirSync(path.join(repoRoot, "src"), { recursive: true }); + const deterministicMap = await generateStarterRepoMap(repoRoot); + + const invalidJson = await parseRepoMapProfilerOutput("not json", { + repoRoot, + deterministicMap, + confidenceThreshold: 0.65, + }); + assert.equal(invalidJson.status, "fallback"); + assert.match(invalidJson.reason ?? "", /not valid JSON/u); + + const unsafePath = await parseRepoMapProfilerOutput(JSON.stringify({ + confidence: 0.9, + repoMap: { + schemaVersion: AGENTRAIL_REPO_MAP_SCHEMA_VERSION, + areas: { application_source: { paths: ["../src"] } }, + ignoredPaths: [], + generatedPaths: [], + commands: {}, + }, + }), { + repoRoot, + deterministicMap, + confidenceThreshold: 0.65, + }); + assert.equal(unsafePath.status, "fallback"); + assert.match(unsafePath.reason ?? "", /escapes the repo/u); + + const missingPath = await parseRepoMapProfilerOutput(JSON.stringify({ + confidence: 0.9, + repoMap: { + schemaVersion: AGENTRAIL_REPO_MAP_SCHEMA_VERSION, + areas: { application_source: { paths: ["missing/**"] } }, + ignoredPaths: [], + generatedPaths: [], + commands: {}, + }, + }), { + repoRoot, + deterministicMap, + confidenceThreshold: 0.65, + }); + assert.equal(missingPath.status, "fallback"); + assert.match(missingPath.reason ?? "", /missing path/u); + + const lowConfidence = await parseRepoMapProfilerOutput(JSON.stringify({ + confidence: 0.2, + repoMap: { + schemaVersion: AGENTRAIL_REPO_MAP_SCHEMA_VERSION, + areas: { application_source: { paths: ["src/**"] } }, + ignoredPaths: [], + generatedPaths: [], + commands: {}, + }, + }), { + repoRoot, + deterministicMap, + confidenceThreshold: 0.65, + }); + assert.equal(lowConfidence.status, "fallback"); + assert.match(lowConfidence.reason ?? "", /below 0\.65/u); +}); + +test("repoMapProfilerLaunchCommand uses local runner commands", () => { + assert.deepEqual(repoMapProfilerLaunchCommand({ + runner: "codex", + model: "gpt-5.5", + timeoutMs: 1000, + confidenceThreshold: 0.65, + }), { + executable: "codex", + args: ["-a", "never", "exec", "--sandbox", "read-only", "--model", "gpt-5.5", "-"], + }); + assert.deepEqual(repoMapProfilerLaunchCommand({ + runner: "cursor", + model: "sonnet-4", + timeoutMs: 1000, + confidenceThreshold: 0.65, + }), { + executable: "cursor-agent", + args: ["--print", "--model", "sonnet-4"], + }); +}); diff --git a/test/setup-files.test.ts b/test/setup-files.test.ts index f783bc8..f7980cc 100644 --- a/test/setup-files.test.ts +++ b/test/setup-files.test.ts @@ -2,12 +2,12 @@ import assert from "node:assert/strict"; import os from "node:os"; import path from "node:path"; import { existsSync } from "node:fs"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import test from "node:test"; import { createSetupConfig, type DetectedRepoContext } from "../src/cli/setup-config.ts"; import { writeSetupFiles } from "../src/cli/setup-files.ts"; -import { repoMapPathForHome } from "../src/repo-map.ts"; +import { AGENTRAIL_REPO_MAP_SCHEMA_VERSION, repoMapPathForHome } from "../src/repo-map.ts"; test("writeSetupFiles creates local setup files without agent secrets", async (t) => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-setup-")); @@ -59,6 +59,7 @@ test("writeSetupFiles creates local setup files without agent secrets", async (t assert.equal(configBody.mode, "server"); assert.equal(configBody.server.baseUrl, "http://127.0.0.1:3000"); + assert.equal(configBody.repoMaps.generationMode, "deterministic"); assert.equal(configBody.runnerPolicy.preset, "strict"); assert.equal(configBody.runnerPolicy.publish.mode, "agentrail_owned"); assert.doesNotMatch(configText, /"apiKey"\s*:/); @@ -85,6 +86,7 @@ test("writeSetupFiles creates local setup files without agent secrets", async (t assert.match(readme, /separate runner connection file for each agent/i); assert.match(readme, /repo-maps/i); assert.match(readme, /editable repo maps/i); + assert.match(readme, /Starter repo maps are generated deterministically/i); assert.match(readme, /abstract areas like `frontend_source`, `tests`, or `ci_configuration`/i); assert.match(readme, /role template references areas that are missing from the repo map/i); assert.doesNotMatch(readme, /local agent profile for this machine/i); @@ -141,6 +143,118 @@ test("writeSetupFiles creates local setup files without agent secrets", async (t assert.match(repoMap, /schemaVersion: agentrail\.repo-map\/v1/); }); +test("writeSetupFiles writes an accepted local AI repo-map proposal", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-setup-ai-map-")); + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-ai-map-")); + const detectedRepo: DetectedRepoContext = { + repoPath: repoRoot, + remoteSlug: "oxnw/agentrail", + defaultBranch: "main", + gitIgnoreHasAgentrail: true, + }; + await rm(path.join(repoRoot, "app"), { recursive: true, force: true }); + + t.after(async () => { + await rm(repoRoot, { recursive: true, force: true }); + await rm(homePath, { recursive: true, force: true }); + }); + + const config = createSetupConfig({ + cwd: repoRoot, + detectedRepo, + interactionMode: "interactive", + acceptedDefaults: false, + repoMapGenerationMode: "ai_assist", + repoMapProfilerRunner: "codex", + repoMapProfilerModel: "gpt-5.4-mini", + }); + + await writeSetupFiles({ + homePath, + config, + repoMapProfiler: async () => ({ + status: "accepted", + source: "ai", + confidence: 0.9, + summary: "Mapped docs only.", + warnings: [], + repoMap: { + schemaVersion: AGENTRAIL_REPO_MAP_SCHEMA_VERSION, + areas: { + documentation: { + paths: ["README.md"], + notes: "Docs entrypoint.", + }, + }, + ignoredPaths: [], + generatedPaths: [], + commands: {}, + }, + reason: "accepted", + }), + onRepoMapProposal: async () => "accept", + }); + + const repoMap = await readFile(repoMapPathForHome(homePath, "oxnw/agentrail"), "utf8"); + const readme = await readFile(path.join(homePath, "README.md"), "utf8"); + + assert.match(repoMap, /documentation:/u); + assert.match(repoMap, /README\.md/u); + assert.match(readme, /Repo maps can optionally be improved with a local AI runner/i); + assert.match(readme, /codex/i); + assert.match(readme, /gpt-5\.4-mini/i); +}); + +test("writeSetupFiles falls back to the starter map when an AI proposal is rejected", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-setup-ai-map-fallback-")); + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-ai-map-fallback-")); + const detectedRepo: DetectedRepoContext = { + repoPath: repoRoot, + remoteSlug: "oxnw/agentrail", + defaultBranch: "main", + gitIgnoreHasAgentrail: true, + }; + await writeFile(path.join(repoRoot, "package.json"), JSON.stringify({ scripts: { test: "node --test" } }), "utf8"); + + t.after(async () => { + await rm(repoRoot, { recursive: true, force: true }); + await rm(homePath, { recursive: true, force: true }); + }); + + const config = createSetupConfig({ + cwd: repoRoot, + detectedRepo, + interactionMode: "interactive", + acceptedDefaults: false, + repoMapGenerationMode: "ai_assist", + }); + + await writeSetupFiles({ + homePath, + config, + repoMapProfiler: async ({ deterministicMap }) => ({ + status: "accepted", + source: "ai", + confidence: 0.9, + summary: "Use only docs.", + warnings: [], + repoMap: { + ...deterministicMap, + areas: { + documentation: { + paths: ["README.md"], + }, + }, + }, + }), + onRepoMapProposal: async () => "reject", + }); + + const repoMap = await readFile(repoMapPathForHome(homePath, "oxnw/agentrail"), "utf8"); + assert.match(repoMap, /package_manifests:/u); + assert.doesNotMatch(repoMap, /documentation:/u); +}); + test("writeSetupFiles documents ai-assisted routing", async (t) => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-setup-ai-")); const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-ai-")); diff --git a/test/setup-wizard.test.ts b/test/setup-wizard.test.ts index e141fc3..1d4579a 100644 --- a/test/setup-wizard.test.ts +++ b/test/setup-wizard.test.ts @@ -34,6 +34,7 @@ test("runCli starts the guided setup wizard in TTY mode by default", async () => { kind: "input", value: "develop" }, { kind: "input", value: "http://127.0.0.1:4100" }, { kind: "select", value: "rules_only" }, + { kind: "confirm", value: false }, { kind: "select", value: "github_rules" }, { kind: "select", value: "strict" }, { kind: "confirm", value: false }, @@ -71,7 +72,7 @@ test("runCli starts the guided setup wizard in TTY mode by default", async () => await rm(agentrailHome, { recursive: true, force: true }); assert.equal(exitCode, 0); - assert.deepEqual(prompt.calls, ["input", "input", "input", "input", "select", "select", "select", "confirm", "confirm", "confirm", "confirm"]); + assert.deepEqual(prompt.calls, ["input", "input", "input", "input", "select", "confirm", "select", "select", "confirm", "confirm", "confirm", "confirm"]); assert.equal(prompt.notes[0]?.title, "Review setup plan"); assert.match(prompt.notes[0]?.body ?? "", /AgentRail is ready to create its local home and connect your first repo\./); assert.match(prompt.notes[0]?.body ?? "", /Setup choices:/); @@ -130,6 +131,7 @@ test("runCli asks how AI routing should handle tasks without a suitable agent", { kind: "select", value: "codex" }, { kind: "select", value: "gpt-5.4" }, { kind: "select", value: "require_suitable_agent" }, + { kind: "confirm", value: false }, { kind: "select", value: "github_rules" }, { kind: "select", value: "strict" }, { kind: "confirm", value: false }, @@ -171,8 +173,9 @@ test("runCli asks how AI routing should handle tasks without a suitable agent", assert.equal(prompt.interactions[5]?.message, "AI routing runner"); assert.equal(prompt.interactions[6]?.message, "Codex routing model"); assert.equal(prompt.interactions[7]?.message, "When AI routing cannot find a suitable agent"); - assert.equal(prompt.interactions[8]?.message, "Should AgentRail wait for code review before shipping PRs?"); - assert.equal(prompt.interactions[9]?.message, "How tightly should AgentRail restrict local agents?"); + assert.equal(prompt.interactions[8]?.message, "Improve the repo map with local AI?"); + assert.equal(prompt.interactions[9]?.message, "Should AgentRail wait for code review before shipping PRs?"); + assert.equal(prompt.interactions[10]?.message, "How tightly should AgentRail restrict local agents?"); assert.match(prompt.notes[0]?.body ?? "", /Routing mode: Use AI to route tasks to the right agents/); assert.match(prompt.notes[0]?.body ?? "", /AI routing model: gpt-5\.4/); assert.match(prompt.notes[0]?.body ?? "", /No suitable agent policy: Require a suitable agent/); @@ -181,6 +184,60 @@ test("runCli asks how AI routing should handle tasks without a suitable agent", assert.equal(writes[0]?.config.routing.classifier.fallbackBehavior, "require_suitable_agent"); }); +test("runCli can opt into local AI repo-map profiling during init", async () => { + const agentrailHome = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const previousHome = process.env.AGENTRAIL_HOME; + process.env.AGENTRAIL_HOME = agentrailHome; + const stdout = createMemoryWriter(); + const stderr = createMemoryWriter(); + const writes: Array<{ homePath?: string; repoRoot?: string; config: SetupConfig }> = []; + const prompt = new ScriptedPromptSession([ + { kind: "input", value: detectedRepo.repoPath }, + { kind: "input", value: `https://github.com/${detectedRepo.remoteSlug}` }, + { kind: "input", value: detectedRepo.defaultBranch }, + { kind: "input", value: "http://127.0.0.1:3000" }, + { kind: "select", value: "rules_only" }, + { kind: "confirm", value: true }, + { kind: "select", value: "codex" }, + { kind: "select", value: "__default__" }, + { kind: "select", value: "github_rules" }, + { kind: "select", value: "strict" }, + { kind: "confirm", value: false }, + { kind: "confirm", value: true }, + { kind: "confirm", value: false }, + { kind: "confirm", value: false }, + ]); + + const exitCode = await runCli(["init"], { + cwd: detectedRepo.repoPath, + stdinIsTTY: true, + stdoutIsTTY: true, + stdout, + stderr, + detectRepoContext: async () => detectedRepo, + createPrompt: () => prompt, + writeSetupFiles: async ({ homePath, repoRoot, config }) => { + writes.push({ homePath, repoRoot, config }); + return { writtenPaths: [] }; + }, + }); + + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + await rm(agentrailHome, { recursive: true, force: true }); + + assert.equal(exitCode, 0, stderr.toString()); + assert.equal(prompt.interactions[5]?.message, "Improve the repo map with local AI?"); + assert.equal(prompt.interactions[6]?.message, "Repo map AI runner"); + assert.equal(prompt.interactions[7]?.message, "Codex repo map model"); + assert.equal(writes[0]?.config.repoMaps.generationMode, "ai_assist"); + assert.equal(writes[0]?.config.repoMaps.profiler.runner, "codex"); + assert.equal(writes[0]?.config.repoMaps.profiler.model, null); + assert.match(prompt.notes[0]?.body ?? "", /Repo map: Improve with local AI/); + assert.match(prompt.notes[0]?.body ?? "", /Repo map AI runner: codex/); + assert.doesNotMatch(prompt.notes[0]?.body ?? "", /API key/i); +}); + test("runCli offers Claude Code model choices for AI routing", async () => { const agentrailHome = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); const previousHome = process.env.AGENTRAIL_HOME; @@ -197,6 +254,7 @@ test("runCli offers Claude Code model choices for AI routing", async () => { { kind: "select", value: "claude-code" }, { kind: "select", value: "claude-opus-4-5-20251101" }, { kind: "select", value: "require_suitable_agent" }, + { kind: "confirm", value: false }, { kind: "select", value: "github_rules" }, { kind: "select", value: "strict" }, { kind: "confirm", value: false }, @@ -246,6 +304,7 @@ test("runCli offers Cursor model choices for AI routing", async () => { { kind: "select", value: "cursor" }, { kind: "select", value: "sonnet-4-thinking" }, { kind: "select", value: "require_suitable_agent" }, + { kind: "confirm", value: false }, { kind: "select", value: "github_rules" }, { kind: "select", value: "strict" }, { kind: "confirm", value: false }, @@ -297,6 +356,7 @@ test("runCli can connect GitHub during init with a hidden token prompt and shows { kind: "input", value: localRepo.defaultBranch }, { kind: "input", value: "http://127.0.0.1:3000" }, { kind: "select", value: "rules_only" }, + { kind: "confirm", value: false }, { kind: "select", value: "github_rules" }, { kind: "select", value: "strict" }, { kind: "confirm", value: false }, @@ -390,6 +450,7 @@ test("runCli lets the user cancel instead of writing files at the final confirma { kind: "input", value: detectedRepo.defaultBranch }, { kind: "input", value: "http://127.0.0.1:3000" }, { kind: "select", value: "rules_only" }, + { kind: "confirm", value: false }, { kind: "select", value: "github_rules" }, { kind: "select", value: "strict" }, { kind: "confirm", value: false }, @@ -435,6 +496,7 @@ test("runCli re-prompts when the GitHub repo input is not a valid owner/repo or { kind: "input", value: detectedRepo.defaultBranch }, { kind: "input", value: "http://127.0.0.1:3000" }, { kind: "select", value: "rules_only" }, + { kind: "confirm", value: false }, { kind: "select", value: "github_rules" }, { kind: "select", value: "strict" }, { kind: "confirm", value: false }, @@ -484,6 +546,7 @@ test("runCli normalizes .git suffix from GitHub repo input", async () => { { kind: "input", value: detectedRepo.defaultBranch }, { kind: "input", value: "http://127.0.0.1:3000" }, { kind: "select", value: "rules_only" }, + { kind: "confirm", value: false }, { kind: "select", value: "github_rules" }, { kind: "select", value: "strict" }, { kind: "confirm", value: false }, @@ -527,6 +590,7 @@ test("runCli review plan describes disabled telemetry when init opts out", async { kind: "input", value: detectedRepo.defaultBranch }, { kind: "input", value: "http://127.0.0.1:3000" }, { kind: "select", value: "rules_only" }, + { kind: "confirm", value: false }, { kind: "select", value: "github_rules" }, { kind: "select", value: "strict" }, { kind: "confirm", value: false }, @@ -569,6 +633,7 @@ test("runCli print-only mode does not show file-write next steps", async () => { { kind: "input", value: detectedRepo.defaultBranch }, { kind: "input", value: "http://127.0.0.1:3000" }, { kind: "select", value: "rules_only" }, + { kind: "confirm", value: false }, { kind: "select", value: "github_rules" }, { kind: "select", value: "strict" }, { kind: "confirm", value: false }, @@ -878,7 +943,7 @@ test("runCli init captures start and completion around setup file writes", async assert.doesNotMatch(serialized, /\/tmp\/agentrail/); }); -test("runCli init help documents telemetry flags", async () => { +test("runCli init help documents telemetry and repo-map flags", async () => { const stdout = createMemoryWriter(); const stderr = createMemoryWriter(); @@ -894,6 +959,9 @@ test("runCli init help documents telemetry flags", async () => { assert.equal(exitCode, 0, stderr.toString()); assert.match(stdout.toString(), /--telemetry/); assert.match(stdout.toString(), /--no-telemetry/); + assert.match(stdout.toString(), /--repo-map-mode /); + assert.match(stdout.toString(), /--repo-map-profiler-runner /); + assert.match(stdout.toString(), /--repo-map-profiler-model /); assert.equal(stderr.toString(), ""); }); From bffb1ad8cb60fb1f4e12fa396c02a3ec37dff044 Mon Sep 17 00:00:00 2001 From: Onyeka Nwamba Date: Tue, 26 May 2026 00:11:03 +0100 Subject: [PATCH 3/7] fix: separate agent role templates from roles --- defaults/agent-roles/backend-api.yaml | 1 + defaults/agent-roles/ci-release.yaml | 1 + defaults/agent-roles/code-review.yaml | 1 + defaults/agent-roles/debugger.yaml | 1 + defaults/agent-roles/docs.yaml | 1 + defaults/agent-roles/frontend-ui.yaml | 1 + defaults/agent-roles/security.yaml | 1 + defaults/agent-roles/test-automation.yaml | 1 + src/agent-role-template.ts | 3 ++ src/cli/agent-management.ts | 34 +++++++++++++++++++---- test/agent-management.test.ts | 21 +++++++------- test/agent-role-template.test.ts | 2 ++ test/managed-run-role-brief.test.ts | 1 + 13 files changed, 53 insertions(+), 16 deletions(-) diff --git a/defaults/agent-roles/backend-api.yaml b/defaults/agent-roles/backend-api.yaml index dfa5c06..7ab172f 100644 --- a/defaults/agent-roles/backend-api.yaml +++ b/defaults/agent-roles/backend-api.yaml @@ -1,6 +1,7 @@ schemaVersion: agentrail.agent-role/v1 id: backend-api name: Backend/API +role: coding_agent description: Handles server code, APIs, provider adapters, lifecycle behavior, data contracts, and backend tests. routing: diff --git a/defaults/agent-roles/ci-release.yaml b/defaults/agent-roles/ci-release.yaml index 3213420..208a18f 100644 --- a/defaults/agent-roles/ci-release.yaml +++ b/defaults/agent-roles/ci-release.yaml @@ -1,6 +1,7 @@ schemaVersion: agentrail.agent-role/v1 id: ci-release name: CI/Release +role: release_agent description: Handles CI failures, workflow configuration, package publishing, deployment readiness, and release automation. routing: diff --git a/defaults/agent-roles/code-review.yaml b/defaults/agent-roles/code-review.yaml index e8bb359..37419fa 100644 --- a/defaults/agent-roles/code-review.yaml +++ b/defaults/agent-roles/code-review.yaml @@ -1,6 +1,7 @@ schemaVersion: agentrail.agent-role/v1 id: code-review name: Code Review +role: review_agent description: Reviews code changes for correctness, regressions, security risks, maintainability, and missing tests. routing: diff --git a/defaults/agent-roles/debugger.yaml b/defaults/agent-roles/debugger.yaml index 8fd78d4..70d0259 100644 --- a/defaults/agent-roles/debugger.yaml +++ b/defaults/agent-roles/debugger.yaml @@ -1,6 +1,7 @@ schemaVersion: agentrail.agent-role/v1 id: debugger name: Debugger +role: coding_agent description: Investigates failures, reproduces bugs, identifies root causes, and applies narrow fixes when requested. routing: diff --git a/defaults/agent-roles/docs.yaml b/defaults/agent-roles/docs.yaml index 6ab5e4f..4043114 100644 --- a/defaults/agent-roles/docs.yaml +++ b/defaults/agent-roles/docs.yaml @@ -1,6 +1,7 @@ schemaVersion: agentrail.agent-role/v1 id: docs name: Docs +role: docs_agent description: Handles documentation, guides, README updates, SDK examples, changelogs, and release notes. routing: diff --git a/defaults/agent-roles/frontend-ui.yaml b/defaults/agent-roles/frontend-ui.yaml index aae08ae..0a04a3a 100644 --- a/defaults/agent-roles/frontend-ui.yaml +++ b/defaults/agent-roles/frontend-ui.yaml @@ -1,6 +1,7 @@ schemaVersion: agentrail.agent-role/v1 id: frontend-ui name: Frontend/UI +role: coding_agent description: Handles frontend application code, visual polish, responsive layout, accessibility, and browser-facing interactions. routing: diff --git a/defaults/agent-roles/security.yaml b/defaults/agent-roles/security.yaml index 421a608..c4135dc 100644 --- a/defaults/agent-roles/security.yaml +++ b/defaults/agent-roles/security.yaml @@ -1,6 +1,7 @@ schemaVersion: agentrail.agent-role/v1 id: security name: Security +role: review_agent description: Reviews and fixes security-sensitive code, auth boundaries, secret handling, webhooks, sandboxing, and permission policies. routing: diff --git a/defaults/agent-roles/test-automation.yaml b/defaults/agent-roles/test-automation.yaml index 7b35e35..bfc935b 100644 --- a/defaults/agent-roles/test-automation.yaml +++ b/defaults/agent-roles/test-automation.yaml @@ -1,6 +1,7 @@ schemaVersion: agentrail.agent-role/v1 id: test-automation name: Test Automation +role: coding_agent description: Adds, repairs, and strengthens automated tests, fixtures, smoke tests, and validation scripts. routing: diff --git a/src/agent-role-template.ts b/src/agent-role-template.ts index 81259a0..b0ea7da 100644 --- a/src/agent-role-template.ts +++ b/src/agent-role-template.ts @@ -19,6 +19,7 @@ export interface AgentRoleTemplate { schemaVersion: typeof AGENT_ROLE_TEMPLATE_SCHEMA_VERSION; id: string; name: string; + role: string; description: string; routing: { capabilityTags: string[]; @@ -73,6 +74,7 @@ const TOP_LEVEL_KEYS = new Set([ "schemaVersion", "id", "name", + "role", "description", "routing", "runtime", @@ -211,6 +213,7 @@ export function validateAgentRoleTemplate(value: unknown, sourcePath = " schemaVersion: AGENT_ROLE_TEMPLATE_SCHEMA_VERSION, id, name: requireString(root.name, sourcePath, "name"), + role: requireString(root.role, sourcePath, "role"), description: requireString(root.description, sourcePath, "description"), routing: validateRouting(root.routing, sourcePath), runtime: validateRuntime(root.runtime, sourcePath), diff --git a/src/cli/agent-management.ts b/src/cli/agent-management.ts index 96ae6b7..0bcef97 100644 --- a/src/cli/agent-management.ts +++ b/src/cli/agent-management.ts @@ -1028,12 +1028,20 @@ async function collectUpdateInputs({ flags, homePath, }); - const roleTemplateId = roleTemplate?.template.id ?? (flags.role ? null : currentProfile.roleTemplateId ?? null); - const role = flags.role ?? roleTemplate?.template.id ?? (prompt ? await prompt.input({ + const currentRoleTemplate = roleTemplate || !currentProfile.roleTemplateId + ? null + : await resolveRoleTemplateByIdOrNull({ homePath, id: currentProfile.roleTemplateId }); + const role = flags.role ?? roleTemplate?.template.role ?? (prompt ? await prompt.input({ message: "Role", defaultValue: currentProfile.role ?? currentUsage.agent?.role ?? "coding_agent", }) : currentProfile.role ?? currentUsage.agent?.role ?? "coding_agent"); assertRoleTemplateMatchesRole(roleTemplate, role); + const roleTemplateId = roleTemplate && role === roleTemplate.template.role + ? roleTemplate.template.id + : currentRoleTemplate && role === currentRoleTemplate.template.role + ? currentRoleTemplate.template.id + : null; + const activeRoleTemplate = roleTemplate ?? (roleTemplateId ? currentRoleTemplate : null); const scopes = await resolveAgentScopes({ flags, prompt, @@ -1052,7 +1060,7 @@ async function collectUpdateInputs({ name, role, roleTemplateId, - roleTemplateName: roleTemplate?.template.name ?? null, + roleTemplateName: activeRoleTemplate?.template.name ?? null, runner, model, repoPath: currentProfile.repoAllowlist?.[0] @@ -1693,8 +1701,8 @@ function describeRoleHint(role: string): string | null { } function assertRoleTemplateMatchesRole(roleTemplate: LoadedAgentRoleTemplate | null, role: string): void { - if (roleTemplate && role !== roleTemplate.template.id) { - throw new Error(`--role-template ${roleTemplate.template.id} sets role to ${roleTemplate.template.id}; remove --role or set --role ${roleTemplate.template.id}.`); + if (roleTemplate && role !== roleTemplate.template.role) { + throw new Error(`--role-template ${roleTemplate.template.id} sets role to ${roleTemplate.template.role}; remove --role or set --role ${roleTemplate.template.role}.`); } } @@ -1979,6 +1987,20 @@ async function resolveRoleTemplateById({ return template; } +async function resolveRoleTemplateByIdOrNull({ + homePath, + id, +}: { + homePath: string; + id: string; +}): Promise { + try { + return await resolveRoleTemplateById({ homePath, id }); + } catch { + return null; + } +} + async function resolveCreateCapabilityTags({ flags, prompt, @@ -2092,7 +2114,7 @@ async function resolveCreateRole({ assertRoleTemplateMatchesRole(roleTemplate, flags.role); return flags.role; } - if (roleTemplate) return roleTemplate.template.id; + if (roleTemplate) return roleTemplate.template.role; if (!prompt) return "coding_agent"; await describeCreateStep( diff --git a/test/agent-management.test.ts b/test/agent-management.test.ts index c319525..2c50110 100644 --- a/test/agent-management.test.ts +++ b/test/agent-management.test.ts @@ -26,6 +26,7 @@ function customRoleTemplateYaml(id = "payments-backend"): string { return `schemaVersion: ${AGENT_ROLE_TEMPLATE_SCHEMA_VERSION} id: ${id} name: Payments Backend +role: coding_agent description: Handles payments and backend integration work. routing: @@ -314,7 +315,7 @@ test("agent create derives profile metadata from a built-in role template", asyn const profile = await getJson(harness.baseUrl, "/operator/routing/agent-profiles/agt_frontend", harness.operatorApiKey); assert.equal(profile.status, 200); - assert.equal(profile.json.data.role, "frontend-ui"); + assert.equal(profile.json.data.role, "coding_agent"); assert.equal(profile.json.data.roleTemplateId, "frontend-ui"); assert.ok(profile.json.data.capabilityTags.includes("frontend")); assert.ok(profile.json.data.capabilityTags.includes("accessibility")); @@ -377,7 +378,7 @@ test("agent create installs a custom role template file and reuses it by id", as const firstProfile = await getJson(harness.baseUrl, "/operator/routing/agent-profiles/agt_payments", harness.operatorApiKey); assert.equal(firstProfile.status, 200); - assert.equal(firstProfile.json.data.role, "payments-backend"); + assert.equal(firstProfile.json.data.role, "coding_agent"); assert.equal(firstProfile.json.data.roleTemplateId, "payments-backend"); assert.deepEqual(firstProfile.json.data.capabilityTags, ["payments", "backend", "api"]); assert.deepEqual(firstProfile.json.data.ownershipTags, ["payments", "backend_source"]); @@ -471,7 +472,7 @@ test("agent create rejects conflicting role template and role flags", async (t) "--role-template", "frontend-ui", "--role", - "coding_agent", + "docs_agent", "--scopes", "tasks:read,tasks:write", "--repo-allowlist", @@ -485,7 +486,7 @@ test("agent create rejects conflicting role template and role flags", async (t) }); assert.equal(exitCode, 1); - assert.match(stderr.toString(), /--role-template frontend-ui sets role to frontend-ui/); + assert.match(stderr.toString(), /--role-template frontend-ui sets role to coding_agent/); assert.equal(stdout.toString(), ""); }); @@ -657,7 +658,7 @@ test("agent create interactive permission preset grants expected scopes", async assert.match(prompt.notes.map((note) => note.body).join("\n"), /Runner model: gpt-5\.4/); assert.doesNotMatch(prompt.notes.map((note) => note.body).join("\n"), /model\/profile/); assert.match(prompt.notes.map((note) => note.body).join("\n"), /Role template: Backend\/API \(backend-api\)/); - assert.match(prompt.notes.map((note) => note.body).join("\n"), /Role: backend-api/); + assert.match(prompt.notes.map((note) => note.body).join("\n"), /Role: General coding \(coding_agent\)/); assert.match(prompt.notes.map((note) => note.body).join("\n"), /Capabilities: backend, api, server/); assert.match(prompt.notes.map((note) => note.body).join("\n"), /Task capacity: 1/); await assert.rejects(readFile(path.join(homePath, "agent.env"), "utf8")); @@ -937,7 +938,7 @@ test("agent update clears stale role template metadata when role is manually cha "--env-file", path.relative(repoRoot, envPath), "--role", - "coding_agent", + "docs_agent", ], { cwd: repoRoot, stdinIsTTY: false, @@ -949,7 +950,7 @@ test("agent update clears stale role template metadata when role is manually cha const profile = await getJson(harness.baseUrl, "/operator/routing/agent-profiles/agt_template_update", harness.operatorApiKey); assert.equal(profile.status, 200); - assert.equal(profile.json.data.role, "coding_agent"); + assert.equal(profile.json.data.role, "docs_agent"); assert.equal(profile.json.data.roleTemplateId, null); const updatedEnv = parseEnv(await readFile(envPath, "utf8")); @@ -1101,7 +1102,7 @@ test("agent update rejects conflicting role template and role flags", async (t) "--role-template", "frontend-ui", "--role", - "coding_agent", + "docs_agent", ], { cwd: repoRoot, stdinIsTTY: false, @@ -1111,7 +1112,7 @@ test("agent update rejects conflicting role template and role flags", async (t) }); assert.equal(exitCode, 1); - assert.match(stderr.toString(), /--role-template frontend-ui sets role to frontend-ui/); + assert.match(stderr.toString(), /--role-template frontend-ui sets role to coding_agent/); }); test("agent update installs and applies a custom role template file", async (t) => { @@ -1190,7 +1191,7 @@ test("agent update installs and applies a custom role template file", async (t) const profile = await getJson(harness.baseUrl, "/operator/routing/agent-profiles/agt_update_custom_template", harness.operatorApiKey); assert.equal(profile.status, 200); - assert.equal(profile.json.data.role, "payments-backend"); + assert.equal(profile.json.data.role, "coding_agent"); assert.equal(profile.json.data.roleTemplateId, "payments-backend"); assert.deepEqual(profile.json.data.capabilityTags, ["payments", "backend", "api"]); assert.deepEqual(profile.json.data.ownershipTags, ["payments", "backend_source"]); diff --git a/test/agent-role-template.test.ts b/test/agent-role-template.test.ts index af15fa7..f620764 100644 --- a/test/agent-role-template.test.ts +++ b/test/agent-role-template.test.ts @@ -30,6 +30,7 @@ function templateYaml(id = "sample-role"): string { return `schemaVersion: ${AGENT_ROLE_TEMPLATE_SCHEMA_VERSION} id: ${id} name: Sample Role +role: coding_agent description: Handles sample tasks. routing: @@ -128,6 +129,7 @@ test("built-in workspace areas are repo-agnostic", async () => { test("parses a valid inline template", () => { const template = parseAgentRoleTemplateYaml(templateYaml("valid-sample")); assert.equal(template.id, "valid-sample"); + assert.equal(template.role, "coding_agent"); assert.deepEqual(template.workspace.preferredAreas, ["backend_source"]); assert.deepEqual(template.runtime.tools.builtin.allow, ["read"]); }); diff --git a/test/managed-run-role-brief.test.ts b/test/managed-run-role-brief.test.ts index d16d3eb..440051f 100644 --- a/test/managed-run-role-brief.test.ts +++ b/test/managed-run-role-brief.test.ts @@ -15,6 +15,7 @@ function template(overrides: Partial = {}): AgentRoleTemplate schemaVersion: AGENT_ROLE_TEMPLATE_SCHEMA_VERSION, id: "frontend-ui", name: "Frontend/UI", + role: "coding_agent", description: "Implements product UI changes.", routing: { capabilityTags: ["frontend", "ui"], From e6bdbc87bf8dcfc1afd97ce93e081c6ab76996cb Mon Sep 17 00:00:00 2001 From: Onyeka Nwamba Date: Tue, 26 May 2026 00:33:35 +0100 Subject: [PATCH 4/7] feat: polish template-aware routing retries --- docs/api/intake-routing-admin.openapi.yaml | 45 ++- docs/integration-guide.md | 4 + docs/quick-start.md | 4 + sdk/python/src/agentrail/models.py | 2 + sdk/typescript/src/types.ts | 2 + src/app.ts | 12 +- src/cli/agent-management.ts | 70 +++- src/cli/local-bootstrap.ts | 1 + src/intake-routing-control-plane.ts | 386 ++++++++++++++++++--- src/server-runtime.ts | 14 +- src/server.ts | 1 + test/agent-management.test.ts | 133 +++++++ test/intake-routing-control-plane.test.ts | 147 +++++++- test/intake-routing-endpoints.test.ts | 166 +++++++++ 14 files changed, 922 insertions(+), 65 deletions(-) diff --git a/docs/api/intake-routing-admin.openapi.yaml b/docs/api/intake-routing-admin.openapi.yaml index 05b598e..dcb06b9 100644 --- a/docs/api/intake-routing-admin.openapi.yaml +++ b/docs/api/intake-routing-admin.openapi.yaml @@ -96,6 +96,7 @@ paths: assignmentSource: deterministic_rule routingDecisionId: rdec_01JZROUTE0000000000000001 assignedAt: '2026-05-05T12:01:03Z' + roleTemplate: null confidence: 0.99 routingReason: summary: Repo agentrail plus labels architecture/api matched CTO ownership rule. @@ -512,6 +513,7 @@ paths: assignmentSource: manual_triage routingDecisionId: rdec_01JZROUTE0000000000000200 assignedAt: null + roleTemplate: null confidence: 0 routingReason: summary: No deterministic route matched; AI routing could not find a suitable agent, so the task is waiting for a suitable agent. @@ -563,6 +565,7 @@ paths: assignmentSource: deterministic_rule routingDecisionId: rdec_01JZROUTE0000000000000001 assignedAt: '2026-05-05T12:01:03Z' + roleTemplate: null confidence: 0.99 routingReason: summary: Repo agentrail plus labels architecture/api matched CTO ownership rule. @@ -704,6 +707,8 @@ components: properties: requestId: type: string + routingRetry: + $ref: '#/components/schemas/RoutingRetryResult' ErrorResponse: type: object additionalProperties: false @@ -1202,7 +1207,7 @@ components: TaskAssignment: type: object additionalProperties: false - required: [assigneeAgentId, triageQueueId, assignmentSource, routingDecisionId, assignedAt] + required: [assigneeAgentId, triageQueueId, assignmentSource, routingDecisionId, assignedAt, roleTemplate] properties: assigneeAgentId: type: [string, 'null'] @@ -1210,12 +1215,48 @@ components: type: [string, 'null'] assignmentSource: type: string - enum: [deterministic_rule, classifier, classifier_best_effort, manual_triage] + enum: [deterministic_rule, classifier, classifier_best_effort, single_agent_fallback, manual_triage] routingDecisionId: type: string assignedAt: type: [string, 'null'] format: date-time + roleTemplate: + anyOf: + - type: 'null' + - $ref: '#/components/schemas/RoutingAssignmentRoleTemplate' + RoutingAssignmentRoleTemplate: + type: object + additionalProperties: false + required: [id, name, sourceKind] + properties: + id: + type: string + pattern: '^[a-z][a-z0-9-]*$' + name: + type: string + sourceKind: + type: string + enum: [built_in, custom, unknown] + RoutingRetryResult: + type: object + additionalProperties: false + required: [scanned, assigned, unchanged, skipped] + properties: + scanned: + type: integer + minimum: 0 + assigned: + type: integer + minimum: 0 + unchanged: + type: integer + minimum: 0 + skipped: + type: integer + minimum: 0 + error: + type: string RoutingReason: type: object additionalProperties: false diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 01ef13a..e1d5f11 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -84,6 +84,10 @@ Current CLI-assisted model: - In AI routing mode, choose whether AgentRail must require a suitable agent and retry waiting tasks after agents change, or assign the closest available match as a recorded best-effort decision. +- Routing audit entries include role-template metadata for template-backed + agents. When no suitable agent exists, AgentRail suggests updating an + existing agent or creating a new one from a likely built-in template, then + retries waiting tasks after agent profile or routing rule changes. - AI routing uses a local runner timeout of 180 seconds by default. If Codex, Claude Code, or Cursor is slow on a machine, raise `routing.classifier.timeoutMs` up to 600 seconds in `config.json`; timeout diff --git a/docs/quick-start.md b/docs/quick-start.md index 9b3607e..182f5d3 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -203,6 +203,10 @@ agentrail linear import ENG-123 Imported Linear and GitHub provider tasks go through the routing engine. Missing routing configuration fails closed instead of creating hidden unassigned work. +When AgentRail cannot find a suitable agent, the task stays waiting and the +routing explanation suggests updating an existing agent or creating a new one +from a likely role template. After `agentrail agent create` or +`agentrail agent update`, AgentRail retries those waiting tasks automatically. ## 7. Start A Coding Agent diff --git a/sdk/python/src/agentrail/models.py b/sdk/python/src/agentrail/models.py index 7d50649..06d0dcb 100644 --- a/sdk/python/src/agentrail/models.py +++ b/sdk/python/src/agentrail/models.py @@ -117,6 +117,8 @@ class CodeReviewPolicy(str, Enum): class TaskAssignmentSource(str, Enum): DETERMINISTIC_RULE = "deterministic_rule" CLASSIFIER = "classifier" + CLASSIFIER_BEST_EFFORT = "classifier_best_effort" + SINGLE_AGENT_FALLBACK = "single_agent_fallback" MANUAL_TRIAGE = "manual_triage" PROVIDER_ASSIGNEE_MAPPING = "provider_assignee_mapping" diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts index 026b82f..5cd4091 100644 --- a/sdk/typescript/src/types.ts +++ b/sdk/typescript/src/types.ts @@ -231,6 +231,8 @@ export interface TaskBlocker { export type TaskAssignmentSource = | "deterministic_rule" | "classifier" + | "classifier_best_effort" + | "single_agent_fallback" | "manual_triage" | "provider_assignee_mapping"; diff --git a/src/app.ts b/src/app.ts index 8f7f96f..12e276f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1877,10 +1877,14 @@ async function handleReplaceCurrentRoutingRuleSet({ actorId, idempotencyKey ); + const routingRetry = await retryWaitingRoutingTasksAfterRoutingChange(routingControlPlane, actorId); writeJson(response, 201, { data, availableActions: ["update", "evaluate"], - meta: responseMeta() + meta: { + ...responseMeta(), + routingRetry + } }); } catch (error) { if (error instanceof TaskLifecycleError) { @@ -1954,7 +1958,7 @@ async function handleReplaceRoutingAgentProfile({ actorId, idempotencyKey ); - const routingRetry = await retryWaitingAiRoutedTasksAfterProfileUpdate(routingControlPlane, actorId); + const routingRetry = await retryWaitingRoutingTasksAfterRoutingChange(routingControlPlane, actorId); writeJson(response, 200, { data, availableActions: ["update"], @@ -1972,12 +1976,12 @@ async function handleReplaceRoutingAgentProfile({ } } -async function retryWaitingAiRoutedTasksAfterProfileUpdate( +async function retryWaitingRoutingTasksAfterRoutingChange( routingControlPlane: RoutingControlPlane, actorId: string ) { try { - return await routingControlPlane.retryWaitingAiRoutedTasks(actorId); + return await routingControlPlane.retryWaitingRoutingTasks(actorId); } catch (error) { return { scanned: 0, diff --git a/src/cli/agent-management.ts b/src/cli/agent-management.ts index 0bcef97..133ab68 100644 --- a/src/cli/agent-management.ts +++ b/src/cli/agent-management.ts @@ -177,6 +177,24 @@ interface RuleSetBody { timeoutMs?: number; }; }; + meta?: { + routingRetry?: RoutingRetryMeta; + }; +} + +interface RoutingRetryMeta { + scanned?: number; + assigned?: number; + unchanged?: number; + skipped?: number; + error?: string; +} + +interface AgentProfileMutationResponse { + data?: ProfileBody; + meta?: { + routingRetry?: RoutingRetryMeta; + }; } interface RoutingRule { @@ -487,7 +505,7 @@ export async function runAgentCreate(argv: string[], options: RunAgentCommandOpt warnPrivilegedScopes(inputs.scopes, stdout); - await requestJson({ + const profileMutation = await requestJson({ baseUrl: inputs.baseUrl, route: `/operator/routing/agent-profiles/${inputs.agentId}`, bearerToken: inputs.setupApiKey, @@ -531,6 +549,7 @@ export async function runAgentCreate(argv: string[], options: RunAgentCommandOpt if (routingResult.mutated) { completedSteps.push("routing"); } + writeRoutingRetrySummary(stdout, mergeRoutingRetry(profileMutation.json?.meta?.routingRetry, routingResult.routingRetry)); await requestJson({ baseUrl: inputs.baseUrl, @@ -762,7 +781,7 @@ export async function runAgentUpdate(argv: string[], options: RunAgentCommandOpt body: rotateBody, }); - await requestJson({ + const profileMutation = await requestJson({ baseUrl, route: `/operator/routing/agent-profiles/${agentId}`, bearerToken: setupApiKey, @@ -803,6 +822,7 @@ export async function runAgentUpdate(argv: string[], options: RunAgentCommandOpt interactive: Boolean(prompt), updateExistingManagedRule: true, }); + writeRoutingRetrySummary(stdout, mergeRoutingRetry(profileMutation.json?.meta?.routingRetry, routingResult.routingRetry)); const envValues = buildAgentEnvValues({ baseUrl, @@ -1142,7 +1162,7 @@ async function ensureManagedRouting({ rules: classifier.enabled ? [] : [managedRule], classifier, }; - await requestJson({ + const response = await requestJson({ baseUrl, route: "/operator/routing/rule-sets/current", bearerToken: setupApiKey, @@ -1150,7 +1170,7 @@ async function ensureManagedRouting({ idempotencyKey: routingRuleSetIdempotencyKey(profile.agentId, body), body, }); - return { mutated: true }; + return { mutated: true, routingRetry: response.json?.meta?.routingRetry }; } const managedRuleId = managedRuleIdFor(profile.agentId); @@ -1168,7 +1188,7 @@ async function ensureManagedRouting({ if (existingManagedRuleIndex !== -1 && updateExistingManagedRule && allowMutationWhenExisting) { const nextRules = [...existingRules]; nextRules[existingManagedRuleIndex] = managedRule; - await replaceRuleSet({ + const response = await replaceRuleSet({ baseUrl, setupApiKey, rules: nextRules, @@ -1176,11 +1196,11 @@ async function ensureManagedRouting({ agentId: profile.agentId, changeReason: "Update managed routing rule for agent.", }); - return { mutated: true }; + return { mutated: true, routingRetry: response.json?.meta?.routingRetry }; } if (allowMutationWhenExisting && hasNarrowingConditions && existingManagedRuleIndex === -1) { - await replaceRuleSet({ + const response = await replaceRuleSet({ baseUrl, setupApiKey, rules: [...existingRules, managedRule], @@ -1188,14 +1208,14 @@ async function ensureManagedRouting({ agentId: profile.agentId, changeReason: "Add managed routing rule for agent.", }); - return { mutated: true }; + return { mutated: true, routingRetry: response.json?.meta?.routingRetry }; } const message = interactive ? `Skipped routing mutation for ${profile.agentId}. Run \`agentrail agent update --agent-id ${profile.agentId} --configure-routing\` with narrowing conditions to add managed routing later.\n` : `Skipped routing mutation for ${profile.agentId} because no safe managed rule change was requested.\n`; stdout.write(message); - return { mutated: false }; + return { mutated: false, routingRetry: undefined }; } async function replaceRuleSet({ @@ -1212,8 +1232,8 @@ async function replaceRuleSet({ classifier: Record; agentId: string; changeReason: string; -}) { - await requestJson({ +}): Promise<{ status: number; json: RuleSetBody | null }> { + return await requestJson({ baseUrl, route: "/operator/routing/rule-sets/current", bearerToken: setupApiKey, @@ -1315,6 +1335,34 @@ function mutationIdempotencyKey(operation: string, entityId: string, payload: un return `${operation}:${entityId}:${digest}`; } +function mergeRoutingRetry(...entries: Array): RoutingRetryMeta | undefined { + const present = entries.filter((entry): entry is RoutingRetryMeta => Boolean(entry)); + if (present.length === 0) { + return undefined; + } + const latest = present[present.length - 1]; + return present.reduce((acc, entry) => ({ + scanned: (acc.scanned ?? 0) + (entry.scanned ?? 0), + assigned: (acc.assigned ?? 0) + (entry.assigned ?? 0), + unchanged: latest.unchanged ?? 0, + skipped: latest.skipped ?? 0, + error: acc.error ?? entry.error, + }), {}); +} + +function writeRoutingRetrySummary(stdout: Writer, retry: RoutingRetryMeta | undefined): void { + if (!retry) { + return; + } + if ((retry.scanned ?? 0) === 0 && (retry.assigned ?? 0) === 0 && !retry.error) { + return; + } + const base = `Routing retry: assigned ${retry.assigned ?? 0} waiting task${(retry.assigned ?? 0) === 1 ? "" : "s"}`; + const waiting = retry.unchanged ? `; ${retry.unchanged} still waiting` : ""; + const error = retry.error ? `; error: ${retry.error}` : ""; + stdout.write(`${base}${waiting}${error}.\n`); +} + function stableStringify(value: unknown): string { if (Array.isArray(value)) { return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; diff --git a/src/cli/local-bootstrap.ts b/src/cli/local-bootstrap.ts index 4c2c02d..c676df3 100644 --- a/src/cli/local-bootstrap.ts +++ b/src/cli/local-bootstrap.ts @@ -194,6 +194,7 @@ export async function withTemporaryLocalServer({ now, eventStore, publicBaseUrl, + homePath: resolvedHomePath, }); const authStore = new AgentAuthStore({ now, storagePath: paths.authStorePath }); const agentRunStore = new AgentRunStore({ now, storagePath: paths.agentRunStorePath }); diff --git a/src/intake-routing-control-plane.ts b/src/intake-routing-control-plane.ts index 0111983..59322f7 100644 --- a/src/intake-routing-control-plane.ts +++ b/src/intake-routing-control-plane.ts @@ -1,7 +1,13 @@ import crypto from "node:crypto"; import type { AgentTaskQueue } from "./agent-task-queue.ts"; -import { ROLE_TEMPLATE_ID_PATTERN } from "./agent-role-template.ts"; +import { + loadBuiltInAgentRoleTemplates, + ROLE_TEMPLATE_ID_PATTERN, + type AgentRoleTemplate, + type AgentRoleTemplateSourceKind, + type LoadedAgentRoleTemplate, +} from "./agent-role-template.ts"; import type { RoutingClassifier, RoutingClassifierCandidate, RoutingClassifierOutput } from "./routing-classifier.ts"; import { TaskLifecycleError } from "./task-lifecycle-errors.ts"; import type { TaskAssignmentSource, TaskProviderIssueSnapshot, TaskRecord } from "./task-store.ts"; @@ -123,6 +129,23 @@ export interface RoutingRetryResult { skipped: number; } +export interface RoutingAssignmentRoleTemplate { + id: string; + name: string; + sourceKind: AgentRoleTemplateSourceKind | "unknown"; +} + +export interface RoutingRoleTemplateDetails extends RoutingAssignmentRoleTemplate { + role: string; + description: string; + routing: AgentRoleTemplate["routing"]; + workspace: AgentRoleTemplate["workspace"]; +} + +export type RoutingRoleTemplateResolver = ( + roleTemplateId: string +) => RoutingRoleTemplateDetails | null | Promise; + export interface RoutingRuleSetReplaceRequest { sourceRef: string; changeReason: string; @@ -172,6 +195,7 @@ export interface TaskAssignment { assignmentSource: TaskAssignmentSource; routingDecisionId: string; assignedAt: string | null; + roleTemplate: RoutingAssignmentRoleTemplate | null; } export interface RoutingDecision { @@ -236,6 +260,32 @@ export interface RoutingControlPlaneOptions { }; classifier?: RoutingClassifier | null; telemetry?: TelemetryClient | null; + roleTemplateResolver?: RoutingRoleTemplateResolver | null; +} + +let builtInTemplateCatalogPromise: Promise | null = null; + +export function routingRoleTemplateDetailsFromLoaded(loaded: LoadedAgentRoleTemplate): RoutingRoleTemplateDetails { + return { + id: loaded.template.id, + name: loaded.template.name, + role: loaded.template.role, + sourceKind: loaded.sourceKind, + description: loaded.template.description, + routing: clone(loaded.template.routing), + workspace: clone(loaded.template.workspace), + }; +} + +async function loadBuiltInRoutingRoleTemplateDetails(): Promise { + builtInTemplateCatalogPromise ??= loadBuiltInAgentRoleTemplates() + .then((templates) => templates.map(routingRoleTemplateDetailsFromLoaded)); + return builtInTemplateCatalogPromise; +} + +async function defaultRoutingRoleTemplateResolver(roleTemplateId: string): Promise { + const builtInTemplates = await loadBuiltInRoutingRoleTemplateDetails(); + return builtInTemplates.find((template) => template.id === roleTemplateId) ?? null; } function createId(prefix: string): string { @@ -349,7 +399,8 @@ const SNAPSHOT_FIELDS = new Set([ const REPOSITORY_FIELDS = new Set(["provider", "owner", "name", "defaultBranch", "codeReviewPolicy"]); const LINKS_FIELDS = new Set(["providerIssue"]); const ACTIVE_TASK_STATUSES = new Set(["todo", "in_progress", "in_review", "blocked"]); -const RETRYABLE_AI_ROUTING_CONFLICTS = new Set([ +const RETRYABLE_ROUTING_CONFLICTS = new Set([ + "no_matching_rule", "classifier_no_candidate_agents", "classifier_no_capable_agent", "classifier_unmatched_capabilities", @@ -419,12 +470,22 @@ export class RoutingControlPlane { private readonly routingRuleStore: RoutingControlPlaneOptions["routingRuleStore"]; private readonly classifier: RoutingClassifier | null; private readonly telemetry: TelemetryClient | null; + private readonly roleTemplateResolver: RoutingRoleTemplateResolver; private readonly profiles: Map; private readonly audits: Map; private readonly idempotency: Map>; private ruleSets: RoutingRuleSet[]; - constructor({ now = () => new Date(), taskQueue, routingAuditStore, agentProfileStore, routingRuleStore, classifier = null, telemetry = null }: RoutingControlPlaneOptions) { + constructor({ + now = () => new Date(), + taskQueue, + routingAuditStore, + agentProfileStore, + routingRuleStore, + classifier = null, + telemetry = null, + roleTemplateResolver = defaultRoutingRoleTemplateResolver, + }: RoutingControlPlaneOptions) { this.now = now; this.taskQueue = taskQueue; this.routingAuditStore = routingAuditStore; @@ -432,6 +493,7 @@ export class RoutingControlPlane { this.routingRuleStore = routingRuleStore; this.classifier = classifier; this.telemetry = telemetry; + this.roleTemplateResolver = roleTemplateResolver ?? defaultRoutingRoleTemplateResolver; this.profiles = new Map(); this.audits = new Map(); this.idempotency = new Map(); @@ -640,7 +702,7 @@ export class RoutingControlPlane { return clone(decision); } - async retryWaitingAiRoutedTasks(_actorId = "routing"): Promise { + async retryWaitingRoutingTasks(_actorId = "routing"): Promise { const result: RoutingRetryResult = { scanned: 0, assigned: 0, @@ -656,12 +718,9 @@ export class RoutingControlPlane { } throw error; } - if (!ruleSet.classifier.enabled) { - return result; - } for (const task of this.taskQueue.listRawTasks()) { - if (!this.isRetryableWaitingAiTask(task)) { + if (!this.isRetryableWaitingRoutingTask(task)) { result.skipped += 1; continue; } @@ -689,6 +748,10 @@ export class RoutingControlPlane { return result; } + async retryWaitingAiRoutedTasks(actorId = "routing"): Promise { + return await this.retryWaitingRoutingTasks(actorId); + } + getRoutingAudit(decisionId: string): RoutingAuditRecord | null { if (this.routingAuditStore) { const audit = this.routingAuditStore.getRoutingAudit(decisionId); @@ -794,7 +857,8 @@ export class RoutingControlPlane { if (topMatches.length > 0 && topTargets.length === 1) { const winner = topMatches[0]!; return { - decision: this.buildDecision({ + decision: await this.buildDecision({ + snapshot, outcome: winner.target.type === "triage_queue" ? "triage" : "assigned", target: winner.target, assignmentSource: "deterministic_rule", @@ -810,7 +874,8 @@ export class RoutingControlPlane { if (topMatches.length > 0 && topTargets.length > 1) { return { - decision: this.buildDecision({ + decision: await this.buildDecision({ + snapshot, outcome: "conflict", target: { type: "triage_queue", id: ruleSet.classifier.fallbackTriageQueueId }, assignmentSource: "manual_triage", @@ -835,7 +900,8 @@ export class RoutingControlPlane { if (soleAgentFallback) { const target = { type: "agent" as const, id: soleAgentFallback.agentId }; return { - decision: this.buildDecision({ + decision: await this.buildDecision({ + snapshot, outcome: "assigned", target, assignmentSource: "single_agent_fallback", @@ -850,10 +916,11 @@ export class RoutingControlPlane { } const assignmentSource: TaskAssignmentSource = "manual_triage"; - const summary = `No deterministic route matched; the task was sent to triage ${ruleSet.classifier.fallbackTriageQueueId}.`; + const summary = await this.buildNoSuitableAgentSummary(snapshot); return { - decision: this.buildDecision({ + decision: await this.buildDecision({ + snapshot, outcome: "no_route", target: { type: "triage_queue", id: ruleSet.classifier.fallbackTriageQueueId }, assignmentSource, @@ -871,7 +938,8 @@ export class RoutingControlPlane { const fallbackTarget = { type: "triage_queue" as const, id: config.fallbackTriageQueueId }; const candidates = this.classifierCandidates(snapshot); if (!this.classifier) { - return this.buildDecision({ + return await this.buildDecision({ + snapshot, outcome: "triage", target: fallbackTarget, assignmentSource: "manual_triage", @@ -888,13 +956,14 @@ export class RoutingControlPlane { }); } if (candidates.length === 0) { - return this.buildDecision({ - outcome: "triage", + return await this.buildDecision({ + snapshot, + outcome: "no_route", target: fallbackTarget, assignmentSource: "manual_triage", confidence: 0, matchedRules: [], - summary: "No deterministic route matched; no active agent is eligible for this repository.", + summary: await this.buildNoSuitableAgentSummary(snapshot), conflictReasons: ["classifier_no_candidate_agents"], classifier: { provider: config.provider, @@ -913,7 +982,8 @@ export class RoutingControlPlane { }, config); } catch (error) { const reason = error instanceof Error ? error.message : String(error); - return this.buildDecision({ + return await this.buildDecision({ + snapshot, outcome: "triage", target: fallbackTarget, assignmentSource: "manual_triage", @@ -944,7 +1014,8 @@ export class RoutingControlPlane { ...(lowConfidence ? ["classifier_low_confidence"] : []), ...(hasMissingInfo ? ["classifier_missing_info"] : []), ]; - return this.buildDecision({ + return await this.buildDecision({ + snapshot, outcome: "triage", target: fallbackTarget, assignmentSource: "manual_triage", @@ -962,7 +1033,8 @@ export class RoutingControlPlane { ? this.selectClosestClassifierAgent(candidates, output) : null; if (closest) { - return this.buildBestEffortDecision({ + return await this.buildBestEffortDecision({ + snapshot, config, output, selected: closest, @@ -970,15 +1042,14 @@ export class RoutingControlPlane { summary: "AI-assisted routing assigned the closest available agent because no suitable agent matched all required work.", }); } - return this.buildDecision({ - outcome: "triage", + return await this.buildDecision({ + snapshot, + outcome: "no_route", target: fallbackTarget, assignmentSource: "manual_triage", confidence: output.confidence, matchedRules: [], - summary: hasUnmatchedCapabilities - ? "No deterministic route matched; classifier found required capabilities that no active agent advertises." - : "No deterministic route matched; classifier did not identify any required capability before assignment.", + summary: await this.buildNoSuitableAgentSummary(snapshot, output), conflictReasons: suitabilityConflictReasons, classifier: this.classifierResult(config, output, fallbackTarget), }); @@ -990,7 +1061,8 @@ export class RoutingControlPlane { ? this.selectClosestClassifierAgent(candidates, output) : null; if (closest) { - return this.buildBestEffortDecision({ + return await this.buildBestEffortDecision({ + snapshot, config, output, selected: closest, @@ -998,20 +1070,22 @@ export class RoutingControlPlane { summary: "AI-assisted routing assigned the closest available agent because no agent matched every required capability.", }); } - return this.buildDecision({ - outcome: "triage", + return await this.buildDecision({ + snapshot, + outcome: "no_route", target: fallbackTarget, assignmentSource: "manual_triage", confidence: output.confidence, matchedRules: [], - summary: "No deterministic route matched; classifier output did not match any capable available agent.", + summary: await this.buildNoSuitableAgentSummary(snapshot, output), conflictReasons: ["classifier_no_capable_agent"], classifier: this.classifierResult(config, output, fallbackTarget), }); } const target = { type: "agent" as const, id: selected.agentId }; - return this.buildDecision({ + return await this.buildDecision({ + snapshot, outcome: "assigned", target, assignmentSource: "classifier", @@ -1023,21 +1097,24 @@ export class RoutingControlPlane { }); } - private buildBestEffortDecision({ + private async buildBestEffortDecision({ + snapshot, config, output, selected, conflictReasons, summary, }: { + snapshot: ProviderIssueSnapshot; config: ClassifierConfig; output: RoutingClassifierOutput; selected: RoutingClassifierCandidate; conflictReasons: string[]; summary: string; - }): RoutingDecision { + }): Promise { const target = { type: "agent" as const, id: selected.agentId }; - return this.buildDecision({ + return await this.buildDecision({ + snapshot, outcome: "assigned", target, assignmentSource: "classifier_best_effort", @@ -1170,10 +1247,45 @@ export class RoutingControlPlane { right.score - left.score || left.candidate.activeTaskCount - right.candidate.activeTaskCount || left.candidate.agentId.localeCompare(right.candidate.agentId) - ); + ); return scored[0]?.candidate ?? null; } + private async buildNoSuitableAgentSummary( + snapshot: ProviderIssueSnapshot, + classifierOutput: RoutingClassifierOutput | null = null, + ): Promise { + const requiredSignals = noSuitableAgentSignals(snapshot, classifierOutput); + const coverageText = requiredSignals.length > 0 + ? ` whose role covers ${requiredSignals.join(", ")}` + : " for this task"; + const suggestions = await this.suggestBuiltInRoleTemplates(snapshot, classifierOutput); + const suggestionText = suggestions.length > 0 + ? ` Suggested templates: ${suggestions.map(template => `${template.name} (${template.id})`).join(", ")}.` + : ""; + return `No suitable agent found. Create a new agent or update an existing agent${coverageText}.${suggestionText}`; + } + + private async suggestBuiltInRoleTemplates( + snapshot: ProviderIssueSnapshot, + classifierOutput: RoutingClassifierOutput | null, + ): Promise { + const templates = await loadBuiltInRoutingRoleTemplateDetails(); + return templates + .map((template) => ({ + template, + score: scoreTemplateForSnapshot(template, snapshot, classifierOutput), + })) + .filter(({ score }) => score >= 2) + .sort((left, right) => right.score - left.score || left.template.id.localeCompare(right.template.id)) + .slice(0, 3) + .map(({ template }) => ({ + id: template.id, + name: template.name, + sourceKind: template.sourceKind, + })); + } + private ruleMatchesSnapshot(rule: RoutingRule, snapshot: ProviderIssueSnapshot): boolean { const conditions = rule.conditions ?? {}; const repo = repoKey(snapshot); @@ -1205,7 +1317,8 @@ export class RoutingControlPlane { return true; } - private buildDecision({ + private async buildDecision({ + snapshot, outcome, target, assignmentSource, @@ -1215,6 +1328,7 @@ export class RoutingControlPlane { conflictReasons, classifier, }: { + snapshot?: ProviderIssueSnapshot; outcome: RoutingDecision["outcome"]; target: RoutingTarget; assignmentSource: TaskAssignmentSource; @@ -1223,8 +1337,19 @@ export class RoutingControlPlane { summary: string; conflictReasons: string[]; classifier: ClassifierResult | null; - }): RoutingDecision { + }): Promise { const id = createId("rdec"); + const assignmentContext = await this.resolveAssignmentContext(target); + const roleTemplate = assignmentContext.roleTemplate; + const routingSummary = roleTemplate && assignmentContext.profile && snapshot + ? buildTemplateAssignmentSummary({ + snapshot, + profile: assignmentContext.profile, + roleTemplate, + classifier, + fallbackSummary: summary, + }) + : summary; return { id, taskId: null, @@ -1237,10 +1362,17 @@ export class RoutingControlPlane { assignmentSource, routingDecisionId: id, assignedAt: this.now().toISOString(), + roleTemplate: roleTemplate + ? { + id: roleTemplate.id, + name: roleTemplate.name, + sourceKind: roleTemplate.sourceKind, + } + : null, }, confidence, routingReason: { - summary, + summary: routingSummary, matchedRules: matchedRules.map(rule => ({ id: rule.id, name: rule.name, @@ -1253,6 +1385,31 @@ export class RoutingControlPlane { }; } + private async resolveAssignmentContext(target: RoutingTarget): Promise<{ + profile: AgentProfile | null; + roleTemplate: RoutingRoleTemplateDetails | null; + }> { + if (target.type !== "agent") { + return { profile: null, roleTemplate: null }; + } + const profile = this.getAgentProfile(target.id); + if (!profile?.roleTemplateId) { + return { profile, roleTemplate: null }; + } + try { + const resolved = await this.roleTemplateResolver(profile.roleTemplateId); + return { + profile, + roleTemplate: resolved ?? unknownRoleTemplateDetails(profile.roleTemplateId), + }; + } catch { + return { + profile, + roleTemplate: unknownRoleTemplateDetails(profile.roleTemplateId), + }; + } + } + private async applyDecisionToTask(snapshot: ProviderIssueSnapshot, decision: RoutingDecision): Promise { const existing = this.taskQueue.findTaskByIdentifier(snapshot.providerIssueId); const assigneeAgentId = decision.assignment.assigneeAgentId; @@ -1359,7 +1516,7 @@ export class RoutingControlPlane { return { task: created, assignmentChanged }; } - private isRetryableWaitingAiTask(task: TaskRecord): boolean { + private isRetryableWaitingRoutingTask(task: TaskRecord): boolean { if (task.status !== "todo") { return false; } @@ -1369,14 +1526,14 @@ export class RoutingControlPlane { if (task.assignmentSource !== "manual_triage") { return false; } - if (!task.providerIssueSnapshot || !task.routingReason?.classifier) { + if (!task.providerIssueSnapshot || !task.routingReason) { return false; } const conflicts = task.routingReason.conflictReasons ?? []; if (conflicts.includes("classifier_low_confidence") || conflicts.includes("classifier_missing_info")) { return false; } - return conflicts.some(conflict => RETRYABLE_AI_ROUTING_CONFLICTS.has(conflict)); + return conflicts.some(conflict => RETRYABLE_ROUTING_CONFLICTS.has(conflict)); } private isAgentEligibleForSnapshot(profile: AgentProfile, repo: string, providerIssueId: string): boolean { @@ -1694,6 +1851,155 @@ export class RoutingControlPlane { } } +function unknownRoleTemplateDetails(roleTemplateId: string): RoutingRoleTemplateDetails { + return { + id: roleTemplateId, + name: roleTemplateId, + role: "unknown", + sourceKind: "unknown", + description: "", + routing: { + capabilityTags: [], + preferredLabels: [], + taskTypes: [], + avoidLabels: [], + }, + workspace: { + preferredAreas: [], + cautionAreas: [], + forbiddenAreas: [], + }, + }; +} + +function buildTemplateAssignmentSummary({ + snapshot, + profile, + roleTemplate, + classifier, + fallbackSummary, +}: { + snapshot: ProviderIssueSnapshot; + profile: AgentProfile; + roleTemplate: RoutingRoleTemplateDetails; + classifier: ClassifierResult | null; + fallbackSummary: string; +}): string { + const matched = matchedTemplateSignals(snapshot, roleTemplate, classifier).slice(0, 4); + if (matched.length === 0) { + return fallbackSummary; + } + return `Assigned to ${profile.displayName} because the task matched the ${roleTemplate.name} (${roleTemplate.id}) template responsibilities: ${matched.join(", ")}.`; +} + +function noSuitableAgentSignals( + snapshot: ProviderIssueSnapshot, + classifierOutput: RoutingClassifierOutput | null, +): string[] { + const explicit = uniqueStrings([ + ...(classifierOutput?.unmatchedCapabilities ?? []), + ...(classifierOutput?.requiredCapabilities ?? []), + ...snapshot.capabilityTags, + ...snapshot.ownershipTags, + ...snapshot.labels, + snapshot.issueType, + ]); + return explicit + .filter(value => !["unknown", "feature", "bug", "maintenance"].includes(lower(value))) + .slice(0, 3); +} + +function matchedTemplateSignals( + snapshot: ProviderIssueSnapshot, + roleTemplate: RoutingRoleTemplateDetails, + classifier: ClassifierResult | null, +): string[] { + const snapshotSignals = signalSet([ + ...snapshot.labels, + ...snapshot.capabilityTags, + ...snapshot.ownershipTags, + snapshot.issueType, + snapshot.project ?? "", + ...titleTokens(snapshot.title), + ...(classifier?.requiredCapabilities ?? []), + ...(classifier?.optionalCapabilities ?? []), + ...(classifier?.ownershipHints ?? []), + ]); + return uniqueStrings([ + ...roleTemplate.routing.capabilityTags, + ...roleTemplate.routing.preferredLabels, + ...roleTemplate.routing.taskTypes, + ...roleTemplate.workspace.preferredAreas, + ]).filter(value => signalVariants(value).some(variant => snapshotSignals.has(variant))); +} + +function scoreTemplateForSnapshot( + roleTemplate: RoutingRoleTemplateDetails, + snapshot: ProviderIssueSnapshot, + classifierOutput: RoutingClassifierOutput | null, +): number { + const labels = signalSet(snapshot.labels); + const capabilities = signalSet([ + ...snapshot.capabilityTags, + ...(classifierOutput?.requiredCapabilities ?? []), + ...(classifierOutput?.optionalCapabilities ?? []), + ...(classifierOutput?.unmatchedCapabilities ?? []), + ]); + const ownership = signalSet([ + ...snapshot.ownershipTags, + ...(classifierOutput?.ownershipHints ?? []), + ]); + const title = signalSet(titleTokens(snapshot.title)); + const issueType = signalSet([snapshot.issueType]); + + return ( + overlapScore(roleTemplate.routing.capabilityTags, capabilities, 4) + + overlapScore(roleTemplate.routing.preferredLabels, labels, 3) + + overlapScore(roleTemplate.workspace.preferredAreas, ownership, 3) + + overlapScore(roleTemplate.routing.taskTypes, issueType, 2) + + overlapScore(roleTemplate.routing.capabilityTags, title, 1) + + overlapScore(roleTemplate.routing.preferredLabels, title, 1) + ); +} + +function overlapScore(candidates: string[], signals: Set, weight: number): number { + return candidates.filter(candidate => signalVariants(candidate).some(variant => signals.has(variant))).length * weight; +} + +function titleTokens(title: string): string[] { + return title + .toLowerCase() + .split(/[^a-z0-9]+/u) + .map(token => token.trim()) + .filter(token => token.length > 2); +} + +function signalSet(values: string[]): Set { + return new Set(values.flatMap(signalVariants)); +} + +function signalVariants(value: string): string[] { + const normalized = value.trim().toLowerCase(); + if (!normalized) return []; + const hyphen = normalized.replace(/[\s_]+/gu, "-"); + const underscore = normalized.replace(/[\s-]+/gu, "_"); + return uniqueStrings([normalized, hyphen, underscore]); +} + +function uniqueStrings(values: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const trimmed = value.trim(); + if (!trimmed) continue; + const key = lower(trimmed); + if (seen.has(key)) continue; + seen.add(key); + result.push(trimmed); + } + return result; +} + function isSetupVerificationTask(identifier: string, provider: TaskSource["provider"] | undefined): boolean { return provider === "agentrail_setup" || identifier.startsWith("LOCAL-SETUP-"); } diff --git a/src/server-runtime.ts b/src/server-runtime.ts index e346b15..3ce5e46 100644 --- a/src/server-runtime.ts +++ b/src/server-runtime.ts @@ -10,11 +10,15 @@ import { GitHubIssueIntakeAdapter } from "./github-issue-intake-adapter.ts"; import { LinearIssueSourceAdapter } from "./linear-issue-source-adapter.ts"; import { LinearCommentWebhookAdapter } from "./linear-comment-webhook-adapter.ts"; import { AgentProfileStore } from "./agent-profile-store.ts"; -import { RoutingControlPlane } from "./intake-routing-control-plane.ts"; +import { + RoutingControlPlane, + routingRoleTemplateDetailsFromLoaded, +} from "./intake-routing-control-plane.ts"; import { RoutingAuditStore } from "./routing-audit-store.ts"; import { RoutingRuleStore } from "./routing-rule-store.ts"; import { ProviderCursorStore } from "./provider-cursor-store.ts"; import { LocalRunnerRoutingClassifier } from "./routing-classifier.ts"; +import { resolveAgentRoleTemplate } from "./agent-role-template.ts"; import type { ConnectedRepo } from "./cli/agentrail-home.ts"; import { logNarrative, logOperatorNotice } from "./structured-logger.ts"; import type { TaskRecord } from "./task-store.ts"; @@ -68,6 +72,7 @@ export function buildRuntime({ eventStore, publicBaseUrl, telemetry = null, + homePath = process.env.AGENTRAIL_HOME || null, }: { githubToken: string | null; githubMode?: "real" | "disabled"; @@ -91,6 +96,7 @@ export function buildRuntime({ eventStore: TaskEventStore; publicBaseUrl: string; telemetry?: TelemetryClient | null; + homePath?: string | null; }) { const hasGitHubRuntime = Boolean(githubToken); @@ -152,6 +158,12 @@ export function buildRuntime({ routingRuleStore, classifier: new LocalRunnerRoutingClassifier({ now }), telemetry, + roleTemplateResolver: homePath + ? async (roleTemplateId) => { + const loaded = await resolveAgentRoleTemplate({ homePath, id: roleTemplateId }); + return loaded ? routingRoleTemplateDetailsFromLoaded(loaded) : null; + } + : null, }); const githubIssueIntakeAdapter = new GitHubIssueIntakeAdapter({ taskQueue: agentQueue, diff --git a/src/server.ts b/src/server.ts index 14e77c2..1772293 100644 --- a/src/server.ts +++ b/src/server.ts @@ -110,6 +110,7 @@ export function startServer() { eventStore, publicBaseUrl, telemetry, + homePath: agentrailHome, }); const authStore = new AgentAuthStore({ now, storagePath: authStorePath }); diff --git a/test/agent-management.test.ts b/test/agent-management.test.ts index 2c50110..0738408 100644 --- a/test/agent-management.test.ts +++ b/test/agent-management.test.ts @@ -325,6 +325,139 @@ test("agent create derives profile metadata from a built-in role template", asyn assert.equal(env.AGENTRAIL_AGENT_ROLE_TEMPLATE_ID, "frontend-ui"); }); +test("agent create routing retry summary reflects the final waiting state", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-create-routing-retry-")); + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const harness = await createHarness(); + const stdout = createMemoryWriter(); + const stderr = createMemoryWriter(); + const previousHome = process.env.AGENTRAIL_HOME; + process.env.AGENTRAIL_HOME = homePath; + await installFakeExecutableOnPath(t, homePath, "codex"); + + t.after(async () => { + await rm(repoRoot, { recursive: true, force: true }); + await rm(homePath, { recursive: true, force: true }); + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + await harness.close(); + }); + + await writeSetupRepo(repoRoot, homePath, harness.baseUrl); + + const ruleSetRes = await fetch(`${harness.baseUrl}/operator/routing/rule-sets/current`, { + method: "PUT", + headers: { + authorization: `Bearer ${harness.operatorApiKey}`, + "content-type": "application/json", + "idempotency-key": "agent-create-routing-retry-nonmatching-rule", + }, + body: JSON.stringify({ + sourceRef: "agent-management-test", + changeReason: "Start with a rule that does not match docs work.", + rules: [ + { + id: "rule_backend_only", + name: "Backend only", + enabled: true, + priority: 10, + conditions: { + repositories: ["oxnw/agentrail"], + labelsAny: ["backend"], + }, + target: { + type: "agent", + id: "agt_backend_missing", + }, + confidence: 0.9, + explanation: "Backend work only.", + }, + ], + classifier: { + enabled: false, + provider: "disabled", + confidenceThreshold: 0.8, + maxCandidates: 3, + fallbackTriageQueueId: "triage_default", + }, + }), + }); + assert.equal(ruleSetRes.status, 201, await ruleSetRes.text()); + + const intakeRes = await fetch(`${harness.baseUrl}/operator/intake/provider-issues`, { + method: "POST", + headers: { + authorization: `Bearer ${harness.operatorApiKey}`, + "content-type": "application/json", + "idempotency-key": "agent-create-routing-retry-docs-intake", + }, + body: JSON.stringify({ + provider: "github", + providerIssueId: "github:oxnw/agentrail:issues/agent-create-routing-retry", + sourceVersion: "agent-create-routing-retry-v1", + repository: { + provider: "github", + owner: "oxnw", + name: "agentrail", + defaultBranch: "main", + }, + title: "Add docs quick start example", + bodyDigest: "sha256:agent-create-routing-retry", + labels: ["docs"], + issueType: "documentation", + priority: "medium", + ownershipTags: ["documentation"], + capabilityTags: ["docs"], + links: { + providerIssue: "https://github.com/oxnw/agentrail/issues/agent-create-routing-retry", + }, + }), + }); + const intakeText = await intakeRes.text(); + assert.equal(intakeRes.status, 202, intakeText); + const intakeJson = JSON.parse(intakeText) as any; + assert.equal(intakeJson.data.outcome, "no_route"); + + const exitCode = await runCli([ + "agent", + "create", + "--setup-api-key", + harness.operatorApiKey, + "--base-url", + harness.baseUrl, + "--agent-id", + "agt_docs_retry", + "--name", + "Docs Retry", + "--runner", + "codex", + "--role-template", + "docs", + "--scopes", + "tasks:read,tasks:write", + "--repo-allowlist", + "oxnw/agentrail", + "--routing-labels", + "docs", + "--configure-routing", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout, + stderr, + }); + + assert.equal(exitCode, 0, stderr.toString()); + assert.match(stdout.toString(), /Routing retry: assigned 1 waiting task\./); + assert.doesNotMatch(stdout.toString(), /still waiting/); + + const env = parseEnv(await readFile(path.join(homePath, "agents", "agt_docs_retry.env"), "utf8")); + const mine = await getJson(harness.baseUrl, "/tasks/mine?status=todo&limit=5", env.AGENTRAIL_API_KEY); + assert.equal(mine.status, 200); + assert.equal(mine.json.data[0].id, intakeJson.data.taskId); +}); + test("agent create installs a custom role template file and reuses it by id", async (t) => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-create-custom-role-template-")); const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); diff --git a/test/intake-routing-control-plane.test.ts b/test/intake-routing-control-plane.test.ts index 6481b2b..84de4e6 100644 --- a/test/intake-routing-control-plane.test.ts +++ b/test/intake-routing-control-plane.test.ts @@ -147,6 +147,56 @@ test("RoutingControlPlane deterministically assigns and persists routing metadat assert.equal(stored?.routingReason?.matchedRules[0]?.id, "rule_architecture_to_cto"); }); +test("RoutingControlPlane records template metadata and explanation for template-backed assignments", async () => { + const { routing, taskQueue } = createControlPlane(); + seedProfile(routing, "agt_frontend", { + displayName: "Frontend", + role: "coding_agent", + roleTemplateId: "frontend-ui", + capabilityTags: ["frontend", "ui", "accessibility"], + ownershipTags: ["frontend_source"], + }); + seedRuleSet(routing, [ + { + id: "rule_frontend_to_frontend_agent", + name: "Frontend ownership", + enabled: true, + priority: 10, + conditions: { + repositories: ["oxnw/agentrail"], + labelsAny: ["frontend", "ui"], + capabilityTagsAll: ["frontend"], + }, + target: { type: "agent", id: "agt_frontend" }, + confidence: 0.98, + explanation: "Frontend work maps to the frontend agent.", + }, + ]); + + const decision = await routing.ingestProviderIssue(makeSnapshot({ + title: "Fix frontend accessibility layout", + labels: ["frontend", "ui", "accessibility"], + issueType: "feature", + capabilityTags: ["frontend", "accessibility"], + ownershipTags: ["frontend_source"], + }), "route_template_assignment"); + + assert.equal(decision.assignment.assigneeAgentId, "agt_frontend"); + assert.deepEqual(decision.assignment.roleTemplate, { + id: "frontend-ui", + name: "Frontend/UI", + sourceKind: "built_in", + }); + assert.match(decision.routingReason.summary, /Assigned to Frontend/); + assert.match(decision.routingReason.summary, /Frontend\/UI \(frontend-ui\)/); + assert.match(decision.routingReason.summary, /frontend/); + + const stored = taskQueue.getRawTask(decision.taskId!); + assert.equal(stored?.routingReason?.summary, decision.routingReason.summary); + const audit = routing.getRoutingAudit(decision.id); + assert.deepEqual(audit?.decision.assignment.roleTemplate, decision.assignment.roleTemplate); +}); + test("RoutingControlPlane emits safe telemetry for assigned provider issue routing", async () => { const eventStore = new TaskEventStore({ now }); const taskQueue = new AgentTaskQueue({ now, eventStore }); @@ -813,7 +863,35 @@ test("RoutingControlPlane falls back to triage when no deterministic rule matche assert.equal(decision.outcome, "no_route"); assert.equal(decision.assignment.assigneeAgentId, null); assert.equal(decision.assignment.triageQueueId, "triage_engineering"); - assert.match(decision.routingReason.summary, /No deterministic route/i); + assert.match(decision.routingReason.summary, /No suitable agent found/i); +}); + +test("RoutingControlPlane suggests built-in templates when no suitable agent exists", async () => { + const { routing } = createControlPlane(); + seedProfile(routing, "agt_backend"); + seedRuleSet(routing, [ + { + id: "rule_backend_only", + name: "Backend ownership", + enabled: true, + priority: 10, + conditions: { labelsAny: ["backend"] }, + target: { type: "agent", id: "agt_backend" }, + confidence: 0.75, + explanation: "Backend work maps to backend.", + }, + ]); + + const decision = await routing.ingestProviderIssue(makeSnapshot({ + issueType: "documentation", + labels: ["docs", "readme"], + capabilityTags: ["docs"], + ownershipTags: ["documentation"], + }), "route_nomatch_template_suggestion"); + + assert.equal(decision.outcome, "no_route"); + assert.match(decision.routingReason.summary, /Create a new agent or update an existing agent/); + assert.match(decision.routingReason.summary, /Suggested templates: Docs \(docs\)/); }); test("RoutingControlPlane triages classifier-enabled fallback when no classifier is configured", async () => { @@ -1003,7 +1081,7 @@ test("RoutingControlPlane triages low-confidence classifier output", async () => assert.deepEqual(decision.routingReason.conflictReasons, ["classifier_low_confidence"]); }); -test("RoutingControlPlane triages classifier output with unmatched capabilities", async () => { +test("RoutingControlPlane leaves classifier output with unmatched capabilities waiting for a suitable agent", async () => { const classifier: RoutingClassifier = { async classify() { return { @@ -1037,7 +1115,7 @@ test("RoutingControlPlane triages classifier output with unmatched capabilities" const decision = await routing.ingestProviderIssue(makeSnapshot({ issueType: "feature" }), "route_classifier_unmatched_capabilities"); - assert.equal(decision.outcome, "triage"); + assert.equal(decision.outcome, "no_route"); assert.equal(decision.assignment.assignmentSource, "manual_triage"); assert.deepEqual(decision.routingReason.conflictReasons, [ "classifier_unmatched_capabilities", @@ -1093,7 +1171,7 @@ test("RoutingControlPlane best-effort assigns closest agent when configured", as assert.equal(decision.routingReason.classifier?.suggestedTarget.id, "agt_backend"); }); -test("RoutingControlPlane triages classifier output without required capabilities", async () => { +test("RoutingControlPlane leaves classifier output without required capabilities waiting for a suitable agent", async () => { const classifier: RoutingClassifier = { async classify() { return { @@ -1127,7 +1205,7 @@ test("RoutingControlPlane triages classifier output without required capabilitie const decision = await routing.ingestProviderIssue(makeSnapshot({ issueType: "maintenance" }), "route_classifier_no_required_capabilities"); - assert.equal(decision.outcome, "triage"); + assert.equal(decision.outcome, "no_route"); assert.equal(decision.assignment.assignmentSource, "manual_triage"); assert.deepEqual(decision.routingReason.conflictReasons, ["classifier_no_required_capabilities"]); }); @@ -1170,7 +1248,7 @@ test("RoutingControlPlane retries waiting AI-routed tasks after an agent profile capabilityTags: [], }), "route_classifier_retry_waiting"); - assert.equal(first.outcome, "triage"); + assert.equal(first.outcome, "no_route"); assert.equal(first.assignment.assignmentSource, "manual_triage"); assert.deepEqual(first.routingReason.conflictReasons, ["classifier_no_candidate_agents"]); @@ -1179,7 +1257,7 @@ test("RoutingControlPlane retries waiting AI-routed tasks after an agent profile ownershipTags: ["mobile"], }); - const retry = await (routing as any).retryWaitingAiRoutedTasks("agt_router"); + const retry = await routing.retryWaitingAiRoutedTasks("agt_router"); assert.equal(retry.scanned, 1); assert.equal(retry.assigned, 1); @@ -1189,6 +1267,61 @@ test("RoutingControlPlane retries waiting AI-routed tasks after an agent profile assert.deepEqual(stored?.availableActions, ["start"]); }); +test("RoutingControlPlane retries waiting rules-only no-route tasks after routing becomes suitable", async () => { + const { routing, taskQueue } = createControlPlane(); + seedRuleSet(routing, [ + { + id: "rule_backend_only", + name: "Backend ownership", + enabled: true, + priority: 10, + conditions: { labelsAny: ["backend"] }, + target: { type: "agent", id: "agt_backend" }, + confidence: 0.75, + explanation: "Backend work maps to backend.", + }, + ]); + + const first = await routing.ingestProviderIssue(makeSnapshot({ + issueType: "documentation", + labels: ["docs"], + capabilityTags: ["docs"], + }), "route_rules_retry_waiting"); + + assert.equal(first.outcome, "no_route"); + assert.equal(first.assignment.assigneeAgentId, null); + + seedProfile(routing, "agt_docs", { + displayName: "Docs", + role: "docs_agent", + roleTemplateId: "docs", + capabilityTags: ["docs"], + ownershipTags: ["documentation"], + }); + seedRuleSet(routing, [ + { + id: "rule_docs_to_docs", + name: "Docs ownership", + enabled: true, + priority: 10, + conditions: { labelsAny: ["docs"] }, + target: { type: "agent", id: "agt_docs" }, + confidence: 0.92, + explanation: "Docs work maps to Docs.", + }, + ]); + + const retry = await routing.retryWaitingRoutingTasks("agt_router"); + + assert.equal(retry.scanned, 1); + assert.equal(retry.assigned, 1); + const stored = taskQueue.getRawTask(first.taskId!); + assert.equal(stored?.assigneeAgentId, "agt_docs"); + assert.equal(stored?.assignmentSource, "deterministic_rule"); + assert.deepEqual(stored?.availableActions, ["start"]); + assert.match(stored?.routingReason?.summary ?? "", /Docs \(docs\)/); +}); + test("RoutingControlPlane rejects stale rule set versions during evaluation", async () => { const { routing } = createControlPlane(); seedProfile(routing, "agt_cto"); diff --git a/test/intake-routing-endpoints.test.ts b/test/intake-routing-endpoints.test.ts index c8c24a8..692d0a0 100644 --- a/test/intake-routing-endpoints.test.ts +++ b/test/intake-routing-endpoints.test.ts @@ -659,6 +659,172 @@ test("operator intake route records a task assignment and exposes audit lookup", } }); +test("operator routing rule update retries waiting no-route tasks with template metadata", async () => { + const now = () => new Date("2026-05-05T12:00:00Z"); + const eventStore = new TaskEventStore({ now }); + const taskQueue = new AgentTaskQueue({ now, eventStore }); + const routingControlPlane = new RoutingControlPlane({ now, taskQueue }); + const authStore = new AgentAuthStore({ now }); + + const { data: operatorKey } = authStore.createKey({ + agent: { id: "agt_operator", displayName: "Operator", role: "cto", externalIdentities: [] }, + scopes: ["routing:admin", "routing:read", "routing:evaluate"], + }, "idemp_operator_retry_key"); + + routingControlPlane.replaceRuleSet({ + sourceRef: "AGEA-99", + changeReason: "seed non-matching rule set for retry endpoint test", + rules: [ + { + id: "rule_backend_only", + name: "Backend ownership", + enabled: true, + priority: 10, + conditions: { labelsAny: ["backend"] }, + target: { type: "agent", id: "agt_backend" }, + confidence: 0.75, + explanation: "Backend work maps to backend.", + }, + ], + classifier: { + enabled: false, + provider: "internal-router", + confidenceThreshold: 0.82, + maxCandidates: 3, + fallbackTriageQueueId: "triage_engineering", + }, + }, "agt_operator", "idemp_retry_initial_rule_seed"); + + const server = createServer({ + store: eventStore, + authStore, + taskLifecycleStore: taskQueue, + routingControlPlane, + now, + }); + + const baseUrl = await listen(server); + + try { + const intakeRes = await fetch(`${baseUrl}/operator/intake/provider-issues`, { + method: "POST", + headers: { + authorization: `Bearer ${operatorKey.apiKey}`, + "content-type": "application/json", + "idempotency-key": "idemp_operator_retry_intake", + }, + body: JSON.stringify({ + provider: "github", + providerIssueId: "github:oxnw/agentrail:issues/103", + sourceVersion: "2026-05-05T12:00:00Z:delivery-retry-01", + repository: { + provider: "github", + owner: "oxnw", + name: "agentrail", + defaultBranch: "main", + }, + title: "Add docs quick start example", + bodyDigest: "sha256:route-retry-test", + labels: ["docs"], + issueType: "documentation", + priority: "medium", + ownershipTags: ["documentation"], + capabilityTags: ["docs"], + links: { + providerIssue: "https://github.com/oxnw/agentrail/issues/103", + }, + }), + }); + + assert.equal(intakeRes.status, 202); + const intakeJson = await intakeRes.json(); + assert.equal(intakeJson.data.outcome, "no_route"); + assert.equal(intakeJson.data.assignment.assigneeAgentId, null); + assert.match(intakeJson.data.routingReason.summary, /Suggested templates: Docs \(docs\)/); + + const profileRes = await fetch(`${baseUrl}/operator/routing/agent-profiles/agt_docs`, { + method: "PUT", + headers: { + authorization: `Bearer ${operatorKey.apiKey}`, + "content-type": "application/json", + "idempotency-key": "idemp_operator_retry_profile", + }, + body: JSON.stringify({ + displayName: "Docs", + role: "docs_agent", + roleTemplateId: "docs", + status: "active", + capabilityTags: ["docs"], + ownershipTags: ["documentation"], + repoAllowlist: ["oxnw/agentrail"], + maxConcurrentTasks: 2, + sourceRef: "endpoint-test", + changeReason: "Add docs agent for retry endpoint test.", + }), + }); + assert.equal(profileRes.status, 200); + + const ruleRes = await fetch(`${baseUrl}/operator/routing/rule-sets/current`, { + method: "PUT", + headers: { + authorization: `Bearer ${operatorKey.apiKey}`, + "content-type": "application/json", + "idempotency-key": "idemp_operator_retry_rule_update", + }, + body: JSON.stringify({ + sourceRef: "endpoint-test", + changeReason: "Route docs tasks to docs agent.", + rules: [ + { + id: "rule_docs_to_docs", + name: "Docs ownership", + enabled: true, + priority: 10, + conditions: { + repositories: ["oxnw/agentrail"], + labelsAny: ["docs"], + }, + target: { type: "agent", id: "agt_docs" }, + confidence: 0.94, + explanation: "Docs work maps to Docs.", + }, + ], + classifier: { + enabled: false, + provider: "internal-router", + confidenceThreshold: 0.82, + maxCandidates: 3, + fallbackTriageQueueId: "triage_engineering", + }, + }), + }); + + assert.equal(ruleRes.status, 201); + const ruleJson = await ruleRes.json(); + assert.equal(ruleJson.meta.routingRetry.assigned, 1); + + const stored = taskQueue.getRawTask(intakeJson.data.taskId); + assert.equal(stored?.assigneeAgentId, "agt_docs"); + assert.equal(stored?.assignmentSource, "deterministic_rule"); + assert.match(stored?.routingReason?.summary ?? "", /Docs \(docs\)/); + + const auditRes = await fetch(`${baseUrl}/operator/routing/audit/${stored?.routingDecisionId}`, { + headers: { + authorization: `Bearer ${operatorKey.apiKey}`, + }, + }); + assert.equal(auditRes.status, 200); + const auditJson = await auditRes.json(); + assert.deepEqual(auditJson.data.decision.assignment.roleTemplate, { + id: "docs", + name: "Docs", + sourceKind: "built_in", + }); + } finally { + server.close(); + } +}); + test("operator dry-run evaluation records an audit lookup without a task action", async () => { const now = () => new Date("2026-05-05T12:00:00Z"); const eventStore = new TaskEventStore({ now }); From 60a9cfe68f9b9978e5a614a127cc738ce64b9d2d Mon Sep 17 00:00:00 2001 From: Onyeka Nwamba Date: Tue, 26 May 2026 01:37:31 +0100 Subject: [PATCH 5/7] fix: harden managed context and repo profiling --- src/intake-routing-control-plane.ts | 5 +- src/managed-run-context.ts | 47 ++++++++++++- src/repo-map-profiler.ts | 35 +++++++-- test/intake-routing-control-plane.test.ts | 64 +++++++++++++++++ test/managed-run-context.test.ts | 82 +++++++++++++++++++++ test/repo-map-profiler.test.ts | 86 +++++++++++++++++++++++ 6 files changed, 311 insertions(+), 8 deletions(-) create mode 100644 test/managed-run-context.test.ts diff --git a/src/intake-routing-control-plane.ts b/src/intake-routing-control-plane.ts index 59322f7..46eb2ad 100644 --- a/src/intake-routing-control-plane.ts +++ b/src/intake-routing-control-plane.ts @@ -1398,9 +1398,12 @@ export class RoutingControlPlane { } try { const resolved = await this.roleTemplateResolver(profile.roleTemplateId); + const matchedRoleTemplate = resolved?.id === profile.roleTemplateId + ? resolved + : null; return { profile, - roleTemplate: resolved ?? unknownRoleTemplateDetails(profile.roleTemplateId), + roleTemplate: matchedRoleTemplate ?? unknownRoleTemplateDetails(profile.roleTemplateId), }; } catch { return { diff --git a/src/managed-run-context.ts b/src/managed-run-context.ts index 1567c1f..51a59cb 100644 --- a/src/managed-run-context.ts +++ b/src/managed-run-context.ts @@ -60,6 +60,48 @@ function nullableRecordValue(value: unknown): Record | null | u return isRecord(value) ? value : undefined; } +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === "string"); +} + +function isRoleBriefAreaArray(value: unknown): value is ManagedRunRoleBrief["preferredAreas"] { + return Array.isArray(value) && value.every((entry) => ( + isRecord(entry) + && typeof entry.area === "string" + && isStringArray(entry.paths) + && (entry.notes === undefined || typeof entry.notes === "string") + )); +} + +function isCommandListMap(value: unknown): value is ManagedRunRoleBrief["commands"] { + if (!isRecord(value)) return false; + return ["test", "lint", "typecheck", "build"].every((key) => ( + value[key] === undefined || isStringArray(value[key]) + )); +} + +function isManagedRunRoleBrief(value: unknown): value is ManagedRunRoleBrief { + if (!isRecord(value)) return false; + return typeof value.templateId === "string" + && typeof value.templateName === "string" + && typeof value.templateSourceKind === "string" + && typeof value.description === "string" + && typeof value.instructions === "string" + && isRoleBriefAreaArray(value.preferredAreas) + && isRoleBriefAreaArray(value.cautionAreas) + && isRoleBriefAreaArray(value.forbiddenAreas) + && isRecord(value.checks) + && isStringArray(value.checks.required) + && isStringArray(value.checks.suggested) + && isRecord(value.evidence) + && isStringArray(value.evidence.required) + && isStringArray(value.evidence.whenApplicable) + && isRecord(value.escalation) + && isStringArray(value.escalation.askUserWhen) + && isCommandListMap(value.commands) + && isStringArray(value.warnings); +} + export function describeManagedRunAction(action: string): ManagedRunContextAction { if (action === "submit") { return { @@ -139,7 +181,7 @@ export function buildManagedRunContextEnvelope({ export function isManagedRunContextEnvelope(value: unknown): value is ManagedRunContextEnvelope { if (!isRecord(value) || !isRecord(value.data)) return false; - const { run, task, nextActions } = value.data; + const { run, task, nextActions, roleBrief } = value.data; if (!isRecord(run) || !isRecord(task) || !Array.isArray(nextActions)) return false; return typeof run.runId === "string" && typeof run.taskId === "string" @@ -149,5 +191,6 @@ export function isManagedRunContextEnvelope(value: unknown): value is ManagedRun && Array.isArray(task.availableActions) && task.availableActions.every((entry) => typeof entry === "string") && Array.isArray(value.availableActions) - && value.availableActions.every((entry) => typeof entry === "string"); + && value.availableActions.every((entry) => typeof entry === "string") + && (roleBrief === undefined || isManagedRunRoleBrief(roleBrief)); } diff --git a/src/repo-map-profiler.ts b/src/repo-map-profiler.ts index 90e7f19..ba7eecd 100644 --- a/src/repo-map-profiler.ts +++ b/src/repo-map-profiler.ts @@ -151,6 +151,7 @@ export async function proposeRepoMapWithLocalRunner({ executable, args, prompt, + repoRoot, timeoutMs: config.timeoutMs, }); return await parseRepoMapProfilerOutput(output, { @@ -367,12 +368,14 @@ async function runRepoMapProfilerProcess({ executable, args, prompt, + repoRoot, timeoutMs, }: { spawnProcess: typeof spawn; executable: string; args: string[]; prompt: string; + repoRoot: string; timeoutMs: number; }): Promise { const stdoutChunks: Buffer[] = []; @@ -380,8 +383,9 @@ async function runRepoMapProfilerProcess({ return await new Promise((resolve, reject) => { let settled = false; const child = spawnProcess(executable, args, { + cwd: repoRoot, stdio: "pipe", - env: stripAgentRailSecrets(process.env), + env: safeRepoMapProfilerEnv(process.env), }); const timeout = setTimeout(() => { child.kill("SIGTERM"); @@ -419,16 +423,37 @@ async function runRepoMapProfilerProcess({ }); } -function stripAgentRailSecrets(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { +function safeRepoMapProfilerEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const next: NodeJS.ProcessEnv = {}; + for (const key of SAFE_REPO_MAP_PROFILER_ENV_KEYS) { + if (env[key] !== undefined) { + next[key] = env[key]; + } + } for (const [key, value] of Object.entries(env)) { - const normalizedKey = key.toUpperCase(); - if (normalizedKey.includes("TOKEN") || normalizedKey.includes("KEY") || normalizedKey.includes("SECRET")) continue; - next[key] = value; + if (/^LC_/u.test(key) && value !== undefined) { + next[key] = value; + } } return next; } +const SAFE_REPO_MAP_PROFILER_ENV_KEYS = [ + "CI", + "HOME", + "LANG", + "LOGNAME", + "NODE_ENV", + "PATH", + "SHELL", + "TERM", + "TMPDIR", + "USER", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", +] as const; + function stringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; return value diff --git a/test/intake-routing-control-plane.test.ts b/test/intake-routing-control-plane.test.ts index 84de4e6..2211529 100644 --- a/test/intake-routing-control-plane.test.ts +++ b/test/intake-routing-control-plane.test.ts @@ -197,6 +197,70 @@ test("RoutingControlPlane records template metadata and explanation for template assert.deepEqual(audit?.decision.assignment.roleTemplate, decision.assignment.roleTemplate); }); +test("RoutingControlPlane discards resolver results that do not match the profile role template id", async () => { + const eventStore = new TaskEventStore({ now }); + const taskQueue = new AgentTaskQueue({ now, eventStore }); + const routing = new RoutingControlPlane({ + now, + taskQueue, + roleTemplateResolver: async () => ({ + id: "wrong-template", + name: "Wrong Template", + role: "coding_agent", + sourceKind: "custom", + description: "Wrong template metadata.", + routing: { + capabilityTags: [], + preferredLabels: [], + taskTypes: [], + avoidLabels: [], + }, + workspace: { + preferredAreas: [], + cautionAreas: [], + forbiddenAreas: [], + }, + }), + }); + seedProfile(routing, "agt_frontend", { + displayName: "Frontend", + role: "coding_agent", + roleTemplateId: "frontend-ui", + capabilityTags: ["frontend"], + ownershipTags: ["frontend_source"], + }); + seedRuleSet(routing, [ + { + id: "rule_frontend_to_frontend_agent", + name: "Frontend ownership", + enabled: true, + priority: 10, + conditions: { + repositories: ["oxnw/agentrail"], + labelsAny: ["frontend"], + }, + target: { type: "agent", id: "agt_frontend" }, + confidence: 0.98, + explanation: "Frontend work maps to the frontend agent.", + }, + ]); + + const decision = await routing.ingestProviderIssue(makeSnapshot({ + title: "Fix frontend layout", + labels: ["frontend"], + issueType: "feature", + capabilityTags: ["frontend"], + ownershipTags: ["frontend_source"], + }), "route_template_resolver_mismatch"); + + assert.deepEqual(decision.assignment.roleTemplate, { + id: "frontend-ui", + name: "frontend-ui", + sourceKind: "unknown", + }); + assert.doesNotMatch(decision.routingReason.summary, /Wrong Template/); +}); + test("RoutingControlPlane emits safe telemetry for assigned provider issue routing", async () => { const eventStore = new TaskEventStore({ now }); const taskQueue = new AgentTaskQueue({ now, eventStore }); diff --git a/test/managed-run-context.test.ts b/test/managed-run-context.test.ts new file mode 100644 index 0000000..1a6b2e5 --- /dev/null +++ b/test/managed-run-context.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildManagedRunContextEnvelope, + isManagedRunContextEnvelope, +} from "../src/managed-run-context.ts"; + +function baseContext() { + return buildManagedRunContextEnvelope({ + run: { + runId: "run_context_test", + agentId: "agt_context", + runner: "codex", + taskId: "tsk_context", + taskIdentifier: "AGEA-CONTEXT", + status: "running", + repoPath: "/tmp/repo", + worktreePath: "/tmp/agentrail/run_context_test", + branchName: "agentrail/agt_context/tsk_context", + promptPath: null, + logPath: null, + handoffPath: null, + createdAt: "2026-05-05T12:00:00Z", + startedAt: "2026-05-05T12:00:00Z", + finishedAt: null, + updatedAt: "2026-05-05T12:00:00Z", + exitCode: null, + summary: null, + runContextTokenHash: null, + runContextTokenIssuedAt: null, + userAction: null, + reports: [], + reportedHandoff: null, + launch: { + executable: "codex", + args: [], + }, + }, + taskBody: { + id: "tsk_context", + identifier: "AGEA-CONTEXT", + title: "Validate context", + status: "in_progress", + acceptanceCriteria: [], + availableActions: ["submit"], + }, + roleBrief: { + templateId: "frontend-ui", + templateName: "Frontend/UI", + templateSourceKind: "built_in", + description: "Handles frontend work.", + instructions: "Follow existing UI patterns.", + preferredAreas: [{ area: "frontend_source", paths: ["app"] }], + cautionAreas: [], + forbiddenAreas: [], + checks: { required: [], suggested: [] }, + evidence: { required: [], whenApplicable: [] }, + escalation: { askUserWhen: [] }, + commands: {}, + warnings: [], + }, + }); +} + +test("isManagedRunContextEnvelope accepts valid role brief arrays", () => { + assert.equal(isManagedRunContextEnvelope(baseContext()), true); +}); + +test("isManagedRunContextEnvelope rejects malformed role brief arrays", () => { + const context = baseContext() as any; + context.data.roleBrief.preferredAreas = "frontend_source"; + + assert.equal(isManagedRunContextEnvelope(context), false); +}); + +test("isManagedRunContextEnvelope rejects malformed role brief warnings", () => { + const context = baseContext() as any; + context.data.roleBrief.warnings = "not an array"; + + assert.equal(isManagedRunContextEnvelope(context), false); +}); diff --git a/test/repo-map-profiler.test.ts b/test/repo-map-profiler.test.ts index 1d3a709..48c317a 100644 --- a/test/repo-map-profiler.test.ts +++ b/test/repo-map-profiler.test.ts @@ -1,13 +1,16 @@ import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import type { spawn } from "node:child_process"; import test from "node:test"; import { AGENTRAIL_REPO_MAP_SCHEMA_VERSION, generateStarterRepoMap } from "../src/repo-map.ts"; import { buildRepoMapProfilerPrompt, parseRepoMapProfilerOutput, + proposeRepoMapWithLocalRunner, repoMapProfilerLaunchCommand, scanRepoForRepoMapProfile, } from "../src/repo-map-profiler.ts"; @@ -189,3 +192,86 @@ test("repoMapProfilerLaunchCommand uses local runner commands", () => { args: ["--print", "--model", "sonnet-4"], }); }); + +test("proposeRepoMapWithLocalRunner launches inside the target repo with a safe env allowlist", async (t) => { + const repoRoot = makeTempDir(t, "agentrail-repo-profile-launch-"); + fs.mkdirSync(path.join(repoRoot, "app"), { recursive: true }); + fs.writeFileSync(path.join(repoRoot, "package.json"), JSON.stringify({ scripts: { test: "node --test" } }), "utf8"); + const deterministicMap = await generateStarterRepoMap(repoRoot); + const previousEnv = { + DATABASE_URL: process.env.DATABASE_URL, + PGPASSWORD: process.env.PGPASSWORD, + SESSION_COOKIE: process.env.SESSION_COOKIE, + JWT_SECRET: process.env.JWT_SECRET, + PATH: process.env.PATH, + }; + process.env.DATABASE_URL = "postgres://secret"; + process.env.PGPASSWORD = "secret"; + process.env.SESSION_COOKIE = "secret"; + process.env.JWT_SECRET = "secret"; + process.env.PATH = previousEnv.PATH ?? "/usr/bin"; + t.after(() => { + for (const [key, value] of Object.entries(previousEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + const launches: Array<{ cwd: string | undefined; env: NodeJS.ProcessEnv | undefined }> = []; + const spawnProcess = ((executable: string, args: string[], options?: { cwd?: string; env?: NodeJS.ProcessEnv }) => { + void executable; + void args; + launches.push({ cwd: options?.cwd, env: options?.env }); + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = { + end() { + queueMicrotask(() => { + child.stdout.emit("data", Buffer.from(JSON.stringify({ + confidence: 0.91, + summary: "Mapped app.", + warnings: [], + repoMap: { + schemaVersion: AGENTRAIL_REPO_MAP_SCHEMA_VERSION, + areas: { + application_source: { + paths: ["app/**"], + }, + }, + ignoredPaths: [".git"], + generatedPaths: [], + commands: { + test: ["node --test"], + }, + }, + }))); + child.emit("close", 0); + }); + }, + }; + child.kill = () => undefined; + return child; + }) as typeof spawn; + + const result = await proposeRepoMapWithLocalRunner({ + repoRoot, + repoSlug: "oxnw/example", + deterministicMap, + config: { + runner: "codex", + model: null, + timeoutMs: 1_000, + confidenceThreshold: 0.65, + }, + spawnProcess, + }); + + assert.equal(result.status, "accepted"); + assert.equal(launches[0]?.cwd, repoRoot); + assert.equal(launches[0]?.env?.PATH, process.env.PATH); + assert.equal(launches[0]?.env?.DATABASE_URL, undefined); + assert.equal(launches[0]?.env?.PGPASSWORD, undefined); + assert.equal(launches[0]?.env?.SESSION_COOKIE, undefined); + assert.equal(launches[0]?.env?.JWT_SECRET, undefined); +}); From 77e26c20cac3347ce3ef167cd2f641c7dd7680bc Mon Sep 17 00:00:00 2001 From: Onyeka Nwamba Date: Tue, 26 May 2026 01:56:46 +0100 Subject: [PATCH 6/7] fix: harden role template updates and profiler output --- src/cli/agent-management.ts | 16 +++-- src/repo-map-profiler.ts | 17 +++++- test/agent-management.test.ts | 104 +++++++++++++++++++++++++++++++++ test/repo-map-profiler.test.ts | 49 ++++++++++++++++ 4 files changed, 180 insertions(+), 6 deletions(-) diff --git a/src/cli/agent-management.ts b/src/cli/agent-management.ts index 133ab68..19f666f 100644 --- a/src/cli/agent-management.ts +++ b/src/cli/agent-management.ts @@ -1051,17 +1051,25 @@ async function collectUpdateInputs({ const currentRoleTemplate = roleTemplate || !currentProfile.roleTemplateId ? null : await resolveRoleTemplateByIdOrNull({ homePath, id: currentProfile.roleTemplateId }); + const currentRole = currentProfile.role ?? currentUsage.agent?.role ?? "coding_agent"; const role = flags.role ?? roleTemplate?.template.role ?? (prompt ? await prompt.input({ message: "Role", - defaultValue: currentProfile.role ?? currentUsage.agent?.role ?? "coding_agent", - }) : currentProfile.role ?? currentUsage.agent?.role ?? "coding_agent"); + defaultValue: currentRole, + }) : currentRole); assertRoleTemplateMatchesRole(roleTemplate, role); + const preserveExistingRoleTemplateId = !roleTemplate + && !currentRoleTemplate + && Boolean(currentProfile.roleTemplateId) + && role === currentRole; const roleTemplateId = roleTemplate && role === roleTemplate.template.role ? roleTemplate.template.id : currentRoleTemplate && role === currentRoleTemplate.template.role ? currentRoleTemplate.template.id - : null; - const activeRoleTemplate = roleTemplate ?? (roleTemplateId ? currentRoleTemplate : null); + : preserveExistingRoleTemplateId + ? currentProfile.roleTemplateId ?? null + : null; + const activeRoleTemplate = roleTemplate + ?? (currentRoleTemplate && roleTemplateId === currentRoleTemplate.template.id ? currentRoleTemplate : null); const scopes = await resolveAgentScopes({ flags, prompt, diff --git a/src/repo-map-profiler.ts b/src/repo-map-profiler.ts index ba7eecd..7901c4b 100644 --- a/src/repo-map-profiler.ts +++ b/src/repo-map-profiler.ts @@ -363,6 +363,17 @@ function extractJsonPayload(raw: string): string { return trimmed; } +function appendBoundedChunk(chunks: Buffer[], chunk: Buffer | Uint8Array | string, currentLength: number): number { + const remaining = MAX_STDIO_BYTES - currentLength; + if (remaining <= 0) { + return currentLength; + } + const buffer = Buffer.from(chunk); + const bounded = buffer.length > remaining ? buffer.subarray(0, remaining) : buffer; + chunks.push(bounded); + return currentLength + bounded.length; +} + async function runRepoMapProfilerProcess({ spawnProcess, executable, @@ -380,6 +391,8 @@ async function runRepoMapProfilerProcess({ }): Promise { const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; + let stdoutLength = 0; + let stderrLength = 0; return await new Promise((resolve, reject) => { let settled = false; const child = spawnProcess(executable, args, { @@ -395,10 +408,10 @@ async function runRepoMapProfilerProcess({ } }, timeoutMs); child.stdout.on("data", chunk => { - if (Buffer.concat(stdoutChunks).length < MAX_STDIO_BYTES) stdoutChunks.push(Buffer.from(chunk)); + stdoutLength = appendBoundedChunk(stdoutChunks, chunk, stdoutLength); }); child.stderr.on("data", chunk => { - if (Buffer.concat(stderrChunks).length < MAX_STDIO_BYTES) stderrChunks.push(Buffer.from(chunk)); + stderrLength = appendBoundedChunk(stderrChunks, chunk, stderrLength); }); child.on("error", error => { clearTimeout(timeout); diff --git a/test/agent-management.test.ts b/test/agent-management.test.ts index 0738408..4254678 100644 --- a/test/agent-management.test.ts +++ b/test/agent-management.test.ts @@ -1090,6 +1090,110 @@ test("agent update clears stale role template metadata when role is manually cha assert.equal(updatedEnv.AGENTRAIL_AGENT_ROLE_TEMPLATE_ID, undefined); }); +test("agent update preserves server-side role template ids unavailable locally", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-update-server-template-")); + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const harness = await createHarness(); + const previousHome = process.env.AGENTRAIL_HOME; + process.env.AGENTRAIL_HOME = homePath; + await installFakeExecutableOnPath(t, homePath, "codex"); + + t.after(async () => { + await rm(repoRoot, { recursive: true, force: true }); + await rm(homePath, { recursive: true, force: true }); + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + await harness.close(); + }); + + await writeSetupRepo(repoRoot, homePath, harness.baseUrl); + + let exitCode = await runCli([ + "agent", + "create", + "--setup-api-key", + harness.operatorApiKey, + "--base-url", + harness.baseUrl, + "--agent-id", + "agt_server_template_update", + "--name", + "Server Template Update", + "--runner", + "codex", + "--scopes", + "tasks:read,tasks:write", + "--repo-allowlist", + "oxnw/agentrail", + "--capability-tags", + "code", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout: createMemoryWriter(), + stderr: createMemoryWriter(), + }); + assert.equal(exitCode, 0); + + const envPath = path.join(homePath, "agents", "agt_server_template_update.env"); + const originalEnv = parseEnv(await readFile(envPath, "utf8")); + const replaceProfile = await fetch(`${harness.baseUrl}/operator/routing/agent-profiles/agt_server_template_update`, { + method: "PUT", + headers: { + authorization: `Bearer ${harness.operatorApiKey}`, + "content-type": "application/json", + "idempotency-key": "server-template-profile-update", + }, + body: JSON.stringify({ + displayName: "Server Template Update", + role: "coding_agent", + roleTemplateId: "server-template", + status: "active", + capabilityTags: ["code"], + ownershipTags: [], + repoAllowlist: ["oxnw/agentrail"], + maxConcurrentTasks: 1, + sourceRef: "test:server-template", + changeReason: "Inject server-side template metadata.", + }), + }); + assert.equal(replaceProfile.status, 200, await replaceProfile.text()); + + exitCode = await runCli([ + "agent", + "update", + "--setup-api-key", + harness.operatorApiKey, + "--base-url", + harness.baseUrl, + "--agent-id", + "agt_server_template_update", + "--api-key-id", + originalEnv.AGENTRAIL_API_KEY_ID, + "--env-file", + path.relative(repoRoot, envPath), + "--name", + "Server Template Renamed", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout: createMemoryWriter(), + stderr: createMemoryWriter(), + }); + assert.equal(exitCode, 0); + + const profile = await getJson(harness.baseUrl, "/operator/routing/agent-profiles/agt_server_template_update", harness.operatorApiKey); + assert.equal(profile.status, 200); + assert.equal(profile.json.data.displayName, "Server Template Renamed"); + assert.equal(profile.json.data.role, "coding_agent"); + assert.equal(profile.json.data.roleTemplateId, "server-template"); + + const updatedEnv = parseEnv(await readFile(envPath, "utf8")); + assert.equal(updatedEnv.AGENTRAIL_AGENT_ROLE_TEMPLATE_ID, "server-template"); +}); + test("agent update clears previous model when runner changes without a new model", async (t) => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-update-runner-model-")); const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); diff --git a/test/repo-map-profiler.test.ts b/test/repo-map-profiler.test.ts index 48c317a..3c56e79 100644 --- a/test/repo-map-profiler.test.ts +++ b/test/repo-map-profiler.test.ts @@ -275,3 +275,52 @@ test("proposeRepoMapWithLocalRunner launches inside the target repo with a safe assert.equal(launches[0]?.env?.SESSION_COOKIE, undefined); assert.equal(launches[0]?.env?.JWT_SECRET, undefined); }); + +test("proposeRepoMapWithLocalRunner bounds oversized stdout and stderr chunks", async (t) => { + const repoRoot = makeTempDir(t, "agentrail-repo-profile-stdio-"); + fs.mkdirSync(path.join(repoRoot, "app"), { recursive: true }); + const deterministicMap = await generateStarterRepoMap(repoRoot); + const maxStdioBytes = 512 * 1024; + + async function runOverflowCase(streamName: "stdout" | "stderr") { + const marker = `${streamName.toUpperCase()}_AFTER_LIMIT`; + const overflow = Buffer.concat([ + Buffer.alloc(maxStdioBytes, streamName === "stdout" ? "o" : "e"), + Buffer.from(marker), + ]); + const spawnProcess = (() => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = { + end() { + queueMicrotask(() => { + child[streamName].emit("data", overflow); + child.emit("close", 1); + }); + }, + }; + child.kill = () => undefined; + return child; + }) as typeof spawn; + + const result = await proposeRepoMapWithLocalRunner({ + repoRoot, + repoSlug: "oxnw/example", + deterministicMap, + config: { + runner: "codex", + model: null, + timeoutMs: 1_000, + confidenceThreshold: 0.65, + }, + spawnProcess, + }); + + assert.equal(result.status, "fallback"); + assert.doesNotMatch(result.reason ?? "", new RegExp(marker, "u")); + } + + await runOverflowCase("stdout"); + await runOverflowCase("stderr"); +}); From 4b8f61cbe99922764efef1b284962ef6bdf4ce49 Mon Sep 17 00:00:00 2001 From: Onyeka Nwamba Date: Tue, 26 May 2026 02:21:37 +0100 Subject: [PATCH 7/7] fix: tighten role template and repo map validation --- docs/quick-start.md | 4 ++-- src/agent-role-template.ts | 9 ++++---- src/cli/agent-management.ts | 10 ++++----- src/cli/agent-runner.ts | 8 ++++++- src/cli/doctor.ts | 9 ++++++-- src/cli/setup-files.ts | 7 +++++++ src/cli/setup-wizard.ts | 3 --- src/repo-map.ts | 2 +- test/agent-management.test.ts | 24 +++++++++++++++------ test/agent-role-template.test.ts | 12 +++++++++++ test/agent-runner.test.ts | 36 ++++++++++++++++++++++++++++++++ test/repo-map.test.ts | 13 ++++++++++++ test/setup-files.test.ts | 4 ++++ 13 files changed, 116 insertions(+), 25 deletions(-) diff --git a/docs/quick-start.md b/docs/quick-start.md index 182f5d3..45632d0 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -127,8 +127,8 @@ repository and passes a compact role brief to the runner. The brief points the agent at relevant repo areas and validation expectations, while AgentRail still owns lifecycle actions such as PR creation, CI watching, review handling, and shipping. If a repo map is missing or incomplete, AgentRail still runs the agent -and includes a warning in the run context. Run `agentrail doctor` to check -repo-map coverage. +and includes a warning in the run context. Run `agentrail doctor --agent-id ` +to check repo-map coverage. Use this later to change permissions, routing, or repo allowlists: diff --git a/src/agent-role-template.ts b/src/agent-role-template.ts index b0ea7da..073a2d8 100644 --- a/src/agent-role-template.ts +++ b/src/agent-role-template.ts @@ -155,12 +155,13 @@ export async function resolveAgentRoleTemplate({ homePath: string; id: string; }): Promise { + const customTemplates = await loadInstalledCustomAgentRoleTemplates(homePath); + const custom = customTemplates.find((entry) => entry.template.id === id) ?? null; const builtIn = await getBuiltInAgentRoleTemplate(id); - if (builtIn) { - return builtIn; + if (custom && builtIn) { + throw new Error(`Agent role template id ${id} exists as both a built-in template and an installed custom template at ${custom.sourcePath}. Rename or remove the custom template to avoid ambiguous routing.`); } - const customTemplates = await loadInstalledCustomAgentRoleTemplates(homePath); - return customTemplates.find((entry) => entry.template.id === id) ?? null; + return custom ?? builtIn ?? null; } export async function installCustomAgentRoleTemplate({ diff --git a/src/cli/agent-management.ts b/src/cli/agent-management.ts index 19f666f..a473dd0 100644 --- a/src/cli/agent-management.ts +++ b/src/cli/agent-management.ts @@ -303,9 +303,9 @@ const RUNNER_DEFINITIONS: RunnerDefinition[] = [ { value: "cursor", label: "Cursor", - description: "Opens this repo in Cursor for agent-assisted work.", - signInHint: "Make sure you are already signed in to Cursor on this machine.", - executable: "cursor", + description: "Runs work through the Cursor Agent CLI.", + signInHint: "Make sure you are already signed in to Cursor Agent on this machine.", + executable: "cursor-agent", }, { value: "devin", @@ -604,6 +604,7 @@ export async function runAgentCreate(argv: string[], options: RunAgentCommandOpt "--base-url", inputs.baseUrl, "--api-key", envValues.AGENTRAIL_API_KEY, "--agent-id", inputs.agentId, + "--runner", inputs.runner, "--setup-api-key", inputs.setupApiKey, ]; if (inputs.repoAllowlist[0]) { @@ -1820,7 +1821,6 @@ function agentModelChoices(runner: string): PromptChoice[] { { value: "gpt-5.4", label: "GPT-5.4", hint: "Strong default choice for coding work." }, { value: "gpt-5.4-mini", label: "GPT-5.4 mini", hint: "Lower-cost Codex model." }, { value: "gpt-5.3-codex", label: "GPT-5.3 Codex", hint: "Coding-optimized Codex model." }, - { value: "gpt-5.3-codex-spark", label: "GPT-5.3 Codex Spark", hint: "Fastest Codex coding model." }, ]; } if (runner === "claude-code") { @@ -1829,10 +1829,8 @@ function agentModelChoices(runner: string): PromptChoice[] { { value: "opus", label: "Opus (latest)", hint: "Higher-capability Claude Code model alias." }, { value: "haiku", label: "Haiku (latest)", hint: "Faster Claude Code model alias." }, { value: "claude-opus-4-7", label: "Claude Opus 4.7", hint: "Current highest-capability Claude Code model." }, - { value: "claude-opus-4-6", label: "Claude Opus 4.6", hint: "Pinned Opus model name." }, { value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5", hint: "Pinned Opus model name." }, { value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", hint: "Pinned Sonnet model name." }, - { value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5", hint: "Pinned Sonnet model name." }, { value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5", hint: "Pinned Haiku model name." }, ]; } diff --git a/src/cli/agent-runner.ts b/src/cli/agent-runner.ts index 0560aa8..5ef8af7 100644 --- a/src/cli/agent-runner.ts +++ b/src/cli/agent-runner.ts @@ -3393,7 +3393,13 @@ function normalizeOptionalModel(value: string | null | undefined): string | null function normalizeOptionalRoleTemplateId(value: string | null | undefined): string | null { const trimmed = typeof value === "string" ? value.trim() : ""; - return trimmed.length > 0 && ROLE_TEMPLATE_ID_PATTERN.test(trimmed) ? trimmed : null; + if (trimmed.length === 0) { + return null; + } + if (!ROLE_TEMPLATE_ID_PATTERN.test(trimmed)) { + throw new Error("AGENTRAIL_AGENT_ROLE_TEMPLATE_ID must use lowercase kebab-case."); + } + return trimmed; } function parsePositiveInteger(value: string, flag: string): number { diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 09176b1..95e18bf 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -31,6 +31,7 @@ export interface DoctorFlags { apiKey?: string; agentId?: string; repo?: string; + runner?: string; setupApiKey?: string; configPath?: string; envFile?: string; @@ -176,6 +177,9 @@ export function parseDoctorArgs(argv: string[]): DoctorFlags { case "--repo": flags.repo = nextValue(argv, ++index, arg); break; + case "--runner": + flags.runner = nextValue(argv, ++index, arg); + break; case "--setup-api-key": flags.setupApiKey = nextValue(argv, ++index, arg); break; @@ -206,7 +210,7 @@ export async function runDoctor( stdout.write( [ "Usage:", - " agentrail doctor --agent-id [--base-url ] [--api-key ] [--repo ] [--setup-api-key ] [--env-file ] [--skip-routing-check]", + " agentrail doctor --agent-id [--base-url ] [--api-key ] [--repo ] [--runner ] [--setup-api-key ] [--env-file ] [--skip-routing-check]", "", ].join("\n"), ); @@ -288,7 +292,8 @@ async function resolveDoctorInputs({ ?? process.env.AGENTRAIL_REPO_ALLOWLIST ?? envFileValues.AGENTRAIL_REPO_ALLOWLIST, ); - const agentRunner = process.env.AGENTRAIL_AGENT_RUNNER + const agentRunner = flags.runner + ?? process.env.AGENTRAIL_AGENT_RUNNER ?? envFileValues.AGENTRAIL_AGENT_RUNNER ?? "codex"; const expectedRepo = repoAllowlist[0] ?? primaryRepoFromConfig(config)?.slug ?? null; diff --git a/src/cli/setup-files.ts b/src/cli/setup-files.ts index acd348e..ecb82e0 100644 --- a/src/cli/setup-files.ts +++ b/src/cli/setup-files.ts @@ -354,6 +354,13 @@ function renderPortableInitCommand(config: SetupConfig): string { parts.push(`--routing-confidence-threshold ${config.routing.classifier.confidenceThreshold}`); parts.push(`--routing-no-suitable-agent ${config.routing.classifier.fallbackBehavior.replace(/_/gu, "-")}`); } + parts.push(`--repo-map-mode ${config.repoMaps.generationMode.replace(/_/gu, "-")}`); + if (config.repoMaps.generationMode === "ai_assist") { + parts.push(`--repo-map-profiler-runner ${config.repoMaps.profiler.runner}`); + if (config.repoMaps.profiler.model) { + parts.push(`--repo-map-profiler-model ${config.repoMaps.profiler.model}`); + } + } return parts.join(" "); } diff --git a/src/cli/setup-wizard.ts b/src/cli/setup-wizard.ts index 0a2182c..83af4c5 100644 --- a/src/cli/setup-wizard.ts +++ b/src/cli/setup-wizard.ts @@ -362,7 +362,6 @@ function classifierModelChoices(runner: string): Array<{ value: string; label: s { value: "gpt-5.4", label: "GPT-5.4", hint: "Strong default choice for routing." }, { value: "gpt-5.4-mini", label: "GPT-5.4 mini", hint: "Lower-cost routing model." }, { value: "gpt-5.3-codex", label: "GPT-5.3 Codex", hint: "Coding-optimized Codex model." }, - { value: "gpt-5.3-codex-spark", label: "GPT-5.3 Codex Spark", hint: "Fastest Codex routing model." }, ]; } if (runner === "claude-code") { @@ -371,10 +370,8 @@ function classifierModelChoices(runner: string): Array<{ value: string; label: s { value: "opus", label: "Opus (latest)", hint: "Higher-capability Claude Code model alias." }, { value: "haiku", label: "Haiku (latest)", hint: "Faster Claude Code model alias." }, { value: "claude-opus-4-7", label: "Claude Opus 4.7", hint: "Current highest-capability Claude Code model." }, - { value: "claude-opus-4-6", label: "Claude Opus 4.6", hint: "Pinned Opus model name." }, { value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5", hint: "Pinned Opus model name." }, { value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", hint: "Pinned Sonnet model name." }, - { value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5", hint: "Pinned Sonnet model name." }, { value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5", hint: "Pinned Haiku model name." }, ]; } diff --git a/src/repo-map.ts b/src/repo-map.ts index 3865286..0a70b0c 100644 --- a/src/repo-map.ts +++ b/src/repo-map.ts @@ -356,7 +356,7 @@ function validateRelativeRepoPath(candidate: string, sourcePath: string, fieldPa if (candidate.includes("\\")) { throw new Error(`Repo map ${sourcePath} field ${fieldPath} contains path ${candidate}. Use forward slashes.`); } - if (path.isAbsolute(candidate) || /^[A-Za-z]:[\\/]/u.test(candidate) || candidate.startsWith("~")) { + if (path.isAbsolute(candidate) || /^[A-Za-z]:/u.test(candidate) || candidate.startsWith("~")) { throw new Error(`Repo map ${sourcePath} field ${fieldPath} contains absolute path ${candidate}. Use repo-relative paths.`); } const segments = candidate.split("/"); diff --git a/test/agent-management.test.ts b/test/agent-management.test.ts index 4254678..48cc076 100644 --- a/test/agent-management.test.ts +++ b/test/agent-management.test.ts @@ -808,6 +808,7 @@ test("agent create interactive uses runner-specific model choices", async (t) => const previousHome = process.env.AGENTRAIL_HOME; process.env.AGENTRAIL_HOME = homePath; await installFakeExecutableOnPath(t, homePath, "cursor"); + await installFakeExecutableOnPath(t, homePath, "cursor-agent"); const prompt = new ScriptedPromptSession([ { kind: "select", value: "cursor" }, { kind: "select", value: "sonnet-4-thinking" }, @@ -828,7 +829,9 @@ test("agent create interactive uses runner-specific model choices", async (t) => await harness.close(); }); - await writeSetupRepo(repoRoot, homePath, harness.baseUrl, harness.operatorApiKey); + await writeSetupRepo(repoRoot, homePath, harness.baseUrl, harness.operatorApiKey, { + runnerPolicy: { preset: "advisory" }, + }); const exitCode = await runCli([ "agent", @@ -865,7 +868,7 @@ test("agent create interactive offers documented Claude Code pinned model choice await installFakeExecutableOnPath(t, homePath, "claude"); const prompt = new ScriptedPromptSession([ { kind: "select", value: "claude-code" }, - { kind: "select", value: "claude-sonnet-4-5-20250929" }, + { kind: "select", value: "claude-sonnet-4-6" }, { kind: "input", value: "Backend Claude" }, { kind: "select", value: "read_write_ship" }, { kind: "select", value: "backend-api" }, @@ -904,9 +907,9 @@ test("agent create interactive offers documented Claude Code pinned model choice assert.ok(createdMatch); const env = parseEnv(await readFile(path.join(homePath, "agents", `${createdMatch[1]}.env`), "utf8")); assert.equal(env.AGENTRAIL_AGENT_RUNNER, "claude-code"); - assert.equal(env.AGENTRAIL_AGENT_MODEL, "claude-sonnet-4-5-20250929"); + assert.equal(env.AGENTRAIL_AGENT_MODEL, "claude-sonnet-4-6"); assert.ok(prompt.selectMessages.includes("Claude Code model")); - assert.match(prompt.notes.map((note) => note.body).join("\n"), /Runner model: claude-sonnet-4-5-20250929/); + assert.match(prompt.notes.map((note) => note.body).join("\n"), /Runner model: claude-sonnet-4-6/); }); test("agent update rotates scopes, refreshes env files, and updates managed routing", async (t) => { @@ -1201,7 +1204,7 @@ test("agent update clears previous model when runner changes without a new model const previousHome = process.env.AGENTRAIL_HOME; process.env.AGENTRAIL_HOME = homePath; await installFakeExecutableOnPath(t, homePath, "codex"); - await installFakeExecutableOnPath(t, homePath, "claude-code"); + await installFakeExecutableOnPath(t, homePath, "claude"); t.after(async () => { await rm(repoRoot, { recursive: true, force: true }); @@ -1683,7 +1686,15 @@ async function bootstrapOperator(baseUrl: string): Promise { return json.data.apiKey as string; } -async function writeSetupRepo(repoRoot: string, homePath: string, baseUrl: string, operatorApiKey?: string): Promise { +async function writeSetupRepo( + repoRoot: string, + homePath: string, + baseUrl: string, + operatorApiKey?: string, + options: { + runnerPolicy?: Parameters[0]["runnerPolicy"]; + } = {}, +): Promise { const config = createSetupConfig({ cwd: repoRoot, detectedRepo: { @@ -1696,6 +1707,7 @@ async function writeSetupRepo(repoRoot: string, homePath: string, baseUrl: strin acceptedDefaults: true, baseUrl, providerMode: "disabled", + runnerPolicy: options.runnerPolicy, }); await writeSetupFiles({ homePath, diff --git a/test/agent-role-template.test.ts b/test/agent-role-template.test.ts index f620764..39d20ed 100644 --- a/test/agent-role-template.test.ts +++ b/test/agent-role-template.test.ts @@ -199,6 +199,18 @@ test("resolves built-in templates before installed custom templates", async (t) assert.equal(missing, null); }); +test("resolving a legacy custom template with a built-in id reports a collision", async (t) => { + const homePath = makeTemplateDir(t); + const templateDir = path.join(homePath, "agent-role-templates"); + fs.mkdirSync(templateDir, { recursive: true }); + fs.writeFileSync(path.join(templateDir, "frontend-ui.yaml"), templateYaml("frontend-ui"), "utf8"); + + await assert.rejects( + () => resolveAgentRoleTemplate({ homePath, id: "frontend-ui" }), + /exists as both a built-in template and an installed custom template/u, + ); +}); + test("installs a custom template and rejects built-in id collisions", async (t) => { const homePath = makeTemplateDir(t); const sourceDir = makeTemplateDir(t); diff --git a/test/agent-runner.test.ts b/test/agent-runner.test.ts index ff95789..4b471f3 100644 --- a/test/agent-runner.test.ts +++ b/test/agent-runner.test.ts @@ -115,6 +115,42 @@ test("agent run falls back to legacy current agent env", async (t) => { assert.match(stderr.toString(), /requires AGENTRAIL_BASE_URL/u); }); +test("agent run rejects malformed role template ids from env files", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-run-role-template-")); + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const envPath = path.join(repoRoot, "agent.env"); + const stdout = createMemoryWriter(); + const stderr = createMemoryWriter(); + const previousHome = process.env.AGENTRAIL_HOME; + process.env.AGENTRAIL_HOME = homePath; + + t.after(async () => { + await rm(repoRoot, { recursive: true, force: true }); + await rm(homePath, { recursive: true, force: true }); + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + }); + + await writeFile(envPath, [ + "AGENTRAIL_BASE_URL=http://127.0.0.1:9", + "AGENTRAIL_API_KEY=ar_live_runner", + "AGENTRAIL_AGENT_ID=agt_runner", + "AGENTRAIL_AGENT_ROLE_TEMPLATE_ID=Frontend UI", + "", + ].join("\n"), { mode: 0o600 }); + + const exitCode = await runCli(["agent", "run", "--once", "--env-file", envPath], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout, + stderr, + }); + + assert.equal(exitCode, 1); + assert.match(stderr.toString(), /AGENTRAIL_AGENT_ROLE_TEMPLATE_ID must use lowercase kebab-case/u); +}); + test("agent run usage shows both accepted identity inputs", async () => { const stdout = createMemoryWriter(); const stderr = createMemoryWriter(); diff --git a/test/repo-map.test.ts b/test/repo-map.test.ts index 0a60bc6..6676b58 100644 --- a/test/repo-map.test.ts +++ b/test/repo-map.test.ts @@ -65,6 +65,19 @@ commands: {} /escapes the repo/u, ); + assert.throws( + () => parseRepoMapYaml(`schemaVersion: ${AGENTRAIL_REPO_MAP_SCHEMA_VERSION} +areas: + frontend_source: + paths: + - C:src +ignoredPaths: [] +generatedPaths: [] +commands: {} +`), + /absolute path C:src/u, + ); + assert.throws( () => parseRepoMapYaml(`schemaVersion: ${AGENTRAIL_REPO_MAP_SCHEMA_VERSION} areas: diff --git a/test/setup-files.test.ts b/test/setup-files.test.ts index f7980cc..fb4f12f 100644 --- a/test/setup-files.test.ts +++ b/test/setup-files.test.ts @@ -95,6 +95,7 @@ test("writeSetupFiles creates local setup files without agent secrets", async (t assert.match(readme, /agentrail agent run --once --agent-id /i); assert.match(readme, /setup is only complete when `\/tasks\/mine\?status=in_progress&limit=1` returns assigned work/i); assert.match(readme, /agents\/\.env/i); + assert.match(readme, /--repo-map-mode deterministic/); assert.match(readme, /First connected repo/i); assert.match(readme, /paste the token into a hidden prompt/i); assert.match(readme, /provider connect github/i); @@ -203,6 +204,9 @@ test("writeSetupFiles writes an accepted local AI repo-map proposal", async (t) assert.match(readme, /Repo maps can optionally be improved with a local AI runner/i); assert.match(readme, /codex/i); assert.match(readme, /gpt-5\.4-mini/i); + assert.match(readme, /--repo-map-mode ai-assist/); + assert.match(readme, /--repo-map-profiler-runner codex/); + assert.match(readme, /--repo-map-profiler-model gpt-5\.4-mini/); }); test("writeSetupFiles falls back to the starter map when an AI proposal is rejected", async (t) => {