diff --git a/docs/api.md b/docs/api.md index 3d5e8d46..14163453 100644 --- a/docs/api.md +++ b/docs/api.md @@ -122,7 +122,7 @@ The guild screen's API ([the guild](world.md), [spec](superpowers/specs/2026-10- While the settings document turns the game layer off (`ui.game: false`, [turning the game off](world.md#turning-the-game-off)), the three routes below answer 404 with `{"detail": "The game layer is off: ui.game is false in the settings document."}`, so a client can tell it from a job that does not exist. The review routes' deprecated aliases under `/world/quests/{job_id}/…` are review, not the game, and answer either way. -- `GET /api/v1/world` (read): `characters` (with `state`: idle, travelling, working, waiting, returning, defeated, recalled or following; the `job_id` it is drawn at; `stats`; `idle_too_long`; `can_quest` and `quest_template`), `parties` (jobs out now and back in the last three minutes: `leader`, `party`, `working`, `lead_id`, `stuck`, `failure`; a defeated one also its `error_code` and `error_message`, the message as the job stored it, at most 2,000 characters; a waiting one held by a spent account its `account_wait`: `provider`, `reason`, `until`, `model`, `agent`, `chosen`), `quests` (the board: each proposal with `level` 1-126, `verb`, `creature`, `leader`; a proposal judged [slop](glossary.md#slop) is a `slop` lead: verb `Clean up`, creature `slop_ooze`, leader `slopmaster`), `dungeons` (one [Slop Dungeon](glossary.md#slop-dungeon) per repository and area with open slop or recent cleanups: `repo`, `area`, `score` 0-100, `open`, `cleaned` in the last 30 days, and `meter`, its [Slop-O-Meter](glossary.md#slop-o-meter) reading `{score, level, name, provisional, computed_at}` or `null`; an area at Spaghetti Bolognese or worse has one even with no open slop; worst first), `inbox` (finished quests a person sent, or that brought back a pull request, not yet judged, each with its `failure` and `error_code`), `loot`, `recent`, `regions`, `frontier`. +- `GET /api/v1/world` (read): `characters` (with `state`: idle, travelling, working, waiting, returning, defeated, recalled or following; the `job_id` it is drawn at; `stats`; `idle_too_long`; `station`, `can_quest` and `quest_template`, from its agent row's `station` and `quest_template`, where `quest_template` is `null` for a task that names no template), `parties` (jobs out now and back in the last three minutes: `leader`, `party`, `working`, `lead_id`, `stuck`, `failure`; a defeated one also its `error_code` and `error_message`, the message as the job stored it, at most 2,000 characters; a waiting one held by a spent account its `account_wait`: `provider`, `reason`, `until`, `model`, `agent`, `chosen`), `quests` (the board: each proposal with `level` 1-126, `verb`, `creature`, `leader`; a proposal judged [slop](glossary.md#slop) is a `slop` lead: verb `Clean up`, creature `slop_ooze`, leader `slopmaster`), `dungeons` (one [Slop Dungeon](glossary.md#slop-dungeon) per repository and area with open slop or recent cleanups: `repo`, `area`, `score` 0-100, `open`, `cleaned` in the last 30 days, and `meter`, its [Slop-O-Meter](glossary.md#slop-o-meter) reading `{score, level, name, provisional, computed_at}` or `null`; an area at Spaghetti Bolognese or worse has one even with no open slop; worst first), `inbox` (finished quests a person sent, or that brought back a pull request, not yet judged, each with its `failure` and `error_code`), `loot`, `recent`, `regions`, `frontier`. - `GET /api/v1/lead-kinds` (read, any signed-in caller, the game on or off): `{"kinds": [...]}`, every kind of work on offer from the one lead-kind catalogue in `embry0/services/lead_kinds.py`, in its order, each with its plain names: `id`, `label` (one, as a badge says it), `plural` (several, as a filter says them), `about` (what it is) and `found_by` (what brings it in), and `lead`, true for a kind a finder, the scout or a knowledge map stores as a lead (`GET /api/v1/leads`). The dashboard names each kind from it (the quest board, the lead dialog, the map and the Decisions page's kind filter), and falls back to the kind's id when it is not served. The game's words for a kind (its verb, creature, leader and level) are not served here: each quest of `GET /world` carries its own. It changes only with a release: `Cache-Control: private, max-age=300`. - `GET /api/v1/world/characters/{agent_type}` (read): the character, its `stats` and its `quest_log` (each job it ran in: outcome, pull request, its share of cost and time, `rating`, `verdict`). - `GET /api/v1/world/quests/{job_id}` (read): the scroll: `report`, `failure`, `error_code`, `error_message`, `pr` (files, additions, deletions, from the forge when it answers in time), `merge_approval`, `verification`, `party`, `fallbacks` (the runs that ran on a [provider fallback](providers.md#provider-fallback) model: `agent_type`, `model`, `instead_of`, `provider`, `reason`, `until`, `runs`), `account_wait` (what a paused quest waits for, as on its party; `null` otherwise), `work_nature` (the slopometer's verdict on the job: `nature` bug, slop or noise, `slop_score`, `kinds`, `evidence`, and a Slopmaster cleanup's `cleanup` `{added, removed}`; `null` when unclassified), `xp` per character and skill (a slop cleanup's pull request is the Slopmaster's, and its merge adds `cleaned` experience), `cost_usd`, `seconds`, `evaluation`. A `failure` anywhere is the reason in words; an account failure (a spent usage window, a rate limit, a refused token) quotes the provider's own words when the job kept them (`the LLM account it ran on refused it: weekly limit · resets ...`). @@ -321,6 +321,15 @@ Agent definitions: the model, tools, skills and prompt each agent type runs with The roster, the palette and the builtins' own values are in [`docs/contracts/identity.json`](contracts/identity.json), written from `embry0/agents/identity.py`; an id outside them is a 422 that lists the allowed ones. These columns are not nullable, so a `PUT` that sends one as `null` is a 422. `POST /agents/{type}/reset` restores a builtin's identity with the rest of the row. +**In the guild.** Two columns say how the [guild](world.md) treats the agent (#167), which `POST` accepts, `PUT` changes, both reads return, and the MCP tools `agent_create` and `agent_update` take; the agent form edits them under *Guild*: + +| Field | Default | Rule | +|---|---|---| +| `station` | `desk` | Where the guild hall draws it when it is home: one of the `stations` in [`docs/contracts/identity.json`](contracts/identity.json) (`desk`, `anvil`, `lectern`, `alchemy_table`, `map_table`); not nullable | +| `quest_template` | `null` | The task the guild can give it: `null` gives none (it works only inside another job), `""` a task that names no template (triage picks the route; only a coding agent's, `developer` or `slopmaster`, since that route's coding step is theirs), a name a task that runs that template; at most 100 characters | + +A value outside these is a 422 naming the field, and so is a `quest_template` that names no pipeline template (`quest_template: no pipeline template named '...'`), or `""` for an agent that does not code. Only a value the write changes is checked: the form sends every field, and a stored task whose template was deleted since does not stop the agent's other fields from saving. A task whose template is deleted is no task: the guild stops offering it; renaming the template moves every task that names it, in the same transaction. The reads return `coding_agent`, read-only: whether the developer node runs the agent, so it can take a task that names no template. `PUT` with `station: null` is a 422; `quest_template: null` takes the task away, which MCP `agent_update` sends as `clear_quest_template=true`. Each builtin's values are its seed's, the same the guild drew by name before (`POST /agents/{type}/reset` puts them back): the developer at the anvil with a task naming no template, the reviewers and the interrogator at lecterns, QA and kintsugi at the alchemy table, research, analysis, ops, the area mapper and blast-radius at the map table, and every specialist, research, analysis, ops and the Slopmaster with a task that runs its own template. + **Definitions no job runs.** Each definition carries `runs_in_jobs`, read-only: false only for the brief writer's voices, `proposal-brief` and `job-brief` ([briefs](configuration.md#proposal-and-job-briefs)). Such a definition is no template step, no per-agent model of a job, and no character of the guild; its model is not read. `PUT` refuses its `system_prompt` with 422 when it defines one of the answer's keys (a quoted key and a colon, such as `"title": ...`): the format is embry0's. A voice may name JSON or quote a word. **Tools at run time.** A definition whose `tools` is empty has no tools: the CLI offers it none and the hook allows none. Only a definition that names no tools at all (null, which the API never writes) runs with its agent type's default tools. A template step's `tools`, which an operator wrote, replaces the definition's. Triage's `pipeline_config.agent_tools` only narrows that list: a tool it lacks is not added and is logged as `triage_tools_not_granted`, and a list triage empties, or a value that is not a list, keeps only the read-only tools (`Read`, `Glob`, `Grep`) the list had, which may be none (`agent_tools_emptied`, `triage_tools_malformed`). `POST` and `PUT` answer an agent that jobs run with an empty `tools` with a warning in `warnings`: it runs with no tools and finishes with text only. Triage's `pipeline_config.system_prompts` is ignored and logged as `triage_system_prompt_ignored`, with the agent's name only. @@ -483,7 +492,25 @@ The saved pipeline graphs the editor and the Pipelines page work on, under `/api | Endpoint | Answer | |---|---| | `PUT /api/v1/pipelines/templates/{id}` | Updates the fields sent. `default_for_kind` set to a kind makes the template that kind's default route; an explicit `null` clears it. 409 when another template already holds the kind (clear it first), or when the new name is taken | -| `POST /api/v1/pipelines/templates/{id}/duplicate` | `{"name": "..."}` copies the graph and sandbox profile under the new name and answers 201. 409 when a template of that name already exists | +| `POST /api/v1/pipelines/templates/{id}/duplicate` | `{"name": "..."}` copies the graph, the sandbox profile and what the row says of its jobs (below) under the new name and answers 201; the copy is no kind's default. 409 when a template of that name already exists | + +**What a template's row says of its jobs.** Five columns (#167) decide how embry0 treats a job of the template, where it once went by the built-in templates' names. `POST` accepts them, `PUT` changes the ones sent, and every read, the list included, returns them; on the Pipelines page a template's menu edits them under *Behaviour…*: + +| Field | Default | What it does | +|---|---|---| +| `fixed_route` | `false` | A job that names the template skips triage: its route is fixed, and triage's classifier is not asked. Triage still runs when the job comes back to it with something to decide (a QA failure, a person's answers) | +| `offered_to_triage` | `true` | Triage's list of templates names it, and plan_route keeps triage's pick of it. `false` runs it only when a job names it: triage's pick of it is dropped and the kind's default routes | +| `job_kind` | `null` | An override of the kind (`code`, `research`, `analysis`, `ops`) a job of it is recorded as when it is no kind's default; `default_for_kind` wins. `null` reads the route as it is: a route whose every step but the output is a specialist is `analysis`, any other leaves triage's kind | +| `delivers` | `""` | What a job of it hands back, in the person's words, one line of at most 300 characters: the job's [brief](configuration.md#proposal-and-job-briefs) says it. `""` says what the kind hands back | +| `leader_agent` | `null` | The agent a job of it is drawn as in the guild before one starts. Must name an agent that runs in jobs. A job led by the context auditor is an audit | + +A malformed value is a 422 naming the field; a `leader_agent` that names no agent a job runs is `leader_agent: no agent named '...' runs in jobs`, checked only when the write changes it. A template may not take the name of a pipeline the code defines (`qa`, `onboard`, `pr-review`), on create, rename or duplicate: a 422 naming `name`. + +**Credit is recorded on the job.** A job that names a template takes its row's leader when it is created (`jobs.leader_agent`), and when plan_route plans the job it records that leader again with the agent its route's coding step runs as (`jobs.coding_agent`). Progression and the world credit the job from those: its pull request to the coder (the developer when it recorded none), a cleanup to the Slopmaster when it coded the job, so an edit or a deletion of a template row later changes no credit a job already earned. Migration 143 gave every older job what the template names gave it. + +**Which jobs are context audits** is one rule (`embry0/storage/repositories/audit_jobs.py`): a job the context auditor led, and not a finding's fix job. Progression credits it as an audit and the findings feed offers its findings, so a copy of the context-audit template is an audit to both, and a context-audit job planned after an operator gave the template another leader is neither. The context audit's scheduler counts every such audit for its cadence, and also every job of its own template and every job it dispatched, whatever led them, so it never starts an audit again on every tick; the job it starts is always its own template's. + +**When the templates cannot be read** (the database fails, not when there is none), nothing a row would allow is assumed: no route is fixed, so triage runs; triage is offered no template, and its pick of one is dropped for the kind's default; the guild offers no task. Only with no database at all do the built-in rows as seeded answer. On `PUT`, an explicit `null` clears `job_kind` and `leader_agent` and is a 422 for the other three. A template with `fixed_route` and a `qa` step is saved with a warning: only triage asks for QA, so that step never runs. The built-in templates' values reproduce what their names did: the ten specialist templates are fixed, not offered and analysis, each led by its specialist; `research-default`, `analysis-default` and `ops-default` are led by their agents, and `slop-cleanup` by the Slopmaster; `analysis-default`, `context-audit` and `map-areas` say what they deliver. The boot seed writes them on a template's first insert only, like `default_for_kind`, so what an operator changes stays across restarts. A job of a pipeline the code defines (`qa`, `onboard`, `pr-review`) has no row; what it delivers and who leads it is the code's (`embry0/workflows/row_behaviour.py`). **A step that leaves its agent without a server.** A step's `tools` replace its agent's, and an MCP server that runs a command reaches only an agent whose tools name `Bash` or `Monitor` ([Agents](#agents), "MCP servers"). So `POST` and `PUT /api/v1/pipelines/templates`, when they save a graph, answer in `warnings` each step whose `tools` name neither while its agent has such a server: a job runs that step without it. A QA step's warning names the default browser backend's server, since a repository picks its own; with any backend the step runs without its browser. The template editor shows the warnings when it saves. diff --git a/docs/architecture.md b/docs/architecture.md index 44b477d3..4c751cce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1389,6 +1389,7 @@ The whole pass is serialised across processes by a session-level Postgres adviso | 140 | the API key that created a credential: `credentials.created_by_key_id`, so a credential nothing uses yet is the own of the key whose id it records, never of another key of the same name; NULL for one a session, the master key or the importer created, and for every credential created before it, none back-filled, so no restricted key owns one of those | `credentials.created_by_key_id` | | 141 | the repository of a job posted with a git context alone: fills `jobs.repo` from `context->>'repo'` where `repo` is NULL and the context is git, so `GET /jobs?repo=`, the world view and the stats find it (`POST /jobs` stores it now); a row naming a repository and any other context are left alone, `updated_at` is kept, and a second run changes nothing | `jobs.repo` (data) | | 142 | the skills triage may add to an agent: `agent_definitions.triage_may_add_skills`, a JSON array of `source:skill` names (NOT NULL, default `[]`); triage adds one only from a source the agent's skills already load. The built-in developer row gets `superpowers:brainstorming` and `superpowers:writing-plans`, the two the triage prompt always offered it, so an upgraded deployment keeps its behaviour; every other row, and a deployment without that row, gets none | `agent_definitions.triage_may_add_skills` | +| 143 | behaviour from agent and template rows, not built-in names (#167): `pipeline_templates.fixed_route` (NOT NULL, default false), `offered_to_triage` (NOT NULL, default true), `job_kind` (an override; NULL reads the route), `delivers` (NOT NULL, default `''`) and `leader_agent`; `agent_definitions.station` (NOT NULL, default `desk`) and `quest_template` (NULL none, `''` a task naming no template); `jobs.coding_agent` and `jobs.leader_agent`, which plan_route records and progression credits from. Every row gets what the code said of its name before: the ten specialist templates fixed and not offered, the leaders and deliverables of the old maps, the old stations and guild tasks; a slop-cleanup job coded by the Slopmaster and every job led by its template's old leader. A template named `qa`, `onboard` or `pr-review` is renamed `-template` | `pipeline_templates`, `agent_definitions`, `jobs` | Note: LangGraph's checkpoint tables (`checkpoints`, `checkpoint_blobs`, `checkpoint_writes`) are created and mutated by `langgraph-checkpoint-postgres` / `orchestration/checkpoint.py`, outside `embry0_migrations` entirely — they're excluded from this ledger by design, not an oversight. diff --git a/docs/configuration.md b/docs/configuration.md index 1812333a..99b0ac62 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -280,6 +280,8 @@ Budget controls, context injection, and sandbox profiles are configurable via th - **Context audit**: `PATCH /api/v1/config/settings/context_audit?owner={owner}` with `{"days": N}` (1 to 365, `false` for off) audits the files that steer a coding agent in each of the owner's registered repositories on a schedule, and `GET /api/v1/orgs/{owner}/context-audit` reads what it sees. The interval, whether an audit opens a pull request and the files it reads are the `context_audit` section's, in the organization's or a repository's layer for their own; its two caps are the `budget` section's `context_audit_job_usd` and `context_audit_max_jobs_per_tick`, in the organization's layer for its own. See [context-audit.md](context-audit.md). +- **Agent and template behaviour** (#167): what embry0 does with an agent or a pipeline template is a column of its row, not a list of the built-in ones' names, so an operator's own take part the same way. A template's `fixed_route` (a job that names it skips triage), `offered_to_triage`, `job_kind`, `delivers` (what its jobs' briefs say they hand back) and `leader_agent` (who the guild draws its jobs as and credits their work to): `POST`/`PUT /api/v1/pipelines/templates` and *Behaviour…* in a template's menu on the Pipelines page. An agent's `station` and `quest_template` (the task the guild can give it): `POST`/`PUT /api/v1/agents`, the MCP tools `agent_create` and `agent_update`, and *Guild* on the agent form. Each value a write changes is checked, a 422 naming the field; the boot seed writes a built-in's on its first insert only, so a change survives a restart. A job records who coded it and who led its template when it is planned, and is credited from that, so editing a template later changes no past credit. The rules: [api.md](api.md#pipeline-templates) and [api.md](api.md#agents). + See [api.md](api.md) for request examples. ## Settings document diff --git a/docs/contracts/identity.json b/docs/contracts/identity.json index 48b3dbf0..beacd6a8 100644 --- a/docs/contracts/identity.json +++ b/docs/contracts/identity.json @@ -169,6 +169,7 @@ }, "default_avatar": "golem", "default_color": "slate", + "default_station": "desk", "display_name_max": 60, "palette": [ "gold", @@ -180,5 +181,12 @@ "sky", "slate" ], + "stations": [ + "desk", + "anvil", + "lectern", + "alchemy_table", + "map_table" + ], "tagline_max": 140 } diff --git a/docs/glossary.md b/docs/glossary.md index 74e6a5fe..aeeb16e9 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -226,7 +226,7 @@ A **quest** is a short goal the account completes once, for quest points and an A scanner's picture of a codebase or of an organization's systems, uploaded as one `embry0.map/1` document: its parts on a plane, the links between them, and per part a bloat score with its reasons, its use and its history. embry0 shows it on the Maps page and does not scan. People mark parts bloat, keep or not sure; a part in a repository that someone marks bloat becomes a `weed` [lead](leads.md) whose job removes it without re-assessing the decision, and any part can be sent for a fix or an analysis. Spec: `docs/superpowers/specs/2026-10-27-knowledge-maps-design.md`; see [maps.md](maps.md). (2026-10) ### Specialist agents -Ten read-only built-in agents that each do one job on a cloned repository and return one Markdown report: `kintsugi` (template `kintsugi-trace`), `gardener` (`garden`), `interrogator` (`interrogate`), `why` (`why`), `how` (`how`), `blast-radius` (`blast-radius`), `comment-sicko` (`comment-pass`), `design-auditor` (`design-audit`), `context-auditor` (`context-audit`) and `area-mapper` (`map-areas`). Like research and analysis they run on the generic agent node, so each one's behavior is its system prompt; their tools stop at Read, Glob, Grep and Bash, and they open no pull request. Each template is `triage → agent → output`, and a job reaches one only by naming it: triage is not offered them. The job is recorded as an analysis job, and the report is its deliverable. See [docs/specialist-agents.md](specialist-agents.md). (2026-10) +Ten read-only built-in agents that each do one job on a cloned repository and return one Markdown report: `kintsugi` (template `kintsugi-trace`), `gardener` (`garden`), `interrogator` (`interrogate`), `why` (`why`), `how` (`how`), `blast-radius` (`blast-radius`), `comment-sicko` (`comment-pass`), `design-auditor` (`design-audit`), `context-auditor` (`context-audit`) and `area-mapper` (`map-areas`). Like research and analysis they run on the generic agent node, so each one's behavior is its system prompt; their tools stop at Read, Glob, Grep and Bash, and they open no pull request. Each template is `triage → agent → output`, and a job reaches one only by naming it: triage is not offered them, and a job that names one skips triage. The job is recorded as an analysis job, and the report is its deliverable. Each template's row says so (`offered_to_triage`, `fixed_route`, and a route of specialists only is analysis; see [docs/api.md](api.md#pipeline-templates)), so a template of the deployment's own can do the same. See [docs/specialist-agents.md](specialist-agents.md). (2026-10) --- diff --git a/docs/specialist-agents.md b/docs/specialist-agents.md index 039c8284..990f14f7 100644 --- a/docs/specialist-agents.md +++ b/docs/specialist-agents.md @@ -150,6 +150,13 @@ something to weigh, a failed QA run or a person's answers. The job is recorded as an analysis job, and the agent's report is its deliverable. The repository must be registered. +None of that is the templates' names: each template's row says it +(`offered_to_triage` false, `fixed_route` true, and `leader_agent` its +specialist; a route of specialists only is analysis unless its `job_kind` +says otherwise; [api.md](api.md#pipeline-templates)). A +template of your own takes the same columns, so a specialist route you write +behaves like these ten, and a duplicate of one of them keeps them. + ### Dashboard On the Jobs page, click **New Job**. Choose **Git repository** as the context, diff --git a/docs/superpowers/specs/2026-11-05-behaviour-from-rows-design.md b/docs/superpowers/specs/2026-11-05-behaviour-from-rows-design.md new file mode 100644 index 00000000..ac5fb987 --- /dev/null +++ b/docs/superpowers/specs/2026-11-05-behaviour-from-rows-design.md @@ -0,0 +1,138 @@ +# Behaviour from agent and template rows, not built-in names (#167) + +Part of #159 (the settings-in-DB rule: "rows, not name lists"). + +## Problem + +Operators write pipeline templates and agent definitions as rows, but much of +what embry0 did with a template or an agent depended on the names of the +built-in ones: + +- `SPECIALIST_TEMPLATE_NAMES` decided the triage skip, triage's list of + templates and plan_route dropping triage's pick; `specialist_job_kind` + recorded an all-specialist route as analysis. +- `_TEMPLATE_DELIVERS` said what a job's brief delivers, by template name. +- The coding agents were listed twice (route_plan and salvage). +- The progression and world SQL credited a job's pull request, its cleanup + and its audit by template and agent names. +- `LEADER_BY_TEMPLATE`, `QUEST_TEMPLATE_BY_AGENT` and the frontend's + `STATION_OF` decided who leads a party, which characters the guild can + send, and where each one stands. + +An operator's own specialist template was triaged, and an agent of their own +could never be sent from the guild. + +## Design + +One module, `embry0/workflows/row_behaviour.py`, answers "what does this row +say about its behaviour", tested on its own +(`tests/unit/workflows/test_row_behaviour.py`). + +| Column | Table | Replaces | Read by | +|---|---|---|---| +| `fixed_route` | `pipeline_templates` | the name list, in `fixed_route_decision` | triage | +| `offered_to_triage` | `pipeline_templates` | the name list, in triage's template list and plan_route | triage, plan_route | +| `job_kind` | `pipeline_templates` | an override of `specialist_job_kind`, which now reads the row's graph | triage's fixed decision, plan_route | +| `delivers` | `pipeline_templates` | `_TEMPLATE_DELIVERS` | job briefs (`template_delivers` in the candidates query) | +| `leader_agent` | `pipeline_templates` | `LEADER_BY_TEMPLATE` | world; recorded on the job by plan_route | +| `coding_agent`, `leader_agent` | `jobs` | the progression `_CODER`, the audit template name (`an_audit`), the world's cleanup count | progression, world | +| `station` | `agent_definitions` | `STATION_OF` | the world's characters, the hall | +| `quest_template` | `agent_definitions` | `QUEST_TEMPLATE_BY_AGENT`, `can_quest` | the world's characters | + +- **Credit is recorded on the job.** A job that names a template takes its + row's leader when it is created (`jobs.leader_agent`); plan_route records + it again with the agent its route's coding step runs as + (`jobs.coding_agent`, one of `CODING_AGENT_TYPES`). A job's pull request is + its coder's (the developer's when it recorded none) and a slop cleanup is a + merged job the Slopmaster coded, so a later edit or deletion of a template + row changes no past credit. +- **An audit** is one predicate (`audit_jobs.an_audit`): a job the context + auditor led (the agent whose report the audit's claims are read from), not + a finding's fix job. Progression and the findings feed both read it, so a + copy of the context-audit template earns audit credit and offers its + findings, and a context-audit job led by another agent does neither. The + scheduler counts every audit for its cadence, plus its own template's jobs + and its own dispatches whatever led them, so a changed leader never makes + it start an audit on every tick; it always dispatches its own template. +- **Renames** read the old name under the row's lock (`FOR UPDATE`), so two + renames at once carry an agent's guild task to the final name. +- **The kind** is `default_for_kind`, else the `job_kind` override, else the + one the route makes it as it is now: every step but the output a + specialist is analysis. A new template of specialists only is analysis; a + specialist route given a coding step is not. +- **`quest_template`** is three-valued: NULL no task, `''` a task naming no + template (triage picks, the developer's), a name a task running that + template. A task naming a template that no longer exists is no task. +- **Pipelines the code defines** (`qa`, `onboard`, `pr-review`) have no row: + `WORKFLOW_PIPELINES` gives their leader and deliverable, once. +- **Without a database**, the built-in rows as seeded answer + (`builtin_template`, `builtin_agent`), as the graph validator already falls + back to `BUILTIN_SEED`. **When the rows cannot be read**, nothing is + known: no route is fixed (triage runs), triage is offered no template and + its pick of one is dropped, and the guild offers no task; a failed world + read is not cached. +- **Names.** A template may not take the name of a pipeline the code + defines (`qa`, `onboard`, `pr-review`), which would override it in briefs + and credit; migration 143 renames such a row `-template`. + +### Write paths and validation + +- API: `POST`/`PUT /pipelines/templates` and `POST`/`PUT /agents`; each + malformed value is a 422 naming the field, a `leader_agent` that names no + job agent, a `quest_template` that names no template and a `''` task for an + agent that does not code are 422s too, checked only when the write changes + the value (the forms send every field). On `PUT`, null clears `job_kind`, + `leader_agent` and `quest_template` and is a 422 for the NOT NULL columns. + A template rename moves every agent's `quest_template` that named it, in + the same transaction. +- MCP: `agent_create` and `agent_update` take `station` and + `quest_template`; `clear_quest_template=true` sends the one null. There is + no MCP template write tool, so templates are the API's and the dashboard's. +- Dashboard: *Guild* on the agent form; *Behaviour…* in a template's menu on + the Pipelines page. +- A fixed route with a `qa` step is saved with a warning: only triage asks + for QA, so that step never runs. +- `duplicate` copies the columns, so a copy of a specialist route behaves + like it. + +### Upgrade and the boot seed + +Migration 143 adds the columns and writes every row what the code said of +its name before, matched by name as the code matched it, built-in or not, +and every job what the old name rules credited it with (a slop-cleanup job +coded by the Slopmaster, each job led by its template's old leader). +`job_kind` stays NULL: the graph gives what the old rule did. Built-in +agents are insert-if-missing, as before. Built-in +templates are upserted at every boot: the new columns follow +`default_for_kind`, written on a first insert only, so an operator's change +survives a restart. + +A brief's input hash does not include `delivers`: the briefs already written +are not rewritten after the upgrade, and a changed `delivers` reaches the +briefs written after it. + +The progression and world queries read only the job's recorded columns, +never `pipeline_templates`, so the rollup's folded days stay true. + +## Roles: a follow-up (#395) + +The issue proposed `agent_definitions.roles` (coder, reviewer, qa). In the +code today a role would have one honest consumer: which agents run on the +developer node. The coding agents are the graph's own node vocabulary +(`FIXED_NODE_TYPES` includes `slopmaster`, and the developer node branches on +the Slopmaster), and the review and QA nodes run only `review` and `qa`. A +`coder` role that put an operator's agent on the developer node, with its +branch, push and pull-request path, is a new capability, not a refactor; a +role that only moved credit would say an agent codes when it cannot. The +reviewer and QA agents the achievements count are already the progression +pack's content (#176), which an operator edits. So this change keeps one +helper for the coding agents and leaves roles to that decision: #395. + +## Found, not changed here + +Name-keyed behaviour outside the issue's list: `generic_agent.py` gives the +watcher log tool to `analysis` and a task to `context-auditor` by name, and +`GENERIC_AGENT_TIMEOUT_SECONDS` is keyed by agent type; plan_route picks the +slop route by `SLOP_TEMPLATE`; the facts fragments `areas_mapped`, +`followup_merged` and the audit ones name the templates their features +dispatch, as does `proposals.py`. diff --git a/docs/world.md b/docs/world.md index 2d9bec0b..33c6a7fa 100644 --- a/docs/world.md +++ b/docs/world.md @@ -16,7 +16,7 @@ Every panel and dialog (a job's report, an agent's panel, a lead, New task) clos From Quests on offer: the quest's dialog says what it is, where, the evidence and what starting it runs; pick the model and key with Run with, and press Start. That is the owner proposal's yes, recorded as you. -**New task** gives one agent a task you write, on a repository you pick. The developer runs the usual pipeline; a specialist (Why, How, Kintsugi and the rest) runs its own. Triage, review and QA work only inside another job. +**New task** gives one agent a task you write, on a repository you pick. The developer runs the usual pipeline; a specialist (Why, How, Kintsugi and the rest) runs its own. Triage, review and QA work only inside another job. Which agents the guild can send, and what each task runs, is the agent's row (`quest_template`, on the agent form under *Guild*), so an agent of your own joins the list once its row names a task; where the hall draws each agent is its row's `station`. See [api.md](api.md#agents). ## Watching diff --git a/embry0/agents/identity.py b/embry0/agents/identity.py index 144c0c94..4396f305 100644 --- a/embry0/agents/identity.py +++ b/embry0/agents/identity.py @@ -1,4 +1,4 @@ -"""Agent identity vocabulary: the avatar roster and the color palette. +"""Agent identity vocabulary: the avatar roster, the color palette and the guild hall's stations. One source for the backend validators and, through docs/contracts/identity.json, for the frontend (frontend/src/lib/agentIdentity.ts). The frontend keys its sprites @@ -58,6 +58,10 @@ DEFAULT_COLOR: Final = "slate" DISPLAY_NAME_MAX: Final = 60 TAGLINE_MAX: Final = 140 +#: Where the guild hall draws an agent when it is home (#167): the objects of the world art +#: (frontend/public/world/manifest.json) a character works at. An agent's row names one. +STATIONS: Final[tuple[str, ...]] = ("desk", "anvil", "lectern", "alchemy_table", "map_table") +DEFAULT_STATION: Final = "desk" #: Identity of every builtin agent row; seeds and migration 101 write these. BUILTIN_IDENTITY: Final[dict[str, dict[str, str]]] = { @@ -186,6 +190,12 @@ def validate_color(value: str) -> str: return value +def validate_station(value: str) -> str: + if value not in STATIONS: + raise ValueError(f"station must be one of {', '.join(STATIONS)}; got {value!r}") + return value + + def contract() -> dict[str, Any]: """The identity vocabulary as docs/contracts/identity.json carries it.""" return { @@ -193,6 +203,8 @@ def contract() -> dict[str, Any]: "palette": list(PALETTE), "default_avatar": DEFAULT_AVATAR, "default_color": DEFAULT_COLOR, + "stations": list(STATIONS), + "default_station": DEFAULT_STATION, "display_name_max": DISPLAY_NAME_MAX, "tagline_max": TAGLINE_MAX, "builtin": BUILTIN_IDENTITY, diff --git a/embry0/api/schemas/__init__.py b/embry0/api/schemas/__init__.py index 28b76285..564a8d7e 100644 --- a/embry0/api/schemas/__init__.py +++ b/embry0/api/schemas/__init__.py @@ -16,6 +16,14 @@ from embry0.git_refs import REF_RULE, valid_ref from embry0.services.tool_catalog import check_agent_tools from embry0.workflows._validation import FIXED_NODE_TYPES +from embry0.workflows.row_behaviour import ( + checked_delivers, + checked_job_kind, + checked_leader_agent, + checked_quest_template, + checked_station, + template_name_problem, +) # Slash-separated alnum + . _ - : segments. Colon allowed for ISO timestamps in # filenames. No leading/trailing slash. No '..' segments. No double slashes. @@ -435,12 +443,26 @@ class AgentCreateRequest(BaseModel): # The skills triage may add to this agent, each from a source its skills # already load (docs/api.md, Agents). Catalog names, each once. triage_may_add_skills: list[Any] = Field(default_factory=list) + # Where the guild hall draws it, and the task the guild can give it: null + # none, "" one that names no template, else that template (#167). + station: Any = "desk" + quest_template: Any = None @field_validator("triage_may_add_skills") @classmethod def _triage_may_add(cls, v: list[Any]) -> list[str]: return checked_triage_may_add(v) + @field_validator("station") + @classmethod + def _station(cls, v: Any) -> str: + return checked_station(v) + + @field_validator("quest_template") + @classmethod + def _quest_template(cls, v: Any) -> str | None: + return checked_quest_template(v) + @field_validator("type") @classmethod def validate_agent_type(cls, v: str) -> str: @@ -487,12 +509,26 @@ class AgentUpdateRequest(BaseModel): tagline: str | None = Field(default=None, max_length=TAGLINE_MAX) # Not nullable: [] lets triage add none (the route refuses an explicit null). triage_may_add_skills: list[Any] | None = None + # Not nullable either (the route refuses an explicit null); quest_template's + # null takes the agent's task out of the guild (#167). + station: Any = None + quest_template: Any = None @field_validator("triage_may_add_skills") @classmethod def _triage_may_add(cls, v: list[Any] | None) -> list[str] | None: return None if v is None else checked_triage_may_add(v) + @field_validator("station") + @classmethod + def _station(cls, v: Any) -> str | None: + return None if v is None else checked_station(v) + + @field_validator("quest_template") + @classmethod + def _quest_template(cls, v: Any) -> str | None: + return checked_quest_template(v) + @field_validator("avatar") @classmethod def _avatar(cls, v: str | None) -> str | None: @@ -569,7 +605,37 @@ def _refuse_agent_models(cls, data: Any) -> Any: return data -class TemplateCreateRequest(_TemplateWriteRequest): +class _TemplateBehaviour(BaseModel): + """What a template's row says about its jobs (#167, ``embry0.workflows.row_behaviour``): each value is + checked here, so a malformed one is a 422 naming its field. Whether ``leader_agent`` names an agent is the + route's check.""" + + @field_validator("job_kind", check_fields=False) + @classmethod + def _job_kind(cls, v: Any) -> str | None: + return checked_job_kind(v) + + @field_validator("delivers", check_fields=False) + @classmethod + def _delivers(cls, v: Any) -> str | None: + return None if v is None else checked_delivers(v) + + @field_validator("leader_agent", check_fields=False) + @classmethod + def _leader_agent(cls, v: Any) -> str | None: + return checked_leader_agent(v) + + @field_validator("name", check_fields=False) + @classmethod + def _name(cls, v: Any) -> Any: + # A pipeline the code defines already owns its name (row_behaviour.WORKFLOW_PIPELINES). + problem = template_name_problem(v) if isinstance(v, str) else None + if problem: + raise ValueError(problem) + return v + + +class TemplateCreateRequest(_TemplateWriteRequest, _TemplateBehaviour): name: str = Field(..., min_length=1, max_length=100) description: str = "" graph_definition: dict[str, Any] @@ -577,9 +643,16 @@ class TemplateCreateRequest(_TemplateWriteRequest): # RAV-601: at most one template per kind is that kind's default route # (DB partial unique index enforces the at-most-one). default_for_kind: str | None = Field(default=None, pattern="^(code|research|analysis|ops)$") + # #167: a job that names it skips triage; triage is offered it; the kind its jobs are + # recorded as; what they deliver; who leads them. + fixed_route: bool = False + offered_to_triage: bool = True + job_kind: Any = None + delivers: Any = "" + leader_agent: Any = None -class TemplateUpdateRequest(_TemplateWriteRequest): +class TemplateUpdateRequest(_TemplateWriteRequest, _TemplateBehaviour): name: str | None = None description: str | None = None graph_definition: dict[str, Any] | None = None @@ -589,11 +662,26 @@ class TemplateUpdateRequest(_TemplateWriteRequest): pattern="^(code|research|analysis|ops)$", description="the kind this template is the default route for; an explicit null clears it, an absent field keeps it", ) + # #167. An absent field keeps the column; job_kind's and leader_agent's explicit null clears it, and + # delivers' "" does. fixed_route, offered_to_triage and delivers are never null (the route refuses it). + fixed_route: bool | None = None + offered_to_triage: bool | None = None + job_kind: Any = None + delivers: Any = None + leader_agent: Any = None class TemplateDuplicateRequest(BaseModel): name: str = Field(..., min_length=1, max_length=100) + @field_validator("name") + @classmethod + def _name(cls, v: str) -> str: + problem = template_name_problem(v) + if problem: + raise ValueError(problem) + return v + # --- Integration Config --- diff --git a/embry0/api/v1/agents.py b/embry0/api/v1/agents.py index 53595575..49252075 100644 --- a/embry0/api/v1/agents.py +++ b/embry0/api/v1/agents.py @@ -24,7 +24,9 @@ ensure_model_provider_ready, ) from embry0.services.tool_catalog import unknown_tools, unknown_tools_message +from embry0.services.world import forget_rows from embry0.storage.repositories.agent_definitions import AgentDefinitionsRepository +from embry0.workflows.row_behaviour import CODING_AGENT_TYPES, quest_template_problem, untemplated_task_problem # Agent definitions run in every repository's jobs: not a key with a repository allowlist's to change. router = APIRouter(dependencies=[deployment_writes("change the deployment's agents")]) @@ -78,7 +80,7 @@ async def list_agents( and the model and provider a job that names no model runs it on (``effective_model``, ``effective_model_source``, ``effective_provider``, ``effective_provider_ready``).""" - return await annotate_agents(await repo.list_all(), request.app.state) + return [_with_coding(a) for a in await annotate_agents(await repo.list_all(), request.app.state)] @router.get("/agents/{agent_type}") @@ -91,6 +93,7 @@ async def get_agent( if not agent: raise HTTPException(status_code=404, detail="Agent type not found") [annotated] = await annotate_agents([agent], request.app.state) + annotated = _with_coding(annotated) known = await catalog_skill_names(request) # A repository skill (no colon) is never in the catalog, and never an orphan. annotated["orphan_skills"] = ( @@ -147,6 +150,32 @@ def _agent_warnings(agent: dict[str, Any]) -> list[str]: return [*_no_tools_warnings(agent), *_no_shell_server_warnings(agent), *_triage_may_add_warnings(agent)] +async def _template_names(request: Request) -> set[str] | None: + """Every pipeline template's name; None when no repository is wired (minimal test apps).""" + repo = getattr(request.app.state, "templates_repo", None) + if repo is None: + return None + return {str(t["name"]) for t in await repo.list_all()} + + +async def _reject_unrunnable_task(request: Request, agent_type: str, value: str | None) -> None: + """A ``quest_template`` the guild could not run is a 422 naming the field (#167): one that names no + template (the task would run the kind default instead of what the operator chose), or '' for an agent + that does not code. Only a value the write changes is checked: a stored one whose template was deleted + since does not stop the agent's other fields from saving.""" + problem = untemplated_task_problem(agent_type, value) + if problem is None and value: + problem = quest_template_problem(value, await _template_names(request)) + if problem: + raise HTTPException(status_code=422, detail=problem) + + +def _with_coding(agent: dict[str, Any]) -> dict[str, Any]: + """The row with ``coding_agent``, read-only: whether the developer node runs it, so it can take a guild + task that names no template.""" + return {**agent, "coding_agent": str(agent.get("type") or "") in CODING_AGENT_TYPES} + + @router.post("/agents", status_code=201, dependencies=[require_admin]) async def create_agent( req: AgentCreateRequest, @@ -154,6 +183,7 @@ async def create_agent( repo: AgentDefinitionsRepository = Depends(get_agent_defs_repo), ) -> dict[str, Any]: await _reject_if_disabled(request, req.model) + await _reject_unrunnable_task(request, req.type, req.quest_template) warnings = await check_added_skills(request, req.skills or []) try: created = await repo.create( @@ -171,11 +201,14 @@ async def create_agent( color=req.color, tagline=req.tagline, triage_may_add_skills=req.triage_may_add_skills, + station=req.station, + quest_template=req.quest_template, ) except Exception as exc: if "duplicate" in str(exc).lower() or "unique" in str(exc).lower(): raise HTTPException(status_code=409, detail=f"Agent type '{req.type}' already exists") from exc raise + forget_rows(request.app.state, "agents") return {**created, "warnings": [*warnings, *_agent_warnings(created)]} @@ -199,10 +232,14 @@ async def update_agent( # `v is not None` filter made clearing impossible over the API). updates = req.model_dump(exclude_unset=True) # The identity columns are NOT NULL: unlike execution_mode, null has no meaning there. - for key in ("display_name", "avatar", "color", "tagline", "triage_may_add_skills"): + for key in ("display_name", "avatar", "color", "tagline", "triage_may_add_skills", "station"): if key in updates and updates[key] is None: raise HTTPException(status_code=422, detail=f"{key} cannot be null") await _reject_if_disabled(request, updates.get("model")) + if "quest_template" in updates: + current = await repo.get(agent_type) or {} + if updates["quest_template"] != current.get("quest_template"): + await _reject_unrunnable_task(request, agent_type, updates["quest_template"]) if updates.get("system_prompt"): # A definition no job runs is a brief writer's voice (#166): the answer's format is the code's, so a voice # that defines one of its keys would fight the format added after it. @@ -234,12 +271,14 @@ async def update_agent( raise HTTPException(status_code=404, detail="Agent type not found") return {**agent, "warnings": [*warnings, *_agent_warnings(agent)]} updated = await repo.update(agent_type, **updates) + forget_rows(request.app.state, "agents") return {**updated, "warnings": [*warnings, *_agent_warnings(updated)]} @router.delete("/agents/{agent_type}", dependencies=[require_admin]) async def delete_agent( agent_type: str, + request: Request, repo: AgentDefinitionsRepository = Depends(get_agent_defs_repo), ) -> dict[str, Any]: try: @@ -248,18 +287,22 @@ async def delete_agent( if "not found" in str(exc): raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=403, detail=str(exc)) from exc + forget_rows(request.app.state, "agents") return {"type": agent_type, "status": "deleted"} @router.post("/agents/{agent_type}/reset", dependencies=[require_admin]) async def reset_agent( agent_type: str, + request: Request, repo: AgentDefinitionsRepository = Depends(get_agent_defs_repo), ) -> dict[str, Any]: try: - return await repo.reset(agent_type) + reset = await repo.reset(agent_type) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc + forget_rows(request.app.state, "agents") + return reset @router.post("/agents/{agent_type}/skills/{skill_name}", dependencies=[require_admin]) diff --git a/embry0/api/v1/pipeline_templates.py b/embry0/api/v1/pipeline_templates.py index 69d8ad1d..a1d24b76 100644 --- a/embry0/api/v1/pipeline_templates.py +++ b/embry0/api/v1/pipeline_templates.py @@ -12,9 +12,11 @@ from embry0.api.schemas import TemplateCreateRequest, TemplateDuplicateRequest, TemplateUpdateRequest from embry0.api.v1._skill_writes import check_added_skills from embry0.services.model_enablement import UnpricedModelError, ensure_models_priced +from embry0.services.world import forget_rows from embry0.storage.repositories.pipeline_templates import PipelineTemplatesRepository from embry0.workflows._validation import validate_graph_definition from embry0.workflows.issue_to_pr.route_plan import node_config, step_run_type +from embry0.workflows.row_behaviour import leader_problem logger = structlog.get_logger(__name__) @@ -140,6 +142,30 @@ def _no_shell_step_warnings(rows: list[dict[str, Any]] | None, graph: dict[str, return warnings +#: The template fields whose explicit null is a write (it clears the column); every other field's null means +#: "unchanged", and a null for one of :data:`_NOT_NULL` is refused. +_CLEARABLE = ("default_for_kind", "job_kind", "leader_agent") +_NOT_NULL = ("fixed_route", "offered_to_triage", "delivers") + + +def _reject_unknown_leader(rows: list[dict[str, Any]] | None, leader: str | None) -> None: + """A ``leader_agent`` that names no agent a job runs is a 422 naming the field (#167).""" + problem = leader_problem(leader, _job_agent_types(rows)) + if problem: + raise HTTPException(status_code=422, detail=problem) + + +def _fixed_route_warnings(fixed: bool, graph: dict[str, Any] | None) -> list[str]: + """What a fixed route skips: a job that names it is not triaged, and only triage asks for QA, so its qa + step never runs.""" + if not fixed or not any(n.get("agent_type") == "qa" for n in (graph or {}).get("nodes") or []): + return [] + return [ + "fixed_route: a job that names this template skips triage, and only triage asks for QA, " + "so its qa step never runs" + ] + + async def _check_template_skills(request: Request, graph: dict[str, Any] | None) -> list[str]: """Check every name the graph adds; return the warnings.""" return await check_added_skills(request, _added_skills(graph)) @@ -176,8 +202,10 @@ async def create_template( if problems: raise HTTPException(status_code=422, detail="; ".join(problems)) _reject_unpriced(req.graph_definition) + _reject_unknown_leader(rows, req.leader_agent) warnings = await _check_template_skills(request, req.graph_definition) warnings += _no_shell_step_warnings(rows, req.graph_definition) + warnings += _fixed_route_warnings(req.fixed_route, req.graph_definition) try: created = await repo.create( name=req.name, @@ -185,12 +213,18 @@ async def create_template( graph_definition=req.graph_definition, sandbox_profile=req.sandbox_profile, default_for_kind=req.default_for_kind, + fixed_route=req.fixed_route, + offered_to_triage=req.offered_to_triage, + job_kind=req.job_kind, + delivers=req.delivers, + leader_agent=req.leader_agent, ) except asyncpg.UniqueViolationError as exc: # The server names the constraint it refused; the body cannot tell the two apart. if exc.constraint_name == _DEFAULT_KIND_INDEX: raise _default_kind_taken(req.default_for_kind) from None raise _template_name_taken(req.name) from None + forget_rows(request.app.state, "templates") return {**created, "warnings": warnings} @@ -202,10 +236,17 @@ async def update_template( repo: PipelineTemplatesRepository = Depends(get_templates_repo), ) -> dict[str, Any]: updates = {k: v for k, v in req.model_dump().items() if v is not None} - # Only this field's null is a write: it clears the default so another - # template can take the kind. Every other field's null means "unchanged". - if "default_for_kind" in req.model_fields_set and req.default_for_kind is None: - updates["default_for_kind"] = None + # Only these fields' null is a write: default_for_kind's clears the default + # so another template can take the kind, job_kind's and leader_agent's + # clear theirs (#167). Every other field's null means "unchanged", but the + # behaviour columns are NOT NULL: a null sent for one is refused. + sent = req.model_dump() + for key in _NOT_NULL: + if key in req.model_fields_set and sent[key] is None: + raise HTTPException(status_code=422, detail=f"{key} cannot be null") + for key in _CLEARABLE: + if key in req.model_fields_set and sent[key] is None: + updates[key] = None if not updates: template = await repo.get(template_id) if not template: @@ -227,10 +268,20 @@ async def update_template( stored = await repo.get(template_id) if repo is not None else None if stored is None: raise HTTPException(status_code=404, detail="Template not found") + # Only a leader the write changes is checked: the dialog sends every field, and a stored leader that + # no longer runs in jobs must not stop the rest from saving. + if updates.get("leader_agent") is not None and updates["leader_agent"] != stored.get("leader_agent"): + rows = rows if rows is not None else await _agent_rows(request) + _reject_unknown_leader(rows, updates["leader_agent"]) # 3. Only the names this save adds are checked; names the stored template has keep working. already = _stored_added_skills(stored) warnings = await check_added_skills(request, [s for s in added if s not in already]) warnings += _no_shell_step_warnings(rows, graph) + if "fixed_route" in updates or "graph_definition" in updates: + warnings += _fixed_route_warnings( + bool(updates.get("fixed_route", stored.get("fixed_route"))), + updates.get("graph_definition", stored.get("graph_definition")), + ) try: updated = await repo.update(template_id, **updates) except asyncpg.UniqueViolationError as exc: @@ -239,15 +290,21 @@ async def update_template( raise _template_name_taken(updates.get("name")) from None if not updated: raise HTTPException(status_code=404, detail="Template not found") + forget_rows(request.app.state, "templates") + if "name" in updates and updates["name"] != stored.get("name"): + # The rename carried each agent's guild task with it (the repository's update). + forget_rows(request.app.state, "agents") return {**updated, "warnings": warnings} @router.delete("/pipelines/templates/{template_id}", dependencies=[_TEMPLATE_WRITE]) async def delete_template( template_id: str, + request: Request, repo: PipelineTemplatesRepository = Depends(get_templates_repo), ) -> dict[str, Any]: await repo.delete(template_id) + forget_rows(request.app.state, "templates") return {"id": template_id, "status": "deleted"} @@ -255,14 +312,17 @@ async def delete_template( async def duplicate_template( template_id: str, req: TemplateDuplicateRequest, + request: Request, repo: PipelineTemplatesRepository = Depends(get_templates_repo), ) -> dict[str, Any]: try: - return await repo.duplicate(template_id, new_name=req.name) + copy = await repo.duplicate(template_id, new_name=req.name) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except asyncpg.UniqueViolationError: raise _template_name_taken(req.name) from None + forget_rows(request.app.state, "templates") + return copy @router.post("/pipelines/validate") diff --git a/embry0/api/v1/world.py b/embry0/api/v1/world.py index 67433a26..7361f885 100644 --- a/embry0/api/v1/world.py +++ b/embry0/api/v1/world.py @@ -23,6 +23,7 @@ import asyncio from collections.abc import Awaitable, Callable +from dataclasses import dataclass from datetime import UTC, datetime from typing import Any @@ -51,6 +52,7 @@ quest_title, quest_xp, ) +from embry0.workflows.row_behaviour import pipelines_by_name logger = structlog.get_logger(__name__) # The game's reads: 404 while the settings document turns the game layer off (ui.game, #177). @@ -147,6 +149,49 @@ async def _regions(request: Request) -> dict[str, Any]: return {"regions": regions, "frontier": frontier} +async def _rows(request: Request, name: str) -> list[dict[str, Any]] | None: + """Every row of the repository ``name`` on the app (agent definitions, pipeline templates); None when none + is wired (no database), so the built-in rows as seeded answer (#167). A read that fails raises: the cache + keeps no answer of it.""" + repo = getattr(request.app.state, name, None) + if repo is None: + return None + return [dict(r) for r in await repo.list_all()] + + +async def _cached_rows(request: Request, cache: WorldCache, name: str, key: str) -> list[dict[str, Any]] | None: + """The rows ``_rows`` reads, kept for :data:`~embry0.services.world.CACHE_SECONDS`; when they cannot be read, + none, for this read only: a character is then drawn at a desk with no task, a party with no leader of its + template, until a read succeeds (never the built-in rows, which an operator may have changed).""" + try: + rows: list[dict[str, Any]] | None = await cache.get(key, lambda: _rows(request, name)) + return rows + except Exception: # noqa: BLE001 - the hall is drawn without the rows rather than not at all + logger.warning("world_rows_unread", repository=name, exc_info=True) + return [] + + +@dataclass(frozen=True) +class _Rows: + """What the agent and template rows say that the world draws (#167): None is the built-in rows as seeded.""" + + agents: dict[str, dict[str, Any]] | None + pipelines: dict[str, Any] | None + templates: set[str] | None + + +async def _behaviour_rows(request: Request, cache: WorldCache) -> _Rows: + agents, templates = await asyncio.gather( + _cached_rows(request, cache, "agent_defs_repo", "agents"), + _cached_rows(request, cache, "templates_repo", "templates"), + ) + return _Rows( + agents={str(a["type"]): a for a in agents} if agents is not None else None, + pipelines=pipelines_by_name(templates) if templates is not None else None, + templates={str(t["name"]) for t in templates} if templates is not None else None, + ) + + async def _brief_titles(request: Request, job_ids: list[str]) -> dict[str, str]: """Each job's brief title (spec 2026-10-26, job briefs), for the jobs that have a current one.""" briefs = getattr(request.app.state, "job_briefs_repo", None) @@ -176,6 +221,7 @@ async def read_world(request: Request) -> dict[str, Any]: if key_covers(request, j.get("repo")) ] ids = [str(j["job_id"]) for j in jobs] + rows = await _behaviour_rows(request, cache) runs, links, last_events, leads, places, stats, inbox, loot, cleaned, meter_rows = await asyncio.gather( repo.agent_runs(ids), repo.lead_links(ids), @@ -202,6 +248,7 @@ async def read_world(request: Request) -> dict[str, Any]: lead_id=links.get(str(j["job_id"])), last_event_at=last_events.get(str(j["job_id"])), now=now, + pipelines=rows.pipelines, ) for j in jobs ] @@ -212,7 +259,9 @@ async def read_world(request: Request) -> dict[str, Any]: "generated_at": _iso(now), "account": snapshot["account"], "skills": snapshot["skills"], - "characters": character_entries(snapshot["characters"], parties, stats, now), + "characters": character_entries( + snapshot["characters"], parties, stats, now, agents=rows.agents, templates=rows.templates + ), "parties": parties, "quests": quests, # Slop dungeon (spec 2026-10-25): each repository's and area's open @@ -276,7 +325,10 @@ async def read_character(agent_type: str, request: Request) -> dict[str, Any]: stats, log = await asyncio.gather(repo.stats(agent_type), repo.quest_log(agent_type, repos=covered_slugs(request))) log = [r for r in log if key_covers(request, r.get("repo"))] titles = await _brief_titles(request, [str(r["job_id"]) for r in log]) - entry = character_entries([character.to_dict()], [], stats, datetime.now(UTC))[0] + rows = await _behaviour_rows(request, _cache(request)) + entry = character_entries( + [character.to_dict()], [], stats, datetime.now(UTC), agents=rows.agents, templates=rows.templates + )[0] return { **entry, "quest_log": [ diff --git a/embry0/mcp_server/client.py b/embry0/mcp_server/client.py index ad3e29f9..275267d1 100644 --- a/embry0/mcp_server/client.py +++ b/embry0/mcp_server/client.py @@ -373,10 +373,15 @@ async def agent_update( color: str | None = None, tagline: str | None = None, triage_may_add_skills: list[str] | None = None, + station: str | None = None, + quest_template: str | None = None, + clear_quest_template: bool = False, ) -> dict[str, Any]: body = _drop_none( { "triage_may_add_skills": triage_may_add_skills, + "station": station, + "quest_template": quest_template, "description": description, "model": model, "tools": tools, @@ -391,6 +396,9 @@ async def agent_update( "tagline": tagline, } ) + if clear_quest_template: + # The one null this tool sends: it takes the agent's task out of the guild (#167). + body["quest_template"] = None return _as_dict(await self._request("PUT", f"/api/v1/agents/{agent_type}", body=body)) async def agent_create( @@ -410,11 +418,15 @@ async def agent_create( color: str = "slate", tagline: str = "", triage_may_add_skills: list[str] | None = None, + station: str | None = None, + quest_template: str | None = None, ) -> dict[str, Any]: body = _drop_none( { "type": agent_type, "triage_may_add_skills": triage_may_add_skills, + "station": station, + "quest_template": quest_template, "description": description, "model": model, "tools": tools, diff --git a/embry0/mcp_server/server.py b/embry0/mcp_server/server.py index bb06fda7..a7a96c54 100644 --- a/embry0/mcp_server/server.py +++ b/embry0/mcp_server/server.py @@ -433,6 +433,9 @@ async def agent_update( color: str | None = None, tagline: str | None = None, triage_may_add_skills: list[str] | None = None, + station: str | None = None, + quest_template: str | None = None, + clear_quest_template: bool = False, confirm: bool = False, ) -> dict[str, Any]: """Update an agent definition (only provided fields change). execution_mode: sdk|cli; @@ -440,9 +443,14 @@ async def agent_update( does not have yet are checked, and a bare name (no colon) is the target repository's own skill. triage_may_add_skills: the source:skill names triage may add to the agent for a job, each only from a source the agent's skills already load; [] lets it add none. + station: where the guild hall draws it (desk, anvil, lectern, alchemy_table, map_table). + quest_template: the template a task the guild gives it runs; '' gives it a task that names no template + (triage picks the route). clear_quest_template=true takes its task out of the guild. Requires confirm=true (changes live pipeline behavior).""" if not confirm: return _confirmation_refusal("agent_update", f"update agent definition {agent_type!r}") + if clear_quest_template and quest_template is not None: + return {"error": "quest_template and clear_quest_template are each other's opposite: send one"} return await _get_client().agent_update( agent_type, description=description, @@ -458,6 +466,9 @@ async def agent_update( color=color, tagline=tagline, triage_may_add_skills=triage_may_add_skills, + station=station, + quest_template=quest_template, + clear_quest_template=clear_quest_template, ) @@ -477,11 +488,15 @@ async def agent_create( color: str = "slate", tagline: str = "", triage_may_add_skills: list[str] | None = None, + station: str = "desk", + quest_template: str | None = None, confirm: bool = False, ) -> dict[str, Any]: """Create a custom agent definition. avatar is one of the roster ids and color one of the palette ids (see GET /agents for the builtins' values). triage_may_add_skills: the source:skill names triage may add - for a job, each only from a source the agent's skills load. Requires confirm=true (adds a runnable agent).""" + for a job, each only from a source the agent's skills load. station: where the guild hall draws it. + quest_template: the template a task the guild gives it runs ('' names none: triage picks); left out, the + guild gives it no task. Requires confirm=true (adds a runnable agent).""" if not confirm: return _confirmation_refusal("agent_create", f"create agent definition {agent_type!r}") return await _get_client().agent_create( @@ -499,6 +514,8 @@ async def agent_create( color=color, tagline=tagline, triage_may_add_skills=triage_may_add_skills, + station=station, + quest_template=quest_template, ) diff --git a/embry0/services/context_audit.py b/embry0/services/context_audit.py index bc1b02b6..a6b170d5 100644 --- a/embry0/services/context_audit.py +++ b/embry0/services/context_audit.py @@ -91,6 +91,7 @@ audit_days, file_rules, ) +from embry0.storage.repositories import audit_jobs from embry0.storage.schemas import FINISHED_WELL_STATUSES if TYPE_CHECKING: @@ -114,7 +115,10 @@ #: files beside it. The actor is the marker because ``jobs.dispatched_by`` is #: written once, in the reserved ``embry0:`` names, while plan_route may #: rewrite the template column. -FIX_ACTOR = dispatch_actors.PROPOSALS +FIX_ACTOR = audit_jobs.FIX_ACTOR +#: The agent an audit is led by: what makes a job an audit for this loop, progression and the findings feed alike +#: (``embry0.storage.repositories.audit_jobs``, #167). +AUDITOR = audit_jobs.AUDITOR #: Files the task names one by one; past it the task says how many more. TASK_FILE_LIMIT = 200 @@ -702,9 +706,10 @@ async def newest_per_repo( self, repos: Collection[str], *, - pipeline_template: str, + pipeline_template: str | None = None, dispatched_by: str | None = None, exclude_dispatched_by: Collection[str] = (), + leader_agent: str | None = None, ) -> dict[str, dict[str, Any]]: ... async def result_summaries(self, job_ids: Collection[str]) -> dict[str, str | None]: ... @@ -961,15 +966,28 @@ async def _registered(self, org: Organization) -> list[str]: async def _audits(self, repos: Collection[str]) -> tuple[dict[str, dict[str, Any]], set[str]]: """Each repository's newest context audit, and the repositories with a context-audit job live. - A finding's fix job (:data:`FIX_ACTOR`) is no audit, so it is never - the newest and never anchors the due rule. While it runs it still - holds its repository, as every context-audit job does: an audit - started beside it could open a pull request on the same instruction - files. So the live jobs are read with it, the newest without. + Every audit counts: a job the context auditor led (``audit_jobs``, + #167), as progression and the findings feed count them, a copy of + the template's included. So does every job of this loop's own + template, and every job it dispatched, whatever led them: such a job + works on the instruction files an audit opens a pull request on, and + an operator who gave the template another leader must not get an + audit started again on every tick. A finding's fix job + (:data:`FIX_ACTOR`) is no audit, so it is never the newest and never + anchors the due rule. While it runs it still holds its repository, + as every such job does: an audit started beside it could open a pull + request on the same instruction files. So the live jobs are read + with it, the newest without. """ - every = await self._jobs.newest_per_repo(repos, pipeline_template=TEMPLATE, dispatched_by=ACTOR) + every = await self._jobs.newest_per_repo( + repos, pipeline_template=TEMPLATE, dispatched_by=ACTOR, leader_agent=AUDITOR + ) audits = await self._jobs.newest_per_repo( - repos, pipeline_template=TEMPLATE, dispatched_by=ACTOR, exclude_dispatched_by=(FIX_ACTOR,) + repos, + pipeline_template=TEMPLATE, + dispatched_by=ACTOR, + leader_agent=AUDITOR, + exclude_dispatched_by=(FIX_ACTOR,), ) return audits, {repo for repo, row in every.items() if row.get("live")} diff --git a/embry0/services/job_briefs.py b/embry0/services/job_briefs.py index 7e4be053..b92d0688 100644 --- a/embry0/services/job_briefs.py +++ b/embry0/services/job_briefs.py @@ -42,6 +42,7 @@ system_prompt, ) from embry0.services.lead_kinds import id_pattern +from embry0.workflows.row_behaviour import WORKFLOW_PIPELINES, builtin_template, delivers #: Part of every brief's input hash: a change to the user prompt or the schema rewrites the briefs on the next #: ticks. The system prompt is the agent definition's, which an operator edits (see brief_writer). @@ -119,15 +120,8 @@ def to_dict(self) -> dict[str, str]: return {"title": self.title, "what": self.what, "why": self.why, "done": self.done, "size": self.size} -# What each kind of job hands back, in the person's words. -_TEMPLATE_DELIVERS = { - "analysis-default": "an analysis of the production logs, and a ticket when it finds a problem worth one", - "context-audit": "a report of the claims that look wrong in the files that steer coding agents", - "map-areas": "a pull request that maps the repository's areas", - "pr-review": "a review on the pull request", - "qa": "a test report", - "onboard": "a test setup for the repository", -} +# What each kind of job hands back, in the person's words, when its template's row says nothing +# (``pipeline_templates.delivers``, #167). _KIND_DELIVERS = { "code": "a pull request with the change", "research": "a written report", @@ -173,10 +167,27 @@ def input_hash(job: Mapping[str, Any]) -> str: return hashlib.sha256(json.dumps(folded, sort_keys=True).encode()).hexdigest()[:24] +def what_it_delivers(job: Mapping[str, Any]) -> str: + """What the job hands back, in the person's words: what its template's row says (``delivers``, #167), or + what a pipeline the code defines says, else what its kind hands back. + + ``template_delivers`` is the row's, as the candidates query reads it; a + job read without it (no database) takes the built-in template's as + seeded. It is no input of the brief's hash: a changed ``delivers`` + reaches the briefs written after it, and rewrites none. + """ + template = str(job.get("pipeline_template") or "") + if "template_delivers" in job: + said = str(job.get("template_delivers") or "") or delivers(WORKFLOW_PIPELINES.get(template)) + else: + said = delivers(builtin_template(template)) + return said or _KIND_DELIVERS.get(str(job.get("job_kind") or "")) or "the result of the work" + + def user_prompt(job: Mapping[str, Any]) -> str: inputs = job_inputs(job) - kind, template = inputs["job_kind"], inputs["template"] - delivers = _TEMPLATE_DELIVERS.get(template) or _KIND_DELIVERS.get(kind) or "the result of the work" + kind = inputs["job_kind"] + delivers = what_it_delivers(job) lines = [f"Kind of work: {_KIND_WORDS.get(kind, kind or 'not classified yet')}"] repo = inputs["repo"].split("/", 1)[-1] if repo: diff --git a/embry0/services/progression_rollup.py b/embry0/services/progression_rollup.py index 934a2dca..32cb9529 100644 --- a/embry0/services/progression_rollup.py +++ b/embry0/services/progression_rollup.py @@ -64,9 +64,9 @@ Action = Literal["built", "rebuilt", "advanced"] #: The modules whose code decides what a fold of the history holds: what an event is (the events -#: query, and the actor that marks a finding's fix job as no audit), what it earns (pricing, the -#: action categories, an audit's claims, the vocabulary), the curve, the quest rewards, the fold -#: and its rows. +#: query, the actor that marks a finding's fix job as no audit, and whom a job's events credit: the +#: default coder, the Slopmaster, the context auditor, the QA pipeline's leader), what it earns (pricing, the action +#: categories, an audit's claims, the vocabulary), the curve, the quest rewards, the fold and its rows. FOLD_MODULES = ( "embry0.execution.skill_actions", "embry0.orchestration.dispatch_actors", @@ -76,8 +76,11 @@ "embry0.services.progression_rollup", "embry0.services.progression_vocabulary", "embry0.services.quests", + "embry0.services.slop", + "embry0.storage.repositories.audit_jobs", "embry0.storage.repositories.progression", "embry0.storage.repositories.progression_rollup", + "embry0.workflows.row_behaviour", ) diff --git a/embry0/services/slop.py b/embry0/services/slop.py index 0e9c421b..29aea2e3 100644 --- a/embry0/services/slop.py +++ b/embry0/services/slop.py @@ -34,7 +34,7 @@ _WHY_CHARS = 300 _PATH_CHARS = 300 -#: The agent that cleans slop, on the developer node (route_plan.CODING_AGENT_TYPES). +#: The agent that cleans slop, on the developer node (row_behaviour.CODING_AGENT_TYPES). SLOPMASTER = "slopmaster" #: The template a slop job runs (storage/seeds/pipeline_templates_builtin.py). SLOP_TEMPLATE = "slop-cleanup" diff --git a/embry0/services/world.py b/embry0/services/world.py index ea8f6309..c683d54d 100644 --- a/embry0/services/world.py +++ b/embry0/services/world.py @@ -18,7 +18,7 @@ import json import math import time -from collections.abc import Awaitable, Callable, Iterable, Mapping +from collections.abc import Awaitable, Callable, Collection, Iterable, Mapping from datetime import UTC, datetime from typing import Any @@ -27,8 +27,9 @@ from embry0.services.progression import price from embry0.services.progression_pack import Rules from embry0.services.proposals import ACTOR as PROPOSALS_ACTOR -from embry0.services.slop import SLOP_TEMPLATE, SLOPMASTER +from embry0.services.slop import SLOPMASTER from embry0.storage.repositories.progression import ProgressEvent +from embry0.workflows import row_behaviour #: How long a party that finished stays out, walking home, before it is back in the hall. RETURN_SECONDS = 180.0 @@ -51,38 +52,18 @@ _SEVERITY = {"critical": 4, "high": 3, "medium": 2, "low": 1, "misleads": 2, "dead": 1, "drift": 1} -#: The character that leads a job of each template when no agent has started yet. -LEADER_BY_TEMPLATE: dict[str, str] = { - "analysis-default": "analysis", - "research-default": "research", - "ops-default": "ops", - "qa": "qa", - "pr-review": "pr-reviewer", - "onboard": "onboarding", - "kintsugi-trace": "kintsugi", - "garden": "gardener", - "interrogate": "interrogator", - "why": "why", - "how": "how", - "blast-radius": "blast-radius", - "comment-pass": "comment-sicko", - "design-audit": "design-auditor", - "context-audit": "context-auditor", - "map-areas": "area-mapper", - SLOP_TEMPLATE: SLOPMASTER, -} +#: The pipelines a job's ``pipeline_template`` can name, by name: the template rows and the code's own +#: (``row_behaviour.pipelines_by_name``). None, wherever a function takes it, is the built-in rows as seeded. +Pipelines = Mapping[str, Mapping[str, Any]] + + +def pipeline_of(job: Mapping[str, Any], pipelines: Pipelines | None) -> Mapping[str, Any] | None: + """The row of the pipeline the job ran: from ``pipelines``, or the built-in one as seeded without them.""" + name = str(job.get("pipeline_template") or "") + if pipelines is None: + return row_behaviour.builtin_template(name) + return pipelines.get(name) -#: The template a quest the player writes for a character runs: its own, or None for the -#: developer's (triage picks the pipeline). A character missing here cannot be given a -#: free-form quest (triage, review and QA work only inside another quest). -QUEST_TEMPLATE_BY_AGENT: dict[str, str | None] = { - "developer": None, - **{ - agent: template - for template, agent in LEADER_BY_TEMPLATE.items() - if template not in ("qa", "pr-review", "onboard") - }, -} #: Job statuses, as the world says them. _FINISHED_STATE = { @@ -172,19 +153,23 @@ def job_state(status: str, current: str | None, members: list[str]) -> str: return _FINISHED_STATE.get(status, "returning") -def leader_of(job: Mapping[str, Any], members: list[str], current: str | None) -> str: - """The character a job is drawn as: the one working now, else the last that worked other than triage, else its template's.""" +def leader_of( + job: Mapping[str, Any], members: list[str], current: str | None, pipelines: Pipelines | None = None +) -> str: + """The character a job is drawn as: the one working now, else the last that worked other than triage, else + its template's leader: the one it recorded when it was planned (``jobs.leader_agent``, #167), or before + then its template row's.""" if current and current != "triage": return current worked = [m for m in members if m != "triage"] if worked: return worked[-1] - template = str(job.get("pipeline_template") or "") - if template in LEADER_BY_TEMPLATE: - return LEADER_BY_TEMPLATE[template] + lead = job.get("leader_agent") or row_behaviour.leader(pipeline_of(job, pipelines)) + if lead: + return lead if members: return current or members[-1] - return "developer" + return row_behaviour.DEFAULT_CODER def quest_title(task: str | None) -> str: @@ -264,6 +249,7 @@ def job_entry( lead_id: str | None, last_event_at: Any, now: datetime, + pipelines: Pipelines | None = None, ) -> dict[str, Any]: """One party: who leads it, who is in it, where it is, and whether it looks stuck.""" members, current = party_of(runs) @@ -277,7 +263,7 @@ def job_entry( "title": quest_title(job.get("task")), "status": status, "state": state, - "leader": leader_of(job, members, current), + "leader": leader_of(job, members, current, pipelines), "party": members, "working": current, "lead_id": lead_id, @@ -326,16 +312,24 @@ def character_entries( parties: list[dict[str, Any]], stats: Mapping[str, Mapping[str, Any]], now: datetime, + *, + agents: Mapping[str, Mapping[str, Any]] | None = None, + templates: Collection[str] | None = None, ) -> list[dict[str, Any]]: """Every character with where it is now: the party it leads (or is in), and how its quests have gone. A character on several quests at once is drawn at the newest one it leads, with the count; one on none is in the hall, idle too long when its - last quest is more than :data:`IDLE_DAYS` old. + last quest is more than :data:`IDLE_DAYS` old. Its station and the task + the guild can give it are its agent row's (``agents``, by type; without + them, the built-in rows as seeded): a task naming a template that is not + among ``templates`` is no task (#167). """ out = [] for character in characters: agent = str(character["agent_type"]) + row = agents.get(agent) if agents is not None else row_behaviour.builtin_agent(agent) + task = row_behaviour.quest(row, templates) leading = [p for p in parties if p["leader"] == agent] along = [p for p in parties if agent in p["party"] and p["leader"] != agent] out_now = [p for p in leading + along if p["state"] in ("travelling", "working", "waiting")] @@ -351,8 +345,9 @@ def character_entries( "quests_out": len(out_now), "stats": entry_stats, "idle_too_long": primary is None and (since_last is None or since_last > IDLE_DAYS * 86_400), - "quest_template": QUEST_TEMPLATE_BY_AGENT.get(agent), - "can_quest": agent in QUEST_TEMPLATE_BY_AGENT, + "station": row_behaviour.station(row), + "quest_template": task.template, + "can_quest": task.offered, } ) return out @@ -389,7 +384,8 @@ def quest_fallbacks(runs: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]: def quest_xp(job: Mapping[str, Any], runs: Iterable[Mapping[str, Any]], *, rules: Rules) -> list[dict[str, Any]]: - """What a quest earned each character, by skill, under ``rules``: its agents' runs, and the pull request it opened or merged.""" + """What a quest earned each character, by skill, under ``rules``: its agents' runs, and the pull request it + opened or merged, credited to the agent that coded it (``jobs.coding_agent``, as progression credits it).""" gains: dict[tuple[str, str], int] = {} at = job.get("finished_at") or job.get("created_at") or datetime.now(UTC) events = [ @@ -399,9 +395,10 @@ def quest_xp(job: Mapping[str, Any], runs: Iterable[Mapping[str, Any]], *, rules for r in runs if r.get("agent_type") and not r.get("error_code") and r.get("result") == "success" ] - # A slop cleanup's pull request is the Slopmaster's, and its merge earns - # the code it removed (spec 2026-10-25, slop dungeon). - coder = SLOPMASTER if job.get("pipeline_template") == SLOP_TEMPLATE else "developer" + # A slop cleanup's pull request is the Slopmaster's, the agent that coded + # it, and its merge earns the code it removed (spec 2026-10-25, slop + # dungeon); a job that recorded no coder is the developer's. + coder = str(job.get("coding_agent") or row_behaviour.DEFAULT_CODER) if job.get("pr_url"): events.append(ProgressEvent("opened", coder, at)) if job.get("status") == "pr_merged" or job.get("delivery_state") == "merged": @@ -442,3 +439,11 @@ async def get(self, key: str, load: Callable[[], Awaitable[Any]]) -> Any: def drop(self, key: str) -> None: self._entries.pop(key, None) + + +def forget_rows(app_state: Any, key: str) -> None: + """Drop the world's cached ``agents`` or ``templates`` rows after a write to them, so the guild hall draws + the change on its next read rather than up to :data:`CACHE_SECONDS` later (#167).""" + cache = getattr(app_state, "world_cache", None) + if isinstance(cache, WorldCache): + cache.drop(key) diff --git a/embry0/storage/migrations/runner.py b/embry0/storage/migrations/runner.py index cc9a5832..a98fa451 100644 --- a/embry0/storage/migrations/runner.py +++ b/embry0/storage/migrations/runner.py @@ -3999,6 +3999,172 @@ def lit(value: str) -> str: 'The skills triage may add to this agent (source:skill names), each only from a source the agent''s skills already load.'; """, ), + ( + 143, + "behaviour from agent and template rows, not built-in names (#167)", + # What embry0 did by a template's or an agent's built-in name is now a + # column of its row, so an operator's own take part the same way + # (embry0/workflows/row_behaviour.py): + # + # - pipeline_templates.fixed_route: a job that names the template skips + # triage; offered_to_triage: triage is offered it and keeps its pick; + # job_kind: an operator's override of the kind its jobs are recorded + # as (NULL: the kind it is the default for, else the one its route + # makes it, read from its graph); delivers: what its jobs hand back, + # for their briefs; leader_agent: who its jobs are drawn as; + # - agent_definitions.station: where the guild hall draws it; + # quest_template: the task the guild can give it (NULL none, '' a + # task naming no template, else that template); + # - jobs.coding_agent and jobs.leader_agent: the agent that coded a job + # and its template's leader, which plan_route records when it plans + # the job. Progression and the world credit a job from these, so an + # edit of a template row later changes no past credit. + # + # Every row is written what the code said of its name before, matched + # by name as the code matched it, built-in or not: the ten specialist + # templates are fixed and not offered, and the leaders, deliverables, + # stations and guild tasks are the old maps'. Each job is credited as + # the old name rules credited it: a slop-cleanup job was coded by the + # Slopmaster (NULL reads as the developer), and a job of a template + # with a leader in the old map is led by it, so a context-audit job is + # led by the context auditor (a job of a pipeline the code defines is + # never planned, and records none). job_kind is left NULL: the graph gives what the old rule + # did. The boot seed writes the template columns on a first insert + # only, so what an operator changes later stays. + # + # A template may no longer take the name of a pipeline the code + # defines (qa, onboard, pr-review): a job's pipeline_template naming + # one means that pipeline. A row with such a name is renamed to + # -template (with its id's head when that is taken too). + """ + ALTER TABLE pipeline_templates + ADD COLUMN IF NOT EXISTS fixed_route BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS offered_to_triage BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN IF NOT EXISTS job_kind TEXT, + ADD COLUMN IF NOT EXISTS delivers TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS leader_agent TEXT; + ALTER TABLE agent_definitions + ADD COLUMN IF NOT EXISTS station TEXT NOT NULL DEFAULT 'desk', + ADD COLUMN IF NOT EXISTS quest_template TEXT; + ALTER TABLE jobs + ADD COLUMN IF NOT EXISTS coding_agent TEXT, + ADD COLUMN IF NOT EXISTS leader_agent TEXT; + + UPDATE pipeline_templates t + SET name = t.name || '-template' + WHERE t.name IN ('qa', 'onboard', 'pr-review') + AND NOT EXISTS (SELECT 1 FROM pipeline_templates o WHERE o.name = t.name || '-template'); + UPDATE pipeline_templates t + SET name = t.name || '-template-' || left(t.id, 8) + WHERE t.name IN ('qa', 'onboard', 'pr-review'); + + UPDATE pipeline_templates t + SET fixed_route = true, offered_to_triage = false + WHERE t.name IN ('kintsugi-trace', 'garden', 'interrogate', 'why', 'how', 'blast-radius', + 'comment-pass', 'design-audit', 'context-audit', 'map-areas'); + UPDATE pipeline_templates t SET leader_agent = v.agent + FROM (VALUES + ('analysis-default', 'analysis'), + ('research-default', 'research'), + ('ops-default', 'ops'), + ('kintsugi-trace', 'kintsugi'), + ('garden', 'gardener'), + ('interrogate', 'interrogator'), + ('why', 'why'), + ('how', 'how'), + ('blast-radius', 'blast-radius'), + ('comment-pass', 'comment-sicko'), + ('design-audit', 'design-auditor'), + ('context-audit', 'context-auditor'), + ('map-areas', 'area-mapper'), + ('slop-cleanup', 'slopmaster') + ) AS v(name, agent) + WHERE t.name = v.name; + UPDATE pipeline_templates t SET delivers = v.delivers + FROM (VALUES + ('analysis-default', 'an analysis of the production logs, and a ticket when it finds a problem worth one'), + ('context-audit', 'a report of the claims that look wrong in the files that steer coding agents'), + ('map-areas', 'a pull request that maps the repository''s areas') + ) AS v(name, delivers) + WHERE t.name = v.name; + + UPDATE agent_definitions a SET station = v.station + FROM (VALUES + ('developer', 'anvil'), + ('review', 'lectern'), + ('pr-reviewer', 'lectern'), + ('interrogator', 'lectern'), + ('qa', 'alchemy_table'), + ('kintsugi', 'alchemy_table'), + ('analysis', 'map_table'), + ('research', 'map_table'), + ('ops', 'map_table'), + ('area-mapper', 'map_table'), + ('blast-radius', 'map_table') + ) AS v(type, station) + WHERE a.type = v.type; + UPDATE agent_definitions a SET quest_template = v.template + FROM (VALUES + ('developer', ''), + ('analysis', 'analysis-default'), + ('research', 'research-default'), + ('ops', 'ops-default'), + ('kintsugi', 'kintsugi-trace'), + ('gardener', 'garden'), + ('interrogator', 'interrogate'), + ('why', 'why'), + ('how', 'how'), + ('blast-radius', 'blast-radius'), + ('comment-sicko', 'comment-pass'), + ('design-auditor', 'design-audit'), + ('context-auditor', 'context-audit'), + ('area-mapper', 'map-areas'), + ('slopmaster', 'slop-cleanup') + ) AS v(type, template) + WHERE a.type = v.type; + + ALTER TABLE jobs DISABLE TRIGGER trg_jobs_updated_at; + UPDATE jobs SET coding_agent = 'slopmaster' WHERE pipeline_template = 'slop-cleanup'; + UPDATE jobs j SET leader_agent = v.agent + FROM (VALUES + ('analysis-default', 'analysis'), + ('research-default', 'research'), + ('ops-default', 'ops'), + ('kintsugi-trace', 'kintsugi'), + ('garden', 'gardener'), + ('interrogate', 'interrogator'), + ('why', 'why'), + ('how', 'how'), + ('blast-radius', 'blast-radius'), + ('comment-pass', 'comment-sicko'), + ('design-audit', 'design-auditor'), + ('context-audit', 'context-auditor'), + ('map-areas', 'area-mapper'), + ('slop-cleanup', 'slopmaster') + ) AS v(name, agent) + WHERE j.pipeline_template = v.name; + ALTER TABLE jobs ENABLE TRIGGER trg_jobs_updated_at; + + COMMENT ON COLUMN pipeline_templates.fixed_route IS + '#167: a job that names this template skips triage; its route is fixed.'; + COMMENT ON COLUMN pipeline_templates.offered_to_triage IS + '#167: triage is offered this template and plan_route keeps its pick of it; false runs it only when a job names it.'; + COMMENT ON COLUMN pipeline_templates.job_kind IS + '#167: an override of the kind a job of this template is recorded as; NULL is its default kind, else what its route makes it.'; + COMMENT ON COLUMN pipeline_templates.delivers IS + '#167: what a job of this template hands back, in the person''s words, for its brief; empty says nothing.'; + COMMENT ON COLUMN pipeline_templates.leader_agent IS + '#167: the agent a job of this template is drawn as before one starts; plan_route records it on the job.'; + COMMENT ON COLUMN agent_definitions.station IS + '#167: where the guild hall draws this agent when it is home (embry0/agents/identity.py STATIONS).'; + COMMENT ON COLUMN agent_definitions.quest_template IS + '#167: the task the guild can give this agent: NULL none, empty a task naming no template, else that template.'; + COMMENT ON COLUMN jobs.coding_agent IS + '#167: the agent that coded the job, as plan_route recorded it; NULL is the developer. Its pull request is credited to it.'; + COMMENT ON COLUMN jobs.leader_agent IS + '#167: the leader of the job''s template when it was planned; a job led by the context auditor is an audit.'; + """, + ), ] diff --git a/embry0/storage/repositories/agent_definitions.py b/embry0/storage/repositories/agent_definitions.py index cf13d459..f59d3c7e 100644 --- a/embry0/storage/repositories/agent_definitions.py +++ b/embry0/storage/repositories/agent_definitions.py @@ -25,6 +25,7 @@ from embry0.workflows.onboard.agent_seed import SYSTEM_PROMPT as _ONBOARDING_SYSTEM_PROMPT from embry0.workflows.pr_review.agent_seed import PR_REVIEWER_AGENT_SEED as _PR_REVIEWER_AGENT_SEED from embry0.workflows.qa.agent_seed import QA_AGENT_SEED as _QA_AGENT_SEED +from embry0.workflows.row_behaviour import AGENT_DEFAULTS, checked_quest_template, checked_station logger = structlog.get_logger(__name__) @@ -74,7 +75,7 @@ }, # Slop dungeon (spec 2026-10-25): a code job triage judged slop runs the # slop-cleanup template, whose slopmaster step runs on the developer node - # (route_plan.CODING_AGENT_TYPES) with this definition. Behavior is the + # (row_behaviour.CODING_AGENT_TYPES) with this definition. Behavior is the # system prompt; branch, push and PR mechanics are the developer node's. "slopmaster": { "description": ( @@ -386,11 +387,42 @@ # copies, so this touches the copies only (their own seeds carry it too). for _agent_type, _ident in BUILTIN_IDENTITY.items(): BUILTIN_SEED[_agent_type].update(_ident) + +# Where the guild hall draws each builtin, and the task the guild can give it +# (#167, embry0/workflows/row_behaviour.py; migration 143 writes the same on +# an upgrade). The developer's task names no template, so triage picks its +# route; every other one runs its own template. The qa and pr-reviewer +# stations ride their own seeds. A builtin left out sits at a desk and gets no +# task: triage, review, QA, onboarding and pull-request review work only +# inside another job. +_GUILD: dict[str, dict[str, Any]] = { + "developer": {"station": "anvil", "quest_template": ""}, + "review": {"station": "lectern"}, + "interrogator": {"station": "lectern", "quest_template": "interrogate"}, + "kintsugi": {"station": "alchemy_table", "quest_template": "kintsugi-trace"}, + "research": {"station": "map_table", "quest_template": "research-default"}, + "analysis": {"station": "map_table", "quest_template": "analysis-default"}, + "ops": {"station": "map_table", "quest_template": "ops-default"}, + "area-mapper": {"station": "map_table", "quest_template": "map-areas"}, + "blast-radius": {"station": "map_table", "quest_template": "blast-radius"}, + "gardener": {"quest_template": "garden"}, + "why": {"quest_template": "why"}, + "how": {"quest_template": "how"}, + "comment-sicko": {"quest_template": "comment-pass"}, + "design-auditor": {"quest_template": "design-audit"}, + "context-auditor": {"quest_template": "context-audit"}, + "slopmaster": {"quest_template": "slop-cleanup"}, +} +for _agent_type, _guild in _GUILD.items(): + BUILTIN_SEED[_agent_type].update(_guild) # Every builtin runs in jobs unless its entry says otherwise (migration 138's -# default), and triage may add none of its skills unless it says so (142's). +# default), triage may add none of its skills unless it says so (142's), and +# it sits at a desk with no guild task unless it says so (143's). for _seed in BUILTIN_SEED.values(): _seed.setdefault("runs_in_jobs", True) _seed.setdefault("triage_may_add_skills", []) + for _key, _default in AGENT_DEFAULTS.items(): + _seed.setdefault(_key, _default) _ALLOWED_UPDATE_FIELDS = { "description", @@ -406,6 +438,8 @@ "color", "tagline", "triage_may_add_skills", + "station", + "quest_template", } @@ -444,22 +478,29 @@ async def create( tagline: str = "", runs_in_jobs: bool = True, triage_may_add_skills: list[str] | None = None, + station: str = AGENT_DEFAULTS["station"], + quest_template: str | None = None, ) -> dict[str, Any]: """Insert a new custom agent definition; ``runs_in_jobs`` is the seeder's to set (the API never passes it). - ``triage_may_add_skills`` is checked (``checked_triage_may_add``): a - malformed value raises ValueError naming the field. Raises ValueError - when ``tools`` names a tool outside the tool catalog - (embry0.services.tool_catalog): every seeder writes through here. + ``triage_may_add_skills``, ``station`` and ``quest_template`` are + checked (``checked_triage_may_add``, ``row_behaviour``): a malformed + value raises ValueError naming the field. Whether ``quest_template`` + names a template that exists is the API's check: a seeder may write + an agent before its template. Raises ValueError when ``tools`` names + a tool outside the tool catalog (embry0.services.tool_catalog): every + seeder writes through here. """ check_agent_tools(agent_type, tools or [], mcp_servers=mcp_servers) may_add = checked_triage_may_add(triage_may_add_skills or []) + guild = (checked_station(station), checked_quest_template(quest_template)) row = await self._db.fetchrow( """ INSERT INTO agent_definitions (type, description, model, tools, skills, system_prompt, is_builtin, execution_mode, auth_mode, - mcp_servers, display_name, avatar, color, tagline, runs_in_jobs, triage_may_add_skills) - VALUES ($1, $2, $3, $4, $5, $6, false, $7, $8, $9, $10, $11, $12, $13, $14, $15) + mcp_servers, display_name, avatar, color, tagline, runs_in_jobs, triage_may_add_skills, + station, quest_template) + VALUES ($1, $2, $3, $4, $5, $6, false, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING * """, agent_type, @@ -477,6 +518,7 @@ async def create( tagline, runs_in_jobs, may_add, + *guild, ) logger.info("agent_definition_created", agent_type=agent_type) assert row is not None, "INSERT ... RETURNING must return a row" @@ -493,6 +535,10 @@ async def update(self, agent_type: str, **fields: Any) -> dict[str, Any]: raise ValueError(f"Unknown fields for update: {unknown}") if TRIAGE_MAY_ADD_FIELD in fields: fields = {**fields, TRIAGE_MAY_ADD_FIELD: checked_triage_may_add(fields[TRIAGE_MAY_ADD_FIELD])} + if "station" in fields: + fields = {**fields, "station": checked_station(fields["station"])} + if "quest_template" in fields: + fields = {**fields, "quest_template": checked_quest_template(fields["quest_template"])} if "tools" in fields or "mcp_servers" in fields: stored = {} if {"tools", "mcp_servers"} <= set(fields) else (await self.get(agent_type) or {}) tools = fields["tools"] if "tools" in fields else stored.get("tools") @@ -556,6 +602,8 @@ async def reset(self, agent_type: str) -> dict[str, Any]: tagline = $12, runs_in_jobs = $14, triage_may_add_skills = $15, + station = $16, + quest_template = $17, updated_at = NOW() WHERE type = $13 AND is_builtin RETURNING * @@ -575,6 +623,8 @@ async def reset(self, agent_type: str) -> dict[str, Any]: agent_type, seed.get("runs_in_jobs", True), checked_triage_may_add(seed.get("triage_may_add_skills", [])), + checked_station(seed.get("station", AGENT_DEFAULTS["station"])), + checked_quest_template(seed.get("quest_template")), ) if row is None: if await self.get(agent_type) is not None: diff --git a/embry0/storage/repositories/audit_jobs.py b/embry0/storage/repositories/audit_jobs.py new file mode 100644 index 00000000..6534d7b8 --- /dev/null +++ b/embry0/storage/repositories/audit_jobs.py @@ -0,0 +1,48 @@ +"""Which jobs are context audits: one predicate for progression, the findings feed and the scheduler (#167). + +A job is an audit when the context auditor led it, the agent whose report +an audit's claims are read from: ``jobs.leader_agent``, which a job takes +from its template's row when it is created and plan_route records again +when it plans it. A finding's fix job (:data:`FIX_ACTOR`, #240) is led by +the context auditor too but is no audit: it fixes one claim. So a copy of +the context-audit template earns audit credit and offers its findings, and +a job of a template whose leader an operator changed to another agent does +neither. + +The context audit's scheduler counts every audit, and also every job of its +own template and every job it dispatched, whatever led them +(``ContextAuditLoop._audits``): such a job works on the files an audit opens +a pull request on, and after an operator gives the template another leader +the scheduler must not start an audit again on every tick. It dispatches +its own template (``context_audit.TEMPLATE``). +""" + +from __future__ import annotations + +from embry0.orchestration.dispatch_actors import PROPOSALS + +#: The agent a context audit is led by. +AUDITOR = "context-auditor" +#: The actor of a context-audit finding's fix job (``context_audit.FIX_ACTOR``): the job an owner starts by accepting +#: one finding. It is no audit, so it earns no audit's credit and offers no findings. +FIX_ACTOR = PROPOSALS + + +def _literal(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def led_by_the_auditor(job: str) -> str: + """The SQL condition that the job aliased ``job`` was led by the :data:`AUDITOR`: an audit or a fix job.""" + return f"{job}.leader_agent = {_literal(AUDITOR)}" + + +def an_audit(job: str) -> str: + """The SQL condition that the job aliased ``job`` is a context audit: led by the :data:`AUDITOR`, and not a + finding's fix job.""" + return f"({led_by_the_auditor(job)} AND {job}.dispatched_by IS DISTINCT FROM {_literal(FIX_ACTOR)})" + + +def is_audit(leader: str | None, dispatched_by: str | None) -> bool: + """:func:`an_audit` for one job's ``leader_agent`` and ``dispatched_by``.""" + return leader == AUDITOR and dispatched_by != FIX_ACTOR diff --git a/embry0/storage/repositories/job_briefs.py b/embry0/storage/repositories/job_briefs.py index c3d3b35b..00e6fd24 100644 --- a/embry0/storage/repositories/job_briefs.py +++ b/embry0/storage/repositories/job_briefs.py @@ -15,10 +15,13 @@ i.title AS issue_title """ +# What the job's template says it delivers (#167): read for the prompt, no input of the hash. _CANDIDATES_SQL = f""" -SELECT {_INPUTS}, j.status, b.input_hash AS brief_hash, b.prompt_hash AS brief_prompt_hash +SELECT {_INPUTS}, t.delivers AS template_delivers, j.status, b.input_hash AS brief_hash, + b.prompt_hash AS brief_prompt_hash FROM jobs j LEFT JOIN issues i ON i.id = j.issue_id +LEFT JOIN pipeline_templates t ON t.name = j.pipeline_template LEFT JOIN job_briefs b ON b.job_id = j.job_id WHERE j.status = ANY($1::text[]) OR j.finished_at > NOW() - make_interval(days => $2) ORDER BY (j.status = ANY($1::text[])) DESC, j.created_at DESC diff --git a/embry0/storage/repositories/jobs.py b/embry0/storage/repositories/jobs.py index cbf7cd5d..f2e65dfa 100644 --- a/embry0/storage/repositories/jobs.py +++ b/embry0/storage/repositories/jobs.py @@ -146,6 +146,9 @@ def _is_gate_refusal(current_status: str, new_status: str, fields: dict[str, Any "work_nature", # Done when (migration 120): the job's finish line and the report on it. "done_when", + # #167 (migration 143): who coded the job and who led its template, as plan_route recorded them. + "coding_agent", + "leader_agent", } ) @@ -225,7 +228,9 @@ async def create( ``dispatched_by`` is the actor that asked for this job (a webhook sender login, an API principal). None when nobody identifiable did — the dispatch rate limit skips those rows rather than pooling them - (#246). + (#246). A job that names a template takes its row's leader now + (``leader_agent``, #167), so it is known for what it is (an audit, + say) before plan_route records it again. """ if context is None: context = {"type": "git", "repo": repo} if repo else {"type": "none"} @@ -235,9 +240,10 @@ async def create( INSERT INTO jobs ( job_id, repo, task, issue_number, issue_id, pipeline_template, pipeline_config, sandbox_profile, trace_id, context, - dispatched_by + dispatched_by, leader_agent ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, + (SELECT t.leader_agent FROM pipeline_templates t WHERE t.name = $6)) """, job_id, repo, @@ -381,11 +387,13 @@ async def newest_per_repo( self, repos: Collection[str], *, - pipeline_template: str, + pipeline_template: str | None = None, dispatched_by: str | None = None, exclude_dispatched_by: Collection[str] = (), + leader_agent: str | None = None, ) -> dict[str, dict[str, Any]]: - """The newest job of each repository in ``repos`` that names ``pipeline_template``. + """The newest job of each repository in ``repos`` that names ``pipeline_template``, or that was led by + ``leader_agent`` (``jobs.leader_agent``, #167: what makes a job a context audit, ``audit_jobs``). A job ``dispatched_by`` started counts too, whatever its template column says now: plan_route rewrites the column with the template it @@ -411,7 +419,7 @@ async def newest_per_repo( repo, job_id, status, created_at, finished_at, pipeline_config, total_cost_usd, error_code, pr_url, bool_or(NOT (status = ANY($4::text[]))) OVER (PARTITION BY repo) AS live FROM jobs - WHERE repo = ANY($1::text[]) AND (pipeline_template = $2 OR dispatched_by = $3) + WHERE repo = ANY($1::text[]) AND (pipeline_template = $2 OR dispatched_by = $3 OR leader_agent = $6) AND NOT (COALESCE(dispatched_by, '') = ANY($5::text[])) AND NOT {gate_refusal_sql("jobs")} ORDER BY repo, created_at DESC, job_id DESC @@ -421,6 +429,7 @@ async def newest_per_repo( dispatched_by, sorted(str(status) for status in RELEASED_JOB_STATUSES), sorted(set(exclude_dispatched_by)), + leader_agent, ) return {str(r["repo"]): dict(r) for r in rows} diff --git a/embry0/storage/repositories/pipeline_templates.py b/embry0/storage/repositories/pipeline_templates.py index bcec83c1..6ac73c2a 100644 --- a/embry0/storage/repositories/pipeline_templates.py +++ b/embry0/storage/repositories/pipeline_templates.py @@ -1,15 +1,46 @@ """Pipeline templates repository — CRUD + duplicate for graph-based pipeline definitions.""" import uuid +from collections.abc import Callable from typing import Any import structlog from embry0.storage.database import DatabasePool +from embry0.workflows.row_behaviour import ( + TEMPLATE_DEFAULTS, + checked_delivers, + checked_job_kind, + checked_leader_agent, +) logger = structlog.get_logger(__name__) -_UPDATABLE_FIELDS = frozenset({"name", "description", "graph_definition", "sandbox_profile", "default_for_kind"}) +#: The columns that say how embry0 treats a template's jobs (#167, ``embry0.workflows.row_behaviour``): the +#: operator's, like ``default_for_kind``, so the boot seed writes them on a first insert and never after. +BEHAVIOUR_FIELDS: tuple[str, ...] = tuple(TEMPLATE_DEFAULTS) + +_UPDATABLE_FIELDS = frozenset( + {"name", "description", "graph_definition", "sandbox_profile", "default_for_kind", *BEHAVIOUR_FIELDS} +) + +_CHECKS: dict[str, Callable[[Any], Any]] = { + "delivers": checked_delivers, + "job_kind": checked_job_kind, + "leader_agent": checked_leader_agent, + "fixed_route": bool, + "offered_to_triage": bool, +} + + +def _behaviour(fields: dict[str, Any]) -> dict[str, Any]: + """Each behaviour column ``fields`` names, checked: a malformed value raises ValueError naming its field.""" + return {key: _CHECKS[key](fields[key]) for key in BEHAVIOUR_FIELDS if key in fields} + + +def _seeded_behaviour(fields: dict[str, Any]) -> dict[str, Any]: + """Every behaviour column, as ``fields`` set it or at its default.""" + return _behaviour({**TEMPLATE_DEFAULTS, **{k: v for k, v in fields.items() if v is not None}}) class PipelineTemplatesRepository: @@ -21,7 +52,8 @@ def __init__(self, db: DatabasePool) -> None: async def list_all(self) -> list[dict[str, Any]]: """List all pipeline templates (summary only, no graph_definition).""" rows = await self._db.fetch( - "SELECT id, name, description, sandbox_profile, is_builtin, default_for_kind, created_at, updated_at " + "SELECT id, name, description, sandbox_profile, is_builtin, default_for_kind, " + "fixed_route, offered_to_triage, job_kind, delivers, leader_agent, created_at, updated_at " "FROM pipeline_templates ORDER BY is_builtin DESC, name" ) return [dict(r) for r in rows] @@ -40,15 +72,26 @@ async def create( description: str = "", sandbox_profile: str | None = None, default_for_kind: str | None = None, + **behaviour: Any, ) -> dict[str, Any]: - """Insert a new pipeline template and return the created record.""" + """Insert a new pipeline template and return the created record. + + ``behaviour`` holds any of :data:`BEHAVIOUR_FIELDS`; one left out is + at its default (a template of the deployment's own, as before #167). + A malformed one raises ValueError naming its field. + """ + unknown = set(behaviour) - set(BEHAVIOUR_FIELDS) + if unknown: + raise ValueError(f"Unknown template fields: {sorted(unknown)}") + columns = _seeded_behaviour(behaviour) template_id = str(uuid.uuid4()) await self._db.execute( """ INSERT INTO pipeline_templates ( - id, name, description, graph_definition, sandbox_profile, default_for_kind + id, name, description, graph_definition, sandbox_profile, default_for_kind, + fixed_route, offered_to_triage, job_kind, delivers, leader_agent ) - VALUES ($1, $2, $3, $4, $5, $6) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) """, template_id, name, @@ -56,14 +99,21 @@ async def create( graph_definition, sandbox_profile, default_for_kind, + *(columns[key] for key in BEHAVIOUR_FIELDS), ) logger.info("pipeline_template_created", template_id=template_id, name=name) row = await self.get(template_id) return row # type: ignore[return-value] async def update(self, template_id: str, **fields: Any) -> dict[str, Any]: - """Update allowed fields on a pipeline template and return the updated record.""" + """Update allowed fields on a pipeline template and return the updated record. + + A malformed behaviour column raises ValueError naming its field. A + rename moves each agent's ``quest_template`` that named the old name + to the new one, in the same transaction. + """ valid = {k: v for k, v in fields.items() if k in _UPDATABLE_FIELDS} + valid.update(_behaviour(valid)) if valid: sets: list[str] = [] args: list[Any] = [template_id] @@ -73,10 +123,22 @@ async def update(self, template_id: str, **fields: Any) -> dict[str, Any]: args.append(value) idx += 1 sets.append("updated_at = NOW()") - await self._db.execute( - f"UPDATE pipeline_templates SET {', '.join(sets)} WHERE id = $1", - *args, - ) + async with self._db.transaction() as conn: + # FOR UPDATE: a rename running beside this one waits for it, then reads the name it left, so + # the agents' tasks follow every rename to the last. + old_name = await conn.fetchval( + "SELECT name FROM pipeline_templates WHERE id = $1 FOR UPDATE", template_id + ) + await conn.execute(f"UPDATE pipeline_templates SET {', '.join(sets)} WHERE id = $1", *args) + if "name" in valid and old_name is not None and valid["name"] != old_name: + # An agent's guild task names its template (#167): it follows a rename, in the same + # transaction, so the task keeps running what the operator chose. + moved = await conn.execute( + "UPDATE agent_definitions SET quest_template = $1, updated_at = NOW() WHERE quest_template = $2", + valid["name"], + old_name, + ) + logger.info("agent_quest_templates_renamed", old=old_name, new=valid["name"], result=moved) logger.info("pipeline_template_updated", template_id=template_id, fields=list(valid.keys())) row = await self.get(template_id) return row # type: ignore[return-value] @@ -93,6 +155,7 @@ async def upsert_builtin( description: str = "", sandbox_profile: str | None = None, default_for_kind: str | None = None, + **behaviour: Any, ) -> dict[str, Any]: """Idempotent upsert for seed-time builtin pipeline templates. @@ -107,7 +170,16 @@ async def upsert_builtin( template survives a restart; a first insert takes the kind only while no other template holds it, so the seed never trips the partial unique index ``idx_pipeline_templates_default_kind`` at boot. + + The behaviour columns (:data:`BEHAVIOUR_FIELDS`, #167) are the + operator's too: a first insert takes ``behaviour`` (each one left out + at its default), and an existing row keeps what it holds, so an + operator's change to a built-in survives a restart. """ + unknown = set(behaviour) - set(BEHAVIOUR_FIELDS) + if unknown: + raise ValueError(f"Unknown template fields: {sorted(unknown)}") + columns = _seeded_behaviour(behaviour) existing = await self._db.fetchrow( "SELECT id FROM pipeline_templates WHERE name = $1", name, @@ -117,7 +189,8 @@ async def upsert_builtin( """ INSERT INTO pipeline_templates ( id, name, description, graph_definition, - sandbox_profile, default_for_kind, is_builtin, updated_at + sandbox_profile, default_for_kind, is_builtin, updated_at, + fixed_route, offered_to_triage, job_kind, delivers, leader_agent ) VALUES ( $1, $2, $3, $4, $5, @@ -125,7 +198,8 @@ async def upsert_builtin( WHEN EXISTS (SELECT 1 FROM pipeline_templates WHERE default_for_kind = $6) THEN NULL ELSE $6 END, - TRUE, NOW() + TRUE, NOW(), + $7, $8, $9, $10, $11 ) ON CONFLICT (name) DO UPDATE SET description = EXCLUDED.description, @@ -140,6 +214,7 @@ async def upsert_builtin( graph_definition, sandbox_profile, default_for_kind, + *(columns[key] for key in BEHAVIOUR_FIELDS), ) logger.info("pipeline_template_seeded", template_id=template_id, name=name) row = await self.get(template_id) @@ -148,13 +223,20 @@ async def upsert_builtin( async def duplicate(self, template_id: str, new_name: str) -> dict[str, Any]: """Create a copy of an existing template with a new name. + The copy runs the same route and its jobs are treated the same way: + it takes the graph, the sandbox profile and what the row says about + its jobs (:data:`BEHAVIOUR_FIELDS`), so a copy of a template triage + is not offered is not offered either. It is no kind's default. + Raises ValueError if the source template does not exist. """ source = await self.get(template_id) if source is None: raise ValueError(f"Pipeline template not found: {template_id}") + behaviour: dict[str, Any] = {key: source.get(key) for key in BEHAVIOUR_FIELDS} return await self.create( name=new_name, graph_definition=source["graph_definition"], sandbox_profile=source["sandbox_profile"], + **behaviour, ) diff --git a/embry0/storage/repositories/progression.py b/embry0/storage/repositories/progression.py index 96b195cc..2ff8e471 100644 --- a/embry0/storage/repositories/progression.py +++ b/embry0/storage/repositories/progression.py @@ -23,33 +23,38 @@ from datetime import datetime from typing import Any, Protocol -from embry0.orchestration.dispatch_actors import PROPOSALS +from embry0.services.slop import SLOPMASTER from embry0.storage.database import DatabasePool +from embry0.storage.repositories.audit_jobs import AUDITOR, an_audit, is_audit from embry0.storage.schemas import FINISHED_WELL_STATUSES +from embry0.workflows.row_behaviour import DEFAULT_CODER, WORKFLOW_PIPELINES -# The character a job's code is credited to: the Slopmaster for a slop cleanup -# (spec 2026-10-25, slop dungeon), the developer otherwise. -_CODER = "CASE WHEN {job}.pipeline_template = 'slop-cleanup' THEN 'slopmaster' ELSE 'developer' END" - -#: The template a context audit runs on. -AUDIT_TEMPLATE = "context-audit" -#: The actor of a context-audit finding's fix job (``context_audit.FIX_ACTOR``, #240): the job an owner starts by -#: accepting one finding. It runs on :data:`AUDIT_TEMPLATE` but is no audit, so it earns no audit's credit (no -#: ``audited`` event, no lore, no ``audit_merged``); merged, it is credited as the proposal it is (#241). Every -#: check of progression for an audit reads :func:`an_audit` or :attr:`JobRow.is_audit`, never the template alone. -FIX_ACTOR = PROPOSALS +# Which jobs are context audits is embry0.storage.repositories.audit_jobs's, the one predicate the findings feed and +# the scheduler read too: a job the context auditor led (``jobs.leader_agent``, #167), a copy of the context-audit +# template included, and not a finding's fix job (FIX_ACTOR, #240), which earns no audit's credit (no ``audited`` +# event, no lore, no ``audit_merged``) and, merged, is credited as the proposal it is (#241). Every check of +# progression for an audit reads an_audit or JobRow.is_audit. def _literal(value: str) -> str: return "'" + value.replace("'", "''") + "'" -def an_audit(job: str) -> str: - """The SQL condition that the job aliased ``job`` is a context audit: on its template, and not a finding's fix job.""" - return ( - f"({job}.pipeline_template = {_literal(AUDIT_TEMPLATE)}" - f" AND {job}.dispatched_by IS DISTINCT FROM {_literal(FIX_ACTOR)})" - ) +#: The pipeline the code defines for a QA job, and the character it credits (``row_behaviour.WORKFLOW_PIPELINES``). +QA_PIPELINE = str(WORKFLOW_PIPELINES["qa"]["name"]) +_QA_AGENT = str(WORKFLOW_PIPELINES["qa"]["leader_agent"]) + + +def coder_sql(job: str) -> str: + """The character the pull request of the job aliased ``job`` is credited to: the agent that coded it, as + plan_route recorded it (``jobs.coding_agent``, #167: the Slopmaster for a slop cleanup, spec 2026-10-25), + the developer for a job that recorded none. What it recorded never moves with a later template edit.""" + return f"COALESCE({job}.coding_agent, {_literal(DEFAULT_CODER)})" + + +def a_cleanup(job: str) -> str: + """The SQL condition that the job aliased ``job`` is a slop cleanup: the Slopmaster coded it.""" + return f"{job}.coding_agent = {_literal(SLOPMASTER)}" #: The statuses of a job that finished its work, ``FINISHED_WELL_STATUSES`` in ``embry0/storage/schemas.py``: @@ -85,28 +90,28 @@ def finished_well(job: str) -> str: ), ( "COALESCE(j.delivery_state_at, j.finished_at)", - f"""-- the developer (the Slopmaster, for a slop cleanup): a pull request a job opened was merged -SELECT 1, j.job_id, 'merged', {_CODER.format(job="j")}, COALESCE(j.delivery_state_at, j.finished_at), + f"""-- the job's coder (coder_sql): a pull request a job opened was merged +SELECT 1, j.job_id, 'merged', {coder_sql("j")}, COALESCE(j.delivery_state_at, j.finished_at), NULL, NULL, NULL FROM jobs j WHERE j.status = 'pr_merged'""", ), ( "j.finished_at", - f"""-- the developer (or the Slopmaster): a job that opened a pull request, once it finished -SELECT 2, j.job_id, 'opened', {_CODER.format(job="j")}, j.finished_at, NULL, NULL, NULL + f"""-- the job's coder: a job that opened a pull request, once it finished +SELECT 2, j.job_id, 'opened', {coder_sql("j")}, j.finished_at, NULL, NULL, NULL FROM jobs j WHERE j.pr_url IS NOT NULL AND j.finished_at IS NOT NULL""", ), ( "COALESCE(j.delivery_state_at, j.finished_at)", - """-- the Slopmaster: a merged slop cleanup, with the lines it added and removed as its report -SELECT 3, j.job_id, 'cleaned', 'slopmaster', COALESCE(j.delivery_state_at, j.finished_at), + f"""-- the Slopmaster: a merged job it coded (a slop cleanup), with the lines it added and removed as its report +SELECT 3, j.job_id, 'cleaned', {_literal(SLOPMASTER)}, COALESCE(j.delivery_state_at, j.finished_at), (j.work_nature -> 'cleanup')::text, NULL, NULL -FROM jobs j WHERE j.status = 'pr_merged' AND j.pipeline_template = 'slop-cleanup'""", +FROM jobs j WHERE j.status = 'pr_merged' AND {a_cleanup("j")}""", ), ( "COALESCE(m.delivery_state_at, m.finished_at)", - f"""-- the developer (or the Slopmaster): a merge on an issue whose earlier job had failed -SELECT 4, m.job_id, 'comeback', {_CODER.format(job="m")}, COALESCE(m.delivery_state_at, m.finished_at), + f"""-- the job's coder: a merge on an issue whose earlier job had failed +SELECT 4, m.job_id, 'comeback', {coder_sql("m")}, COALESCE(m.delivery_state_at, m.finished_at), NULL, NULL, NULL FROM jobs m WHERE m.status = 'pr_merged' AND m.issue_id IS NOT NULL @@ -125,14 +130,14 @@ def finished_well(job: str) -> str: ), ( "j.finished_at", - f"""-- QA: a QA job that finished its work -SELECT 6, j.job_id, 'qa_passed', 'qa', j.finished_at, NULL, NULL, NULL -FROM jobs j WHERE j.pipeline_template = 'qa' AND {finished_well("j")}""", + f"""-- QA: a job of the QA pipeline that finished its work, credited to the pipeline's leader +SELECT 6, j.job_id, 'qa_passed', {_literal(_QA_AGENT)}, j.finished_at, NULL, NULL, NULL +FROM jobs j WHERE j.pipeline_template = {_literal(QA_PIPELINE)} AND {finished_well("j")}""", ), ( "j.finished_at", - f"""-- the context auditor: an audit that finished its work, with its report; a finding's fix job is no audit -SELECT 7, j.job_id, 'audited', 'context-auditor', j.finished_at, j.result_summary, NULL, NULL + f"""-- the context auditor: an audit it led that finished its work, with its report; a finding's fix job is no audit +SELECT 7, j.job_id, 'audited', {_literal(AUDITOR)}, j.finished_at, j.result_summary, NULL, NULL FROM jobs j WHERE {an_audit("j")} AND {finished_well("j")}""", ), ( @@ -223,6 +228,7 @@ async def journal(self, agents: Collection[str] | None = None) -> Journal: r["report"], r["delivery_state_at"], r["dispatched_by"], + r["leader"], ) for r in jobs if r["finished_at"] is not None @@ -289,8 +295,8 @@ def params(self) -> Mapping[str, Any]: ... # ``dispatched_by`` tells a context audit from a finding's fix job (an_audit). _FACTS_CTES = """ WITH merged AS ( - SELECT j.job_id, j.repo, j.issue_id, j.pr_url, j.pipeline_template, j.dispatched_by, j.area, j.total_cost_usd, - j.created_at, j.started_at, j.finished_at, + SELECT j.job_id, j.repo, j.issue_id, j.pr_url, j.pipeline_template, j.leader_agent, j.dispatched_by, j.area, + j.total_cost_usd, j.created_at, j.started_at, j.finished_at, COALESCE(j.delivery_state_at, j.finished_at, j.updated_at) AS at, COALESCE(j.repo, 'a job') || COALESCE(' #' || substring(j.pr_url from '([0-9]+)/?$'), '') AS label FROM jobs j @@ -401,9 +407,9 @@ def params(self) -> Mapping[str, Any]: ... WHERE t.agent_type = ANY({agents}::text[]) AND t.result = 'success' AND t.error_code IS NULL""", "review_posted": """ SELECT r.posted_at, 1, r.repo || ' #' || r.number, NULL, NULL FROM pr_reviews r WHERE r.posted_at IS NOT NULL""", - "qa_passed": """ + "qa_passed": f""" SELECT COALESCE(j.finished_at, j.updated_at), 1, j.repo, NULL, NULL - FROM jobs j WHERE j.pipeline_template = 'qa' AND """ + FROM jobs j WHERE j.pipeline_template = {_literal(QA_PIPELINE)} AND """ + finished_well("j"), "gate_blocked": """ SELECT COALESCE(j.finished_at, j.updated_at), 1, j.repo, j.pr_url, NULL @@ -501,13 +507,15 @@ class JobRow: report: str | None = None #: When its pull request reached the state merge sync last recorded, by the forge's clock; None without one. delivery_state_at: datetime | None = None - #: Who asked for it (``jobs.dispatched_by``): :data:`FIX_ACTOR` for a finding's fix job. + #: Who asked for it (``jobs.dispatched_by``): ``audit_jobs.FIX_ACTOR`` for a finding's fix job. dispatched_by: str | None = None + #: The leader of its template when it was planned (``jobs.leader_agent``, #167): the :data:`AUDITOR` for an audit. + leader: str | None = None @property def is_audit(self) -> bool: """A context audit, and not a finding's fix job: :func:`an_audit` for one row of the journal.""" - return self.template == AUDIT_TEMPLATE and self.dispatched_by != FIX_ACTOR + return is_audit(self.leader, self.dispatched_by) @property def merged_at(self) -> datetime | None: @@ -540,6 +548,7 @@ class Journal: _JOBS_SQL = f""" SELECT j.repo, j.issue_id, j.pipeline_template AS template, j.status, j.created_at, COALESCE(j.finished_at, j.updated_at) AS finished_at, j.delivery_state_at, j.dispatched_by, + j.leader_agent AS leader, CASE WHEN {an_audit("j")} AND {finished_well("j")} THEN j.result_summary END AS report FROM jobs j WHERE j.repo IS NOT NULL diff --git a/embry0/storage/repositories/proposals.py b/embry0/storage/repositories/proposals.py index ea99dc79..3c2263a8 100644 --- a/embry0/storage/repositories/proposals.py +++ b/embry0/storage/repositories/proposals.py @@ -9,9 +9,9 @@ from datetime import datetime from typing import Any -from embry0.services.context_audit import FIX_ACTOR from embry0.services.proposals import Decision from embry0.storage.database import DatabasePool +from embry0.storage.repositories.audit_jobs import an_audit from embry0.storage.schemas import FINISHED_WELL_STATUSES, JobStatus _CASES_SQL = """ @@ -44,25 +44,24 @@ # which is no earlier than merge sync recording the merge, so an unknown date can only leave # out more, never offer a claim the merge may have fixed. A pull request closed unmerged fixed # nothing, and its audit's findings stand. A newer audit that failed or has not finished is -# passed over. A finding's fix job ($3, context_audit.FIX_ACTOR) is no audit: whatever became +# passed over. A finding's fix job (context_audit.FIX_ACTOR) is no audit: whatever became # of it or its pull request, the audit before it is still the newest, and its other findings # are still offered; the one the job fixes was answered when its owner accepted it (#222). Its -# merge leaves no audit out either: it changed that one claim. -_AUDITS_SQL = """ +# merge leaves no audit out either: it changed that one claim. Which jobs are audits is +# audit_jobs.an_audit, as progression counts them (#167): a job the context auditor led. +_AUDITS_SQL = f""" SELECT newest.repo, newest.job_id, newest.finished_at, newest.result_summary FROM ( SELECT DISTINCT ON (j.repo) j.repo, j.job_id, j.status, j.created_at, COALESCE(j.finished_at, j.updated_at) AS finished_at, j.result_summary FROM jobs j - WHERE j.pipeline_template = 'context-audit' AND j.status = ANY($1::text[]) AND j.repo IS NOT NULL - AND NOT (COALESCE(j.dispatched_by, '') = ANY($3::text[])) + WHERE {an_audit("j")} AND j.status = ANY($1::text[]) AND j.repo IS NOT NULL ORDER BY j.repo, COALESCE(j.finished_at, j.updated_at) DESC ) newest WHERE newest.status <> $2 AND NOT EXISTS ( SELECT 1 FROM jobs merged - WHERE merged.repo = newest.repo AND merged.pipeline_template = 'context-audit' AND merged.status = $2 - AND NOT (COALESCE(merged.dispatched_by, '') = ANY($3::text[])) + WHERE merged.repo = newest.repo AND {an_audit("merged")} AND merged.status = $2 AND COALESCE(merged.delivery_state_at, merged.updated_at) > newest.created_at ) """ @@ -88,7 +87,7 @@ async def audits(self) -> list[dict[str, Any]]: until an audit that started after the merge finishes. A finding's fix job is no audit, and its merge does not count. """ - rows = await self._db.fetch(_AUDITS_SQL, list(FINISHED_WELL_STATUSES), JobStatus.PR_MERGED.value, [FIX_ACTOR]) + rows = await self._db.fetch(_AUDITS_SQL, list(FINISHED_WELL_STATUSES), JobStatus.PR_MERGED.value) return [dict(r) for r in rows] async def owners(self) -> dict[str, list[str]]: diff --git a/embry0/storage/repositories/world.py b/embry0/storage/repositories/world.py index 20013c15..4d77e335 100644 --- a/embry0/storage/repositories/world.py +++ b/embry0/storage/repositories/world.py @@ -11,6 +11,7 @@ from typing import Any from embry0.storage.database import DatabasePool +from embry0.storage.repositories.progression import a_cleanup #: Job statuses a party is still out on. LIVE_STATUSES = ("pending", "running", "awaiting_input", "paused") @@ -49,7 +50,7 @@ _JOBS_SQL = """ SELECT job_id, repo, task, status, pipeline_template, job_kind, created_at, started_at, finished_at, pr_url, delivery_state, area, issue_id, dispatched_by, current_stage, error_code, error_message, - account_wait + account_wait, coding_agent, leader_agent FROM jobs WHERE (status = ANY($2::text[]) OR COALESCE(finished_at, updated_at) > NOW() - make_interval(secs => $1)) @@ -84,13 +85,15 @@ # Slop dungeon (spec 2026-10-25): the slop cleanups merged in the last ``$1`` # days, by repository and area — what each Slop Dungeon's ``cleaned`` counts. -_CLEANED_SQL = """ -SELECT repo, area, COUNT(*) AS cleaned -FROM jobs -WHERE pipeline_template = 'slop-cleanup' AND repo IS NOT NULL - AND (status = 'pr_merged' OR delivery_state = 'merged') - AND COALESCE(delivery_state_at, finished_at, created_at) > NOW() - make_interval(days => $1) -GROUP BY repo, area +# A slop cleanup is a job the Slopmaster coded, as plan_route recorded it +# (#167, progression.a_cleanup). +_CLEANED_SQL = f""" +SELECT j.repo, j.area, COUNT(*) AS cleaned +FROM jobs j +WHERE {a_cleanup("j")} AND j.repo IS NOT NULL + AND (j.status = 'pr_merged' OR j.delivery_state = 'merged') + AND COALESCE(j.delivery_state_at, j.finished_at, j.created_at) > NOW() - make_interval(days => $1) +GROUP BY j.repo, j.area """ @@ -141,7 +144,7 @@ SELECT j.job_id, j.repo, j.context, j.area, j.task, j.status, j.delivery_state, j.delivery_state_at, j.pr_url, j.pipeline_template, j.job_kind, j.created_at, j.started_at, j.finished_at, j.error_code, j.error_message, j.total_cost_usd, j.result_summary, j.dispatched_by, j.issue_id, j.account_wait, j.work_nature, - r.rating, r.verdict, r.note, r.followup_job_id, r.rated_by, r.rated_at + j.coding_agent, j.leader_agent, r.rating, r.verdict, r.note, r.followup_job_id, r.rated_by, r.rated_at FROM jobs j LEFT JOIN job_ratings r ON r.job_id = j.job_id WHERE j.job_id = $1 """ diff --git a/embry0/storage/seeds/agents_builtin.py b/embry0/storage/seeds/agents_builtin.py index 3c042ead..01540493 100644 --- a/embry0/storage/seeds/agents_builtin.py +++ b/embry0/storage/seeds/agents_builtin.py @@ -45,6 +45,8 @@ async def seed_missing_builtin_agents(repo: AgentDefinitionsRepository) -> None: tagline=seed_fields["tagline"], runs_in_jobs=seed_fields.get("runs_in_jobs", True), triage_may_add_skills=seed_fields.get("triage_may_add_skills", []), + station=seed_fields["station"], + quest_template=seed_fields["quest_template"], ) # create() marks rows non-builtin (it serves the custom-agent API); # only seeders may set is_builtin, so flip it via direct SQL — the diff --git a/embry0/storage/seeds/pipeline_templates_builtin.py b/embry0/storage/seeds/pipeline_templates_builtin.py index 82197fbc..ac82608d 100644 --- a/embry0/storage/seeds/pipeline_templates_builtin.py +++ b/embry0/storage/seeds/pipeline_templates_builtin.py @@ -1,10 +1,13 @@ """Builtin pipeline-template seeding. Run at orchestrator startup to upsert the canonical embry0 starter -pipelines. The seeds overwrite every field but ``default_for_kind`` (per -upsert_builtin): the default an operator moved or cleared stays where they -put it. Users who want a customized variant should clone the builtin under -a different name. +pipelines. The seeds overwrite the graph, the description and the sandbox +profile (per upsert_builtin), not the operator's decisions: the default an +operator moved or cleared, and what a template's row says about its jobs +(``fixed_route``, ``offered_to_triage``, ``job_kind``, ``delivers``, +``leader_agent``; #167, ``embry0.workflows.row_behaviour``), stay where they +put them. A first insert takes the seed's. Users who want a customized +variant should clone the builtin under a different name. Each template is a small graph in the same shape the frontend stores under ``graph_definition``: ``{nodes: [...], edges: [...], metadata: {...}}``. @@ -17,6 +20,7 @@ import structlog from embry0.storage.repositories.pipeline_templates import PipelineTemplatesRepository +from embry0.workflows.row_behaviour import TEMPLATE_DEFAULTS logger = structlog.get_logger(__name__) @@ -56,15 +60,21 @@ def _graph(name: str, description: str, nodes: list[dict[str, Any]], edges: list } -def _specialist_route(name: str, agent_type: str, description: str) -> dict[str, Any]: +def _specialist_route(name: str, agent_type: str, description: str, *, delivers: str = "") -> dict[str, Any]: """A specialist route: triage, the specialist on the generic node, output. No ``default_for_kind``, and triage is not offered it: a job reaches it - only by naming the template (``SPECIALIST_TEMPLATE_NAMES``), and - plan_route records it as an analysis job (``specialist_job_kind``). + only by naming the template, and then skips triage (``fixed_route``). + plan_route records it as an analysis job, what a route of specialists + is (``row_behaviour.recorded_kind``: no ``job_kind`` overrides it), and + the world draws it as its specialist (``leader_agent``). """ return { "description": description, + "fixed_route": True, + "offered_to_triage": False, + "leader_agent": agent_type, + "delivers": delivers, "graph_definition": _graph( name=name, description=f"Triage → {agent_type} → output.", @@ -163,6 +173,7 @@ def _specialist_route(name: str, agent_type: str, description: str) -> dict[str, ), "sandbox_profile": "slim", "default_for_kind": "research", + "leader_agent": "research", }, "analysis-default": { "description": ( @@ -184,6 +195,8 @@ def _specialist_route(name: str, agent_type: str, description: str) -> dict[str, ), "sandbox_profile": "slim", "default_for_kind": "analysis", + "leader_agent": "analysis", + "delivers": "an analysis of the production logs, and a ticket when it finds a problem worth one", }, "ops-default": { "description": ( @@ -205,6 +218,7 @@ def _specialist_route(name: str, agent_type: str, description: str) -> dict[str, ), "sandbox_profile": "slim", "default_for_kind": "ops", + "leader_agent": "ops", }, # Slop dungeon (spec 2026-10-25): the route of a code job triage judged # slop, not a bug. The Slopmaster runs on the developer node (its step @@ -235,6 +249,7 @@ def _specialist_route(name: str, agent_type: str, description: str) -> dict[str, ], ), "sandbox_profile": "slim", + "leader_agent": "slopmaster", }, "pr-followup": { "description": ( @@ -263,8 +278,8 @@ def _specialist_route(name: str, agent_type: str, description: str) -> dict[str, }, # One route per specialist agent (specialist_agents.py). Each is # read-only and delivers one report, and the descriptions say so on the - # Pipelines page. Triage is not offered these (SPECIALIST_TEMPLATE_NAMES): - # a job runs one only by naming it. + # Pipelines page. Triage is not offered these (offered_to_triage): a job + # runs one only by naming it. "kintsugi-trace": _specialist_route( "kintsugi-trace", "kintsugi", @@ -320,15 +335,22 @@ def _specialist_route(name: str, agent_type: str, description: str) -> dict[str, "Read-only: triage → context-auditor → output. Checks the files that steer a coding agent " "against the code and its history and delivers the stale lines with their evidence and a " "diff that corrects the certain ones.", + delivers="a report of the claims that look wrong in the files that steer coding agents", ), "map-areas": _specialist_route( "map-areas", "area-mapper", "Read-only: triage → area-mapper → output. Maps a monorepo's parts (paths, the names their " "logs run under, owners) into .embry0/areas.yml; the output step opens the pull request.", + delivers="a pull request that maps the repository's areas", ), } +# Every template's row says what #167's columns say of it; a seed that leaves one out takes the column's default. +for _seed in BUILTIN_PIPELINE_TEMPLATES.values(): + for _key, _default in TEMPLATE_DEFAULTS.items(): + _seed.setdefault(_key, _default) + async def seed_builtin_pipeline_templates(repo: PipelineTemplatesRepository) -> None: """Idempotently upsert all builtin pipeline templates.""" diff --git a/embry0/workflows/issue_to_pr/nodes/_developer.py b/embry0/workflows/issue_to_pr/nodes/_developer.py index 4607aaec..804ae775 100644 --- a/embry0/workflows/issue_to_pr/nodes/_developer.py +++ b/embry0/workflows/issue_to_pr/nodes/_developer.py @@ -847,10 +847,10 @@ def _branch_missing_explanation(branch: str, diagnostics: dict[str, Any]) -> str def _coding_agent(state: dict[str, Any]) -> str: """The agent this node runs as (spec 2026-10-25, slop dungeon): the Slopmaster on a slop-cleanup route, else the developer. A job planned before then has none.""" - from embry0.workflows.issue_to_pr.route_plan import CODING_AGENT_TYPES + from embry0.workflows.row_behaviour import CODING_AGENT_TYPES, DEFAULT_CODER - agent = str(state.get("coding_agent") or "developer") - return agent if agent in CODING_AGENT_TYPES else "developer" + agent = str(state.get("coding_agent") or DEFAULT_CODER) + return agent if agent in CODING_AGENT_TYPES else DEFAULT_CODER def _fallback_system_prompt(coding_agent: str) -> str: @@ -1363,12 +1363,8 @@ def _forward_event(event: dict[str, Any]) -> None: if held is not None: goto = held elif state.get("route_plan"): - from embry0.workflows.issue_to_pr.route_plan import ( - CODING_AGENT_TYPES, - ROUTE_TARGETS, - advance, - next_route, - ) + from embry0.workflows.issue_to_pr.route_plan import ROUTE_TARGETS, advance, next_route + from embry0.workflows.row_behaviour import CODING_AGENT_TYPES # RAV-1271: advance only when the cursor is ON a developer # step (fresh forward progression). Re-entry paths — the diff --git a/embry0/workflows/issue_to_pr/nodes/_salvage.py b/embry0/workflows/issue_to_pr/nodes/_salvage.py index 7c26115e..17f72d07 100644 --- a/embry0/workflows/issue_to_pr/nodes/_salvage.py +++ b/embry0/workflows/issue_to_pr/nodes/_salvage.py @@ -29,19 +29,18 @@ import embry0.workflows.issue_to_pr.nodes as _nodes from embry0.services.progression_templates import Field, parse, render from embry0.settings.sections.salvage import MAX_SUBJECT_CHARS +from embry0.workflows.row_behaviour import CODING_AGENT_TYPES, DEFAULT_CODER logger = structlog.get_logger(__name__) -#: The agents the developer node runs; their outputs are the coding run's. -CODING_AGENTS = frozenset({"developer", "slopmaster"}) - BUDGET = "budget reached" QUESTIONS = "question rounds used up" def _last_coding_output(result: dict[str, Any]) -> dict[str, Any] | None: for out in reversed(result.get("agent_outputs") or []): - if isinstance(out, dict) and out.get("agent_type") in CODING_AGENTS: + # The agents the developer node runs: their outputs are the coding run's. + if isinstance(out, dict) and out.get("agent_type") in CODING_AGENT_TYPES: return dict(out) return None @@ -49,7 +48,7 @@ def _last_coding_output(result: dict[str, Any]) -> dict[str, Any] | None: def coding_agent(result: dict[str, Any], state: dict[str, Any]) -> str: """The agent the run was: its output's type, else the state's coding agent, else the developer.""" out = _last_coding_output(result) - return str((out or {}).get("agent_type") or state.get("coding_agent") or "developer") + return str((out or {}).get("agent_type") or state.get("coding_agent") or DEFAULT_CODER) def stop_reason(result: dict[str, Any]) -> str | None: diff --git a/embry0/workflows/issue_to_pr/nodes/_triage.py b/embry0/workflows/issue_to_pr/nodes/_triage.py index b41fdad5..1914e32e 100644 --- a/embry0/workflows/issue_to_pr/nodes/_triage.py +++ b/embry0/workflows/issue_to_pr/nodes/_triage.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from collections.abc import Mapping from typing import Any import structlog @@ -27,7 +28,7 @@ _format_user_answers_block, _max_turns_recovery_session, ) -from embry0.workflows.issue_to_pr.specialist_agents import SPECIALIST_TEMPLATE_NAMES +from embry0.workflows.row_behaviour import fixed_route, offered_to_triage, recorded_kind logger = structlog.get_logger(__name__) @@ -53,24 +54,29 @@ def _log_triage_agent_failure(event: str, output: dict[str, Any], *, fallback: s return message -def fixed_route_decision(state: dict[str, Any]) -> dict[str, Any] | None: - """The decision for a job whose specialist template fixes its route, or None when triage must decide. +def fixed_route_decision(state: dict[str, Any], template: Mapping[str, Any] | None) -> dict[str, Any] | None: + """The decision for a job whose named template fixes its route, or None when triage must decide. - A re-entry with something to decide (a QA failure, a person's answers) - still goes to the agent. + ``template`` is the row of the template the job names + (``plan_route.pinned_template``); its ``fixed_route`` says whether triage + has anything to choose (#167). The decision records the kind the row + says its jobs are (``job_kind``, else the kind it is the default for), + or triage's default kind when it says none. A re-entry with something to + decide (a QA failure, a person's answers) still goes to the agent. """ from embry0.orchestration.nodes.triage import parse_triage_response requested = str(state.get("pipeline_template_request") or "") - if requested not in SPECIALIST_TEMPLATE_NAMES: + if not requested or template is None or template.get("name") != requested or not fixed_route(template): return None if (state.get("qa") or {}).get("final_status") == "failed" or state.get("user_answers"): return None + kind = recorded_kind(template) decision = parse_triage_response( json.dumps( { "action": "proceed", - "job_kind": "analysis", + **({"job_kind": kind} if kind else {}), "confidence": 1.0, "pipeline_template": requested, "reasoning": f"The job names the {requested} template; its route is fixed.", @@ -100,13 +106,17 @@ async def triage_node(state: dict[str, Any], config: RunnableConfig) -> dict[str attempt = agent_attempt(state, "triage") writer(events.node_started(node="triage", agent="triage", attempt=attempt)) - # A job that names a specialist template (a context audit, a map of a - # repository's areas, a design audit) already has its route: plan_route - # runs the named template whatever triage picks. Asking the classifier - # anyway costs a model call, and handed the specialist's task with the - # repository's tools it starts doing that task itself and can run out of - # time before it decides. - fixed = fixed_route_decision(state) + # A job that names a template whose route is fixed (a specialist's: a + # context audit, a map of a repository's areas, a design audit) already + # has its route: plan_route runs the named template whatever triage picks. + # Asking the classifier anyway costs a model call, and handed the + # specialist's task with the repository's tools it starts doing that task + # itself and can run out of time before it decides. + from embry0.workflows.issue_to_pr.plan_route import pinned_template, triage_config_of + + templates_repo = config["configurable"].get("pipeline_templates_repo") + pinned = await pinned_template(state, templates_repo) + fixed = fixed_route_decision(state, pinned) if fixed is not None: from embry0.orchestration.nodes.triage import apply_job_config_overrides @@ -184,7 +194,6 @@ async def triage_node(state: dict[str, Any], config: RunnableConfig) -> dict[str # RAV-601: list available templates so triage can pick one by NAME. Rides # the user prompt — the system prompt carries nothing job-specific, so # CLI prompt caching keeps covering it (EMB-35). - templates_repo = config["configurable"].get("pipeline_templates_repo") if templates_repo is not None: try: template_rows = await templates_repo.list_all() @@ -193,8 +202,8 @@ async def triage_node(state: dict[str, Any], config: RunnableConfig) -> dict[str template_rows = [] template_lines = [] for row in template_rows: - # A specialist runs only when the job names its template. - if row["name"] in SPECIALIST_TEMPLATE_NAMES: + # A template triage is not offered (a specialist's) runs only when the job names it. + if not offered_to_triage(row): continue entry = f"- {row['name']}" if row.get("default_for_kind"): @@ -286,9 +295,7 @@ async def triage_node(state: dict[str, Any], config: RunnableConfig) -> dict[str # #468: a template the job named up front is known before triage runs, # so its triage node's config (model, tools, skills) applies here the way # a step's config applies to its agent. - from embry0.workflows.issue_to_pr.plan_route import pinned_triage_template_config - - triage_template_config = await pinned_triage_template_config(state, templates_repo) + triage_template_config = triage_config_of(pinned) if templates_repo is not None else None # Collect agent events locally so we can scan for agent_ask_user events # after the agent finishes. We still forward every event to the graph diff --git a/embry0/workflows/issue_to_pr/plan_route.py b/embry0/workflows/issue_to_pr/plan_route.py index 6dcf5ad8..68e48892 100644 --- a/embry0/workflows/issue_to_pr/plan_route.py +++ b/embry0/workflows/issue_to_pr/plan_route.py @@ -30,10 +30,9 @@ LEGACY_ROUTE_PLAN, build_route_plan, coding_agent_of, - specialist_job_kind, triage_template_config, ) -from embry0.workflows.issue_to_pr.specialist_agents import SPECIALIST_TEMPLATE_NAMES +from embry0.workflows.row_behaviour import builtin_template, leader, offered_to_triage, recorded_kind logger = structlog.get_logger(__name__) @@ -263,21 +262,59 @@ async def _find_template( return None -async def pinned_triage_template_config(state: dict[str, Any], templates_repo: Any) -> dict[str, Any] | None: - """The triage node config of the template the job named up front (#468). +async def template_row(templates_repo: Any, name: str) -> dict[str, Any] | None: + """The template ``name``'s row, for what it says about its jobs (#167); None when there is none. - Only a template named on the job (``pipeline_template_request``) is known - before triage runs; one that triage picks, or a kind default, is resolved - after it. Best-effort: a lookup failure leaves triage on its usual model. + Without a repository (no database) the built-in template as seeded + answers (``row_behaviour.builtin_template``): every deployment has those + rows. A read that fails raises its error: the seed's answer is no + stand-in for it, since an operator may have changed the row. Each + caller says what an unknown row means. """ - requested = str(state.get("pipeline_template_request") or "") - if templates_repo is None or requested in _LEGACY_TEMPLATE_VALUES: + if not name or name in _LEGACY_TEMPLATE_VALUES: return None + if templates_repo is None: + return builtin_template(name) + return await _find_template(templates_repo, name=name) + + +async def pinned_template(state: dict[str, Any], templates_repo: Any) -> dict[str, Any] | None: + """The row of the template the job named up front (``pipeline_template_request``), or None. + + Only such a template is known before triage runs; one that triage picks, + or a kind default, is resolved after it (``template_row``). None too when + the templates cannot be read: then no route is fixed, and triage runs. + """ + name = str(state.get("pipeline_template_request") or "") try: - row = await _find_template(templates_repo, name=requested) + return await template_row(templates_repo, name) except Exception: - logger.warning("triage_template_lookup_failed", template=requested, exc_info=True) + logger.warning("template_lookup_failed", template=name, exc_info=True) return None + + +async def _offered_to_triage(templates_repo: Any, name: str) -> bool: + """Whether triage may pick ``name``: its row's ``offered_to_triage``; a name with no row is (it is not + found later, and logged); when the templates cannot be read, no (the kind default routes instead).""" + try: + return offered_to_triage(await template_row(templates_repo, name)) + except Exception: + logger.warning("template_lookup_failed", template=name, exc_info=True) + return False + + +async def pinned_triage_template_config(state: dict[str, Any], templates_repo: Any) -> dict[str, Any] | None: + """The triage node config of the template the job named up front (#468). + + Best-effort: a lookup failure leaves triage on its usual model. + """ + if templates_repo is None: + return None + return triage_config_of(await pinned_template(state, templates_repo)) + + +def triage_config_of(row: dict[str, Any] | None) -> dict[str, Any] | None: + """The overrides of a template row's triage node (#468); None for no row or a node that sets nothing.""" if row is None: return None return triage_template_config(row.get("graph_definition") or {}) @@ -308,7 +345,8 @@ async def plan_route_node(state: dict[str, Any], config: RunnableConfig) -> dict """Resolve template → route_plan snapshot. Resolution order: 1. triage's explicit ``pipeline_template`` name (legacy values skipped, - and a specialist template, which runs only when the job names it); + and a template triage is not offered, which runs only when the job + names it: ``offered_to_triage``, #167); 2. the template whose ``default_for_kind`` matches the job_kind; 3. the legacy synthesized plan (bit-for-bit today's dev→review→qa chain). @@ -340,44 +378,46 @@ async def plan_route_node(state: dict[str, Any], config: RunnableConfig) -> dict requested, job_kind = "", "code" explicit = requested not in _LEGACY_TEMPLATE_VALUES - # Slop dungeon (spec 2026-10-25): a code job triage judged slop, not a - # bug, runs the Slopmaster's cleanup. It keeps review and QA, so a - # watcher-origin job may take it too; a template a person named wins - # below. A missing slop-cleanup template falls to the kind default. - work_nature = parse_work_nature(decision.get("work_nature")) - if is_slop(work_nature) and job_kind == "code": - logger.info( - "plan_route_slop_cleanup", - job_id=state.get("job_id"), - triage_template=requested or None, - slop_score=(work_nature or {}).get("slop_score"), - ) - requested, explicit = SLOP_TEMPLATE, True - # RAV-1058: a template named on JobCreateRequest.pipeline_template is an # operator instruction — it outranks triage's pick. Surfaced on state by # IssueExecutor._run_workflow (the job-row column itself is overwritten # with the resolved name below, so the request rides its own state key). requested_user = str(state.get("pipeline_template_request") or "") explicit_user = requested_user not in _LEGACY_TEMPLATE_VALUES + templates_repo = config["configurable"].get("pipeline_templates_repo") - # A specialist template runs only when the job names it. Triage is not - # offered one, so its pick of one is dropped and the kind default routes. - if requested in SPECIALIST_TEMPLATE_NAMES and requested != requested_user: + # A template triage is not offered (its row's offered_to_triage, #167: a + # specialist's, say) runs only when the job names it, so triage's pick of + # one is dropped and the kind default routes. + if explicit and requested != requested_user and not await _offered_to_triage(templates_repo, requested): logger.info( "plan_route_triage_pick_overridden", job_id=state.get("job_id"), - reason="a specialist template runs only when the job names it", + reason="a template triage is not offered runs only when the job names it", triage_template=requested, triage_kind=job_kind, ) requested, explicit = "", False - templates_repo = config["configurable"].get("pipeline_templates_repo") + # Slop dungeon (spec 2026-10-25): a code job triage judged slop, not a + # bug, runs the Slopmaster's cleanup. It keeps review and QA, so a + # watcher-origin job may take it too; a template a person named wins + # below. A missing slop-cleanup template falls to the kind default. + work_nature = parse_work_nature(decision.get("work_nature")) + if is_slop(work_nature) and job_kind == "code": + logger.info( + "plan_route_slop_cleanup", + job_id=state.get("job_id"), + triage_template=requested or None, + slop_score=(work_nature or {}).get("slop_score"), + ) + requested, explicit = SLOP_TEMPLATE, True + plan: list[dict[str, Any]] | None = None resolved_name: str | None = None resolved_kind: str | None = None resolved_graph: dict[str, Any] | None = None + resolved_row: dict[str, Any] | None = None explicit_invalid = False if templates_repo is not None: @@ -407,8 +447,9 @@ async def plan_route_node(state: dict[str, Any], config: RunnableConfig) -> dict continue plan = build_route_plan(graph) resolved_name = row["name"] - resolved_kind = row.get("default_for_kind") or specialist_job_kind(plan) + resolved_kind = recorded_kind(row) resolved_graph = graph + resolved_row = row break if plan is None: @@ -425,8 +466,9 @@ async def plan_route_node(state: dict[str, Any], config: RunnableConfig) -> dict # triage's opinion. When a template wins (user-requested or kind-default) # its ``default_for_kind`` is the honest job_kind — a watcher run pinned to # ``analysis-default`` is an analysis job even if triage guessed ``code``. - # A specialist route declares no kind, and its agents make it analysis. - # Otherwise fall back to triage's kind (the legacy path, a custom template). + # A template that is no kind's default says its jobs' kind on its row + # (``job_kind``, #167: a specialist's is analysis). Otherwise fall back to + # triage's kind (the legacy path, a template that says none). # Triage's raw opinion stays in state["triage_decision"] for audit. effective_kind = resolved_kind or job_kind @@ -449,11 +491,17 @@ async def plan_route_node(state: dict[str, Any], config: RunnableConfig) -> dict str(state.get("task") or ""), ticket_sections=job_section(state, DONE_WHEN).ticket_sections, ) + # #167: who codes the job and who leads its template, as they are now: + # progression and the world credit the job from these, so a later edit + # of the template row changes no credit it earned. + coding_agent = coding_agent_of(plan) if jobs_repo is not None: try: await jobs_repo.update( state.get("job_id", ""), job_kind=effective_kind, + coding_agent=coding_agent, + leader_agent=leader(resolved_row), **({"pipeline_template": resolved_name} if resolved_name else {}), **({"work_nature": work_nature} if work_nature is not None else {}), **({"done_when": done_when} if done_when is not None else {}), @@ -498,7 +546,7 @@ async def plan_route_node(state: dict[str, Any], config: RunnableConfig) -> dict "route_cursor": 0, "route_template_name": resolved_name, "work_nature": work_nature, - "coding_agent": coding_agent_of(plan), + "coding_agent": coding_agent, "done_when": done_when, } diff --git a/embry0/workflows/issue_to_pr/route_plan.py b/embry0/workflows/issue_to_pr/route_plan.py index df0cc66d..be5012bc 100644 --- a/embry0/workflows/issue_to_pr/route_plan.py +++ b/embry0/workflows/issue_to_pr/route_plan.py @@ -18,6 +18,7 @@ import structlog from embry0.workflows.issue_to_pr.specialist_agents import SPECIALIST_AGENT_TYPES +from embry0.workflows.row_behaviour import CODING_AGENT_TYPES, DEFAULT_CODER logger = structlog.get_logger(__name__) @@ -30,11 +31,11 @@ # type needs its BUILTIN_SEED row and an entry here. GENERIC_AGENT_TYPES: tuple[str, ...] = ("research", "analysis", "ops", *SPECIALIST_AGENT_TYPES) -# The agents that run on the developer node (spec 2026-10-25, slop dungeon): -# the developer, and the Slopmaster, whose slop-cleanup step is a cleanup on -# the same branch, push and PR machinery. plan_route records which one a job -# runs as state["coding_agent"]; the node runs that definition under that type. -CODING_AGENT_TYPES: tuple[str, ...] = ("developer", "slopmaster") +# The agents that run on the developer node (spec 2026-10-25, slop dungeon) are +# row_behaviour.CODING_AGENT_TYPES: the developer, and the Slopmaster, whose +# slop-cleanup step is a cleanup on the same branch, push and PR machinery. +# plan_route records which one a job runs as state["coding_agent"]; the node +# runs that definition under that type. # Template agent_type → physical LangGraph node. ``qa`` maps to the head of # the whole QA chain (init_orchestrator → orchestrate_qa → qa_report → @@ -100,21 +101,6 @@ def build_route_plan(graph_definition: dict[str, Any]) -> list[dict[str, Any]]: return plan -def specialist_job_kind(plan: list[dict[str, Any]]) -> str | None: - """``"analysis"`` for a route whose agents are all specialists, else None. - - A specialist route (kintsugi-trace, interrogate, ...) declares no - ``default_for_kind`` and is never triage's pick: a job reaches it only by - naming it (``SPECIALIST_TEMPLATE_NAMES``). Its agent examines a cloned - repository and reports, which is what the analysis kind means, so - plan_route records the job as one whatever kind triage guessed. - """ - agents = [step.get("agent_type") for step in plan if step.get("agent_type") != "output"] - if agents and all(agent in SPECIALIST_AGENT_TYPES for agent in agents): - return "analysis" - return None - - def _step_target(step: dict[str, Any]) -> str: """Dispatcher key for a step ('developer' | 'review' | 'qa' | 'output' | 'agent'). @@ -260,7 +246,7 @@ def coding_agent_of(plan: list[dict[str, Any]]) -> str: for step in plan: if step.get("agent_type") in CODING_AGENT_TYPES: return str(step["agent_type"]) - return "developer" + return DEFAULT_CODER def advance(state: dict[str, Any]) -> dict[str, Any]: diff --git a/embry0/workflows/issue_to_pr/specialist_agents.py b/embry0/workflows/issue_to_pr/specialist_agents.py index e3569c80..13015f37 100644 --- a/embry0/workflows/issue_to_pr/specialist_agents.py +++ b/embry0/workflows/issue_to_pr/specialist_agents.py @@ -14,8 +14,10 @@ ``agent_definitions`` via ``BUILTIN_SEED`` and restored by ``POST /agents/{type}/reset``. Each runs single-pass over a cloned repository and returns one Markdown deliverable as its final message. A job reaches one -only by naming its pipeline template (``SPECIALIST_TEMPLATE_NAMES``): triage -is not offered those templates, and plan_route drops triage naming one. +only by naming its pipeline template, whose row says so (#167, +``embry0.workflows.row_behaviour``): triage is not offered it +(``offered_to_triage``), plan_route drops triage naming one, and a job that +names one skips triage (``fixed_route``). """ from embry0.agents.output_text import MAX_OUTPUT_CHARS @@ -882,22 +884,3 @@ } SPECIALIST_AGENT_TYPES: tuple[str, ...] = tuple(SPECIALIST_SYSTEM_PROMPTS) - -# The built-in pipeline templates that run them (pipeline_templates_builtin.py), -# one per agent. A job runs one only by naming it: triage's template list -# leaves them out, and plan_route drops triage naming one, so a job that names -# no template never ends as a report when it asked for code. -SPECIALIST_TEMPLATE_NAMES: frozenset[str] = frozenset( - { - "kintsugi-trace", - "garden", - "interrogate", - "why", - "how", - "blast-radius", - "comment-pass", - "design-audit", - "context-audit", - "map-areas", - } -) diff --git a/embry0/workflows/pr_review/agent_seed.py b/embry0/workflows/pr_review/agent_seed.py index a6a1114c..c5201977 100644 --- a/embry0/workflows/pr_review/agent_seed.py +++ b/embry0/workflows/pr_review/agent_seed.py @@ -55,6 +55,8 @@ "auth_mode": None, "mcp_servers": {}, **BUILTIN_IDENTITY["pr-reviewer"], + # Where the guild hall draws it (#167). + "station": "lectern", } @@ -80,6 +82,7 @@ async def seed_pr_reviewer_agent(repo: AgentDefinitionsRepository) -> None: avatar=seed["avatar"], color=seed["color"], tagline=seed["tagline"], + station=seed["station"], ) # is_builtin is outside update()'s fields: only seeders set it (seed_onboarding_agent does the same). await repo._db.execute( # noqa: SLF001 diff --git a/embry0/workflows/qa/agent_seed.py b/embry0/workflows/qa/agent_seed.py index 1b6e5c04..0eb63352 100644 --- a/embry0/workflows/qa/agent_seed.py +++ b/embry0/workflows/qa/agent_seed.py @@ -171,6 +171,8 @@ "auth_mode": None, "mcp_servers": PLAYWRIGHT.mcp_servers(), **BUILTIN_IDENTITY["qa"], + # Where the guild hall draws it (#167). + "station": "alchemy_table", } @@ -197,6 +199,7 @@ async def seed_qa_agent(repo: AgentDefinitionsRepository) -> None: avatar=QA_AGENT_SEED["avatar"], color=QA_AGENT_SEED["color"], tagline=QA_AGENT_SEED["tagline"], + station=QA_AGENT_SEED["station"], ) # is_builtin is outside update()'s fields on purpose: only seeders mark a # row as builtin, so it is set via direct SQL. diff --git a/embry0/workflows/row_behaviour.py b/embry0/workflows/row_behaviour.py new file mode 100644 index 00000000..21df6617 --- /dev/null +++ b/embry0/workflows/row_behaviour.py @@ -0,0 +1,307 @@ +"""What a pipeline template's row, or an agent's, says about how embry0 treats it (#167). + +Behaviour that depends on a template or an agent is a column of its row, not +a list of built-in names, so an operator's own templates and agents take +part the way the built-in ones do. A template's row says: + +- ``fixed_route``: a job that names the template skips triage, whose choice + is already made (``_triage.fixed_route_decision``); +- ``offered_to_triage``: triage's list of templates names it, and plan_route + keeps triage's pick of it; a template that is not offered runs only when + the job names it; +- ``job_kind``: the kind a job of the template is recorded as, an + operator's override; when it is NULL the kind is the one the template is + the default for, else the one its route makes it (a route of specialists + only is analysis: :func:`recorded_kind`); +- ``delivers``: what a job of the template hands back, in the person's + words, for the job's brief; +- ``leader_agent``: the character the world draws a job of the template as + before an agent has started. plan_route records it on the job + (``jobs.leader_agent``) with the agent that coded it (``jobs.coding_agent``), + and a job's outcomes are credited from what it recorded, so a later edit + of the row changes no past credit. + +An agent's row says: + +- ``station``: where the guild hall draws it when it is home; +- ``quest_template``: whether the guild can give it a task, and what that + task runs: NULL is no task, ``''`` a task that names no template (triage + picks the route; a coding agent's only, the developer's), a name that + template. + +The pipelines the code defines (QA, onboarding, pull-request review) have +no template row: :data:`WORKFLOW_PIPELINES` says the same of them. Without a +database the built-in rows as they are seeded answer +(:func:`builtin_template`, :func:`builtin_agent`). A row read without one of +these columns (a fake, a summary from an older release) reads as its +column's default: the behaviour of a template or an agent of the +deployment's own before #167. + +The built-in rows answer only where there is no database at all. Where a +read of the rows fails, nothing is known: no route is fixed, so triage +runs, and triage's pick of a template is not trusted (plan_route). +""" + +from __future__ import annotations + +from collections.abc import Collection, Iterable, Mapping +from dataclasses import dataclass +from types import MappingProxyType +from typing import Any, Final + +from embry0.agents.identity import DEFAULT_STATION, STATIONS, validate_station + +#: The kinds a template may record its jobs as: ``orchestration.state.JobKind``. +JOB_KINDS: Final[tuple[str, ...]] = ("code", "research", "analysis", "ops") +#: The longest ``delivers`` a template may say: it is one line of a brief's prompt. +DELIVERS_MAX: Final = 300 +#: The longest name a template or an agent column may hold (``pipeline_templates.name`` is at most 100). +NAME_MAX: Final = 100 + +#: The agent types the developer node runs: the developer, and the Slopmaster on a slop-cleanup route +#: (spec 2026-10-25, slop dungeon). They are the graph's own vocabulary (``_validation.FIXED_NODE_TYPES``), +#: not rows: route_plan maps their steps to the developer node, salvage reads their outputs, and plan_route +#: records the one a job's route runs (``jobs.coding_agent``), whom its pull request is credited to. +CODING_AGENT_TYPES: Final[tuple[str, ...]] = ("developer", "slopmaster") +#: The coding agent of a route that names no other, and of a job that recorded none. +DEFAULT_CODER: Final = "developer" + +#: Each column #167 added to ``pipeline_templates``, with what a row without it means. +TEMPLATE_DEFAULTS: Final[Mapping[str, Any]] = MappingProxyType( + {"fixed_route": False, "offered_to_triage": True, "job_kind": None, "delivers": "", "leader_agent": None} +) +#: Each column #167 added to ``agent_definitions``, with what a row without it means. +AGENT_DEFAULTS: Final[Mapping[str, Any]] = MappingProxyType({"station": DEFAULT_STATION, "quest_template": None}) + +#: The pipelines the code defines, which have no template row: a job of one names it in +#: ``pipeline_template`` (``POST /jobs`` with ``pipeline``). What they lead with and deliver is the +#: code's, said here once for the world, progression and the job briefs. +WORKFLOW_PIPELINES: Final[Mapping[str, Mapping[str, Any]]] = MappingProxyType( + { + "qa": MappingProxyType({"name": "qa", "leader_agent": "qa", "delivers": "a test report"}), + "pr-review": MappingProxyType( + {"name": "pr-review", "leader_agent": "pr-reviewer", "delivers": "a review on the pull request"} + ), + "onboard": MappingProxyType( + {"name": "onboard", "leader_agent": "onboarding", "delivers": "a test setup for the repository"} + ), + } +) + + +def _column(row: Mapping[str, Any] | None, key: str, defaults: Mapping[str, Any]) -> Any: + value = (row or {}).get(key) + return defaults[key] if value is None else value + + +# --- what a template's row says --------------------------------------------------------------------------- + + +def fixed_route(template: Mapping[str, Any] | None) -> bool: + """Whether a job that names the template skips triage: its route is fixed.""" + return bool(_column(template, "fixed_route", TEMPLATE_DEFAULTS)) + + +def offered_to_triage(template: Mapping[str, Any] | None) -> bool: + """Whether triage's list of templates names it, and plan_route keeps triage's pick of it.""" + return bool(_column(template, "offered_to_triage", TEMPLATE_DEFAULTS)) + + +def derived_kind(graph: Mapping[str, Any] | None) -> str | None: + """The kind a route makes its jobs: ``"analysis"`` when every step but the output is a specialist (one of + ``specialist_agents.SPECIALIST_AGENT_TYPES``, each of which examines a repository and reports), else None. + Triage is the selector, not a step.""" + from embry0.workflows.issue_to_pr.specialist_agents import SPECIALIST_AGENT_TYPES + + nodes = (graph or {}).get("nodes") or [] + steps = [str(n.get("agent_type") or "") for n in nodes if n.get("agent_type") not in ("triage", "output")] + if steps and all(step in SPECIALIST_AGENT_TYPES for step in steps): + return "analysis" + return None + + +def recorded_kind(template: Mapping[str, Any] | None) -> str | None: + """The kind a job of the template is recorded as: the kind it is the default for, else its ``job_kind``, + else the one its route makes it (:func:`derived_kind`), read from the row's graph as it is now.""" + row = template or {} + said = str(row.get("default_for_kind") or row.get("job_kind") or "") + return said or derived_kind(row.get("graph_definition")) + + +def delivers(template: Mapping[str, Any] | None) -> str: + """What a job of the template hands back, in the person's words; '' when the row says nothing.""" + return str(_column(template, "delivers", TEMPLATE_DEFAULTS)).strip() + + +def leader(template: Mapping[str, Any] | None) -> str | None: + """The character a job of the template is drawn as before an agent starts, and its outcomes' credit.""" + return str(_column(template, "leader_agent", TEMPLATE_DEFAULTS) or "") or None + + +# --- what an agent's row says ------------------------------------------------------------------------------ + + +def station(agent: Mapping[str, Any] | None) -> str: + """Where the guild hall draws the agent when it is home; the desk for a row that names no known station.""" + value = str(_column(agent, "station", AGENT_DEFAULTS)) + return value if value in STATIONS else DEFAULT_STATION + + +@dataclass(frozen=True) +class Quest: + """Whether the guild can give an agent a task, and the template that task names (None: triage picks).""" + + offered: bool + template: str | None + + +def quest(agent: Mapping[str, Any] | None, templates: Collection[str] | None = None) -> Quest: + """The guild's task for the agent, from its ``quest_template``. + + With ``templates`` (the names that exist), a row naming a template that + no longer exists offers no task: a task would run the kind default + instead of what the operator chose. + """ + value = (agent or {}).get("quest_template") + if value is None: + return Quest(False, None) + name = str(value).strip() or None + if name is not None and templates is not None and name not in templates: + return Quest(False, None) + return Quest(True, name) + + +# --- checking a value an operator writes ------------------------------------------------------------------- + + +def checked_job_kind(value: Any) -> str | None: + """``job_kind`` as stored: None, or one of :data:`JOB_KINDS`. ValueError naming the field otherwise.""" + if value is None: + return None + if not isinstance(value, str) or value not in JOB_KINDS: + raise ValueError(f"job_kind must be one of {', '.join(JOB_KINDS)} or null; got {value!r}") + return value + + +def checked_delivers(value: Any) -> str: + """``delivers`` as stored: one line of at most :data:`DELIVERS_MAX` characters, trimmed.""" + if not isinstance(value, str): + raise ValueError(f"delivers must be text; got {type(value).__name__}") + text = value.strip() + if len(text) > DELIVERS_MAX: + raise ValueError(f"delivers must be at most {DELIVERS_MAX} characters; got {len(text)}") + if any(ord(ch) < 32 for ch in text): + raise ValueError("delivers must be one line of text") + return text + + +def _checked_name(field: str, value: Any, *, empty_ok: bool) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise ValueError(f"{field} must be a name or null; got {type(value).__name__}") + name = value.strip() + if not name and not empty_ok: + raise ValueError(f"{field} must be a name or null; got ''") + if len(name) > NAME_MAX: + raise ValueError(f"{field} must be at most {NAME_MAX} characters; got {len(name)}") + return name + + +def checked_leader_agent(value: Any) -> str | None: + """``leader_agent`` as stored: None or an agent type's shape. Whether the agent exists is the API's check.""" + return _checked_name("leader_agent", value, empty_ok=False) + + +def checked_quest_template(value: Any) -> str | None: + """``quest_template`` as stored: None, '' (the task names no template) or a template's name.""" + return _checked_name("quest_template", value, empty_ok=True) + + +def checked_station(value: Any) -> str: + """``station`` as stored: one of the guild hall's stations (``identity.STATIONS``).""" + if not isinstance(value, str): + raise ValueError(f"station must be one of {', '.join(STATIONS)}; got {value!r}") + return validate_station(value) + + +def leader_problem(value: str | None, agent_types: Collection[str] | None) -> str | None: + """Why ``leader_agent`` cannot be saved: it names no agent a job runs. None when it can, or when the + agents are unknown (no database).""" + if value is None or agent_types is None or value in agent_types: + return None + return f"leader_agent: no agent named {value!r} runs in jobs" + + +def untemplated_task_problem(agent_type: str, value: str | None) -> str | None: + """Why ``agent_type`` cannot take a task that names no template: such a task runs the route triage picks, + whose coding step is a coding agent's, so it is only a coding agent's (:data:`CODING_AGENT_TYPES`).""" + if value != "" or agent_type in CODING_AGENT_TYPES: + return None + return ( + "quest_template: '' (a task that names no template) runs the route triage picks, which only a coding " + f"agent ({', '.join(CODING_AGENT_TYPES)}) works on; name a template for {agent_type!r}" + ) + + +def template_name_problem(name: str | None) -> str | None: + """Why a template cannot be named ``name``: it is the name of a pipeline the code defines, which a job's + ``pipeline_template`` already means (:data:`WORKFLOW_PIPELINES`).""" + if name is None or name.strip() not in WORKFLOW_PIPELINES: + return None + return ( + f"name: {name.strip()!r} is a pipeline embry0 defines ({', '.join(WORKFLOW_PIPELINES)}); " + "a template cannot take its name" + ) + + +def quest_template_problem(value: str | None, template_names: Collection[str] | None) -> str | None: + """Why ``quest_template`` cannot be saved: it names no template. None when it can, or when the templates + are unknown (no database).""" + if not value or template_names is None or value in template_names: + return None + return f"quest_template: no pipeline template named {value!r}" + + +# --- rows --------------------------------------------------------------------------------------------------- + + +def pipelines_by_name(templates: Iterable[Mapping[str, Any]]) -> dict[str, Mapping[str, Any]]: + """Every pipeline a job's ``pipeline_template`` can name, by name: the template rows and the code's own.""" + rows: dict[str, Mapping[str, Any]] = dict(WORKFLOW_PIPELINES) + for row in templates: + if row.get("name"): + rows[str(row["name"])] = row + return rows + + +def builtin_template(name: str | None) -> dict[str, Any] | None: + """The built-in template ``name`` as the seeder writes it, or a pipeline the code defines; None for others. + + What answers without a database: the rows every deployment has. Never a + stand-in for a read that failed. + """ + from embry0.storage.seeds.pipeline_templates_builtin import BUILTIN_PIPELINE_TEMPLATES + + if not name: + return None + seed = BUILTIN_PIPELINE_TEMPLATES.get(name) + if seed is not None: + return {"name": name, **seed} + workflow = WORKFLOW_PIPELINES.get(name) + return dict(workflow) if workflow is not None else None + + +def builtin_templates() -> list[dict[str, Any]]: + """Every built-in template as the seeder writes it.""" + from embry0.storage.seeds.pipeline_templates_builtin import BUILTIN_PIPELINE_TEMPLATES + + return [{"name": name, **seed} for name, seed in BUILTIN_PIPELINE_TEMPLATES.items()] + + +def builtin_agent(agent_type: str | None) -> dict[str, Any] | None: + """The built-in agent ``agent_type`` as the seeders write it; None for an agent of the deployment's own.""" + from embry0.storage.repositories.agent_definitions import BUILTIN_SEED + + seed = BUILTIN_SEED.get(agent_type or "") + return {"type": agent_type, **seed} if seed is not None else None diff --git a/frontend/src/api/__tests__/pipelines.test.ts b/frontend/src/api/__tests__/pipelines.test.ts index 9b57e608..53cb9de8 100644 --- a/frontend/src/api/__tests__/pipelines.test.ts +++ b/frontend/src/api/__tests__/pipelines.test.ts @@ -23,6 +23,7 @@ import { fetchTemplate, fetchTemplates, renameTemplate, + setTemplateBehaviour, setTemplateDefaultKind, validatePipeline, } from "../pipelines"; @@ -76,10 +77,48 @@ describe("pipelines API client — boundary mappers", () => { sandbox_profile: "slim", is_builtin: true, default_for_kind: null, + // An older backend's row says nothing of its jobs: it behaves as a template of the deployment's own (#167). + behaviour: { fixed_route: false, offered_to_triage: true, job_kind: null, delivers: "", leader_agent: null }, }, ]); }); + it("fetchTemplates reads what each row says about its jobs (#167)", async () => { + (api.get as ReturnType).mockResolvedValueOnce({ + data: [ + { + id: "abc", + name: "why", + description: "", + created_at: "2026-05-04T00:00:00Z", + updated_at: "2026-05-04T00:00:00Z", + fixed_route: true, + offered_to_triage: false, + job_kind: "analysis", + delivers: "an answer", + leader_agent: "why", + }, + ], + }); + const [row] = await fetchTemplates(); + expect(row.behaviour).toEqual({ + fixed_route: true, + offered_to_triage: false, + job_kind: "analysis", + delivers: "an answer", + leader_agent: "why", + }); + }); + + it("setTemplateBehaviour sends every field, the nulls that clear included", async () => { + (api.put as ReturnType).mockResolvedValueOnce({ + data: { id: "abc", name: "n", description: "", graph_definition: SAMPLE_GRAPH, created_at: "", updated_at: "" }, + }); + const behaviour = { fixed_route: false, offered_to_triage: true, job_kind: null, delivers: "", leader_agent: null }; + await setTemplateBehaviour("abc", behaviour); + expect(api.put).toHaveBeenCalledWith("/pipelines/templates/abc", behaviour); + }); + it("fetchTemplate maps graph_definition → graph", async () => { (api.get as ReturnType).mockResolvedValueOnce({ data: { diff --git a/frontend/src/api/pipelines.ts b/frontend/src/api/pipelines.ts index 89e9e1ae..2a9047ae 100644 --- a/frontend/src/api/pipelines.ts +++ b/frontend/src/api/pipelines.ts @@ -1,5 +1,12 @@ import { api } from "./client"; -import type { PipelineGraph, PipelineTemplate, PipelineTemplateSummary, TemplateKind } from "@/lib/types"; +import { + DEFAULT_TEMPLATE_BEHAVIOUR, + type PipelineGraph, + type PipelineTemplate, + type PipelineTemplateSummary, + type TemplateBehaviour, + type TemplateKind, +} from "@/lib/types"; /** * The embry0 pipeline-templates API uses snake_case fields that don't quite @@ -22,10 +29,28 @@ interface ApiTemplateSummary { sandbox_profile?: string | null; is_builtin?: boolean; default_for_kind?: string | null; + /** #167; absent on an older backend. */ + fixed_route?: boolean; + offered_to_triage?: boolean; + job_kind?: string | null; + delivers?: string; + leader_agent?: string | null; created_at: string; updated_at: string; } +/** What the row says about its jobs; a field an older backend leaves out reads as its default. */ +function behaviourOf(row: ApiTemplateSummary): TemplateBehaviour { + const d = DEFAULT_TEMPLATE_BEHAVIOUR; + return { + fixed_route: row.fixed_route ?? d.fixed_route, + offered_to_triage: row.offered_to_triage ?? d.offered_to_triage, + job_kind: (row.job_kind as TemplateKind | null | undefined) ?? d.job_kind, + delivers: row.delivers ?? d.delivers, + leader_agent: row.leader_agent ?? d.leader_agent, + }; +} + interface ApiTemplate extends ApiTemplateSummary { graph_definition: PipelineGraph; /** Create and update responses only. */ @@ -41,6 +66,7 @@ function mapSummary(row: ApiTemplateSummary): PipelineTemplateSummary { sandbox_profile: row.sandbox_profile ?? null, is_builtin: row.is_builtin === true, default_for_kind: (row.default_for_kind as TemplateKind | null | undefined) ?? null, + behaviour: behaviourOf(row), }; } @@ -123,3 +149,12 @@ export async function setTemplateDefaultKind(templateId: string, kind: TemplateK const { data } = await api.put(`/pipelines/templates/${templateId}`, { default_for_kind: kind }); return mapTemplate(data); } + +/** + * Write what a template's row says about its jobs (#167). Every field is sent: + * job_kind's and leader_agent's null clears them. + */ +export async function setTemplateBehaviour(templateId: string, behaviour: TemplateBehaviour): Promise { + const { data } = await api.put(`/pipelines/templates/${templateId}`, behaviour); + return mapTemplate(data); +} diff --git a/frontend/src/api/world.ts b/frontend/src/api/world.ts index c8e9607b..b761dfe3 100644 --- a/frontend/src/api/world.ts +++ b/frontend/src/api/world.ts @@ -37,9 +37,12 @@ export interface WorldCharacter { quests_out: number; stats: CharacterStats; idle_too_long: boolean; - /** The template a quest the player writes for it runs; null for the developer's usual pipeline. */ + /** The template a quest the player writes for it runs; null for one that names none (triage picks the route). */ quest_template: string | null; + /** Whether its agent row gives it a task the guild can send (`quest_template`, #167). */ can_quest: boolean; + /** Where the guild hall draws it when it is home, from its agent row (#167). Absent on an older backend. */ + station?: string; } export interface Party { diff --git a/frontend/src/components/pipelines/TemplateActions.tsx b/frontend/src/components/pipelines/TemplateActions.tsx index 06221565..4f1b44d2 100644 --- a/frontend/src/components/pipelines/TemplateActions.tsx +++ b/frontend/src/components/pipelines/TemplateActions.tsx @@ -3,20 +3,25 @@ import { toastApiError } from "@/api/errorToast"; import { useSetTemplateDefault } from "@/hooks/usePipelines"; import { Menu, type MenuItem } from "@/components/ui/Menu"; import { DuplicateTemplateDialog } from "@/components/pipelines/DuplicateTemplateDialog"; +import { TemplateBehaviourDialog } from "@/components/pipelines/TemplateBehaviourDialog"; import { TEMPLATE_KINDS, type PipelineTemplateSummary } from "@/lib/types"; /** * A saved template's menu on the Pipelines page (spec 3.7): duplicate it - * under a new name, make it the default route for a kind, or clear the kind - * it holds. It sits beside the card, which is itself a button, so a choice + * under a new name, say how its jobs are treated (#167), make it the default + * route for a kind, or clear the kind it holds. It sits beside the card, which is itself a button, so a choice * here never opens the template. */ export function TemplateActions({ template }: { template: PipelineTemplateSummary }) { const [duplicating, setDuplicating] = useState(false); + const [editing, setEditing] = useState(false); const setDefault = useSetTemplateDefault(); const held = template.default_for_kind ?? null; - const items: MenuItem[] = [{ label: "Duplicate…", onSelect: () => setDuplicating(true) }]; + const items: MenuItem[] = [ + { label: "Duplicate…", onSelect: () => setDuplicating(true) }, + { label: "Behaviour…", onSelect: () => setEditing(true) }, + ]; if (held) { items.push({ label: `Clear default (${held})`, @@ -37,6 +42,7 @@ export function TemplateActions({ template }: { template: PipelineTemplateSummar <> {duplicating && setDuplicating(false)} />} + {editing && setEditing(false)} />} ); } diff --git a/frontend/src/components/pipelines/TemplateBehaviourDialog.tsx b/frontend/src/components/pipelines/TemplateBehaviourDialog.tsx new file mode 100644 index 00000000..93ae50c7 --- /dev/null +++ b/frontend/src/components/pipelines/TemplateBehaviourDialog.tsx @@ -0,0 +1,136 @@ +import { useState, type FormEvent } from "react"; +import { toast } from "sonner"; +import { apiErrorMessage } from "@/api/errorDetail"; +import { useAgents } from "@/hooks/useAgents"; +import { useSetTemplateBehaviour } from "@/hooks/usePipelines"; +import { Button } from "@/components/ui/Button"; +import { Dialog } from "@/components/ui/Dialog"; +import { Input } from "@/components/ui/Input"; +import { Label } from "@/components/ui/Label"; +import { Select } from "@/components/ui/Select"; +import { Switch } from "@/components/ui/Switch"; +import { + DEFAULT_TEMPLATE_BEHAVIOUR, + TEMPLATE_KINDS, + type PipelineTemplateSummary, + type TemplateBehaviour, + type TemplateKind, +} from "@/lib/types"; + +/** The longest `delivers` the backend keeps (row_behaviour.DELIVERS_MAX). */ +const DELIVERS_MAX = 300; + +/** + * What a template's row says about its jobs (#167): whether a job that names + * it skips triage, whether triage is offered it, the kind its jobs are + * recorded as, what they deliver and who leads them. A built-in template + * keeps what is saved here across restarts. A value the server refuses is + * said in the dialog, which keeps what was set, so the person corrects it in place. + */ +export function TemplateBehaviourDialog({ template, onClose }: { template: PipelineTemplateSummary; onClose: () => void }) { + const [form, setForm] = useState(template.behaviour ?? DEFAULT_TEMPLATE_BEHAVIOUR); + const [error, setError] = useState(null); + const { data: agents } = useAgents(); + const save = useSetTemplateBehaviour(); + // A leader is an agent a job runs; the brief writer's voices are none. + const leaders = (agents ?? []).filter((a) => a.runs_in_jobs !== false).map((a) => a.type); + if (form.leader_agent && !leaders.includes(form.leader_agent)) leaders.push(form.leader_agent); + const set = (patch: Partial) => { + setError(null); + setForm((prev) => ({ ...prev, ...patch })); + }; + + const submit = (e: FormEvent) => { + e.preventDefault(); + save.mutate( + { templateId: template.template_id, behaviour: { ...form, delivers: form.delivers.trim() } }, + { + onSuccess: (saved) => { + saved.warnings?.forEach((w) => toast.warning(w)); + toast.success(`Saved how ${template.name} treats its jobs`); + onClose(); + }, + onError: (err) => setError(apiErrorMessage(err)), + }, + ); + }; + + return ( + +
+
+
+ +

A job that names this template runs it as it is, without asking triage.

+
+ set({ fixed_route })} /> +
+
+
+ +

Off: triage never picks it, so it runs only when a job names it.

+
+ set({ offered_to_triage })} + /> +
+
+ + +

The kind this template is the default for, when it is one, wins.

+
+
+ + +

+ The guild draws a job of this template as its leader before an agent starts, and credits its work to it. +

+
+
+ + set({ delivers: e.target.value })} + /> +

One line, read into each job's brief. Empty says what its kind delivers.

+
+ {error && ( +

+ {error} +

+ )} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/pipelines/__tests__/TemplateActions.test.tsx b/frontend/src/components/pipelines/__tests__/TemplateActions.test.tsx index b6d27c83..b13d8374 100644 --- a/frontend/src/components/pipelines/__tests__/TemplateActions.test.tsx +++ b/frontend/src/components/pipelines/__tests__/TemplateActions.test.tsx @@ -4,11 +4,21 @@ import userEvent from "@testing-library/user-event"; const duplicate = vi.fn(); const setDefault = vi.fn(); +const setBehaviour = vi.fn(); vi.mock("@/hooks/usePipelines", () => ({ useDuplicateTemplate: () => ({ mutate: duplicate, isPending: false }), useSetTemplateDefault: () => ({ mutate: setDefault, isPending: false }), + useSetTemplateBehaviour: () => ({ mutate: setBehaviour, isPending: false }), })); -vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +const agents = vi.hoisted(() => ({ + data: [ + { type: "developer", runs_in_jobs: true }, + { type: "scout", runs_in_jobs: true }, + { type: "job-brief", runs_in_jobs: false }, + ], +})); +vi.mock("@/hooks/useAgents", () => ({ useAgents: () => agents })); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() } })); import { toast } from "sonner"; import { TemplateActions } from "../TemplateActions"; @@ -18,15 +28,60 @@ const TEMPLATE = { template_id: "t1", name: "Issue to PR", description: "", crea beforeEach(() => { duplicate.mockReset(); setDefault.mockReset(); + setBehaviour.mockReset(); vi.mocked(toast.error).mockReset(); }); describe("TemplateActions", () => { - it("offers Duplicate and a default for each kind, naming the one it holds", async () => { + it("offers Duplicate, its behaviour and a default for each kind, naming the one it holds", async () => { render(); await userEvent.click(screen.getByRole("button", { name: "Actions for Issue to PR" })); const names = screen.getAllByRole("menuitem").map((i) => i.textContent); - expect(names).toEqual(["Duplicate…", "Clear default (code)", "Make default for research", "Make default for analysis", "Make default for ops"]); + expect(names).toEqual([ + "Duplicate…", + "Behaviour…", + "Clear default (code)", + "Make default for research", + "Make default for analysis", + "Make default for ops", + ]); + }); + + it("saves what the row says about its jobs, every field at once (#167)", async () => { + setBehaviour.mockImplementation((_vars: unknown, opts: { onSuccess: (t: unknown) => void }) => opts.onSuccess({ warnings: [] })); + const behaviour = { fixed_route: false, offered_to_triage: true, job_kind: null, delivers: "", leader_agent: null }; + render(); + await userEvent.click(screen.getByRole("button", { name: "Actions for Issue to PR" })); + await userEvent.click(screen.getByRole("menuitem", { name: "Behaviour…" })); + await userEvent.click(screen.getByRole("switch", { name: "Skips triage" })); + await userEvent.click(screen.getByRole("switch", { name: "Offered to triage" })); + await userEvent.selectOptions(screen.getByLabelText("Its jobs are recorded as"), "analysis"); + const leader = screen.getByLabelText("Led by"); + expect([...leader.querySelectorAll("option")].map((o) => o.value)).toEqual(["", "developer", "scout"]); + await userEvent.selectOptions(leader, "scout"); + await userEvent.type(screen.getByLabelText("What its jobs deliver"), " a list of dead flags "); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + expect(setBehaviour).toHaveBeenCalledWith( + { + templateId: "t1", + behaviour: { fixed_route: true, offered_to_triage: false, job_kind: "analysis", delivers: "a list of dead flags", leader_agent: "scout" }, + }, + expect.anything(), + ); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + }); + + it("says a refused value in the dialog and keeps what was set", async () => { + setBehaviour.mockImplementation((_vars: unknown, opts: { onError: (e: unknown) => void }) => + opts.onError({ response: { status: 422, data: { detail: "leader_agent: no agent named 'scout' runs in jobs" } } }), + ); + render(); + await userEvent.click(screen.getByRole("button", { name: "Actions for Issue to PR" })); + await userEvent.click(screen.getByRole("menuitem", { name: "Behaviour…" })); + await userEvent.selectOptions(screen.getByLabelText("Led by"), "scout"); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + expect(screen.getByRole("alert")).toHaveTextContent("no agent named 'scout'"); + expect(screen.getByLabelText("Led by")).toHaveValue("scout"); }); it("makes the template the default for a kind", async () => { diff --git a/frontend/src/components/world/HallScene.tsx b/frontend/src/components/world/HallScene.tsx index 515a91c0..18dad112 100644 --- a/frontend/src/components/world/HallScene.tsx +++ b/frontend/src/components/world/HallScene.tsx @@ -2,8 +2,9 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { Loot, WorldCharacter } from "@/api/world"; import { AgentAvatar } from "@/components/agents/AgentAvatar"; import { useIdentityLookup } from "@/hooks/useAgentIdentity"; +import { stationOf } from "@/lib/agentIdentity"; import { STATE_POSE } from "@/lib/world"; -import { SPACING, STATION_OF, chestOf, hallLayout } from "@/lib/worldHall"; +import { SPACING, chestOf, hallLayout } from "@/lib/worldHall"; import { ArtObjectAt, EffectAt } from "./art/Pieces"; import { Torchlight } from "./art/Nightfall"; import { anchored } from "./art/style"; @@ -23,7 +24,7 @@ interface HallSceneProps { /** * The guild hall's inside: the floor and walls, each character standing at - * its station (the station stands empty while it is out), the quest board, + * its station (its agent row's, #167; the station stands empty while it is out), the quest board, * the loot chest. Drawn at the art's size and scaled by a whole number to * fill the width; a click on a character opens its panel. It is night * (Nightfall, art/relight.ts): the wall torches and the quest board light @@ -84,12 +85,12 @@ export function HallScene({ art, characters, loot, xpDrops, levelUps, onSelect, {ordered.map((c, i) => { const spot = layout.station(i); - const station = STATION_OF[c.agent_type] ?? "desk"; + const station = stationOf(c.station); const home = c.state === "idle" || c.state === "returning" || c.state === "defeated" || c.state === "recalled"; const identity = lookup(c.agent_type); const drop = xpDrops.get(c.agent_type); return ( -
+
+ {!runsInNoJob && ( + setSections((prev) => ({ ...prev, guild: open }))} + > +
+
+ + +

Where the guild hall draws it when it is home.

+
+
+ + +

+ A task the guild gives this agent runs the template chosen here. With none, the guild cannot send it. +

+
+
+
+ )} + {formatDay(template.created_at)} {template.is_builtin && Built in} {template.default_for_kind && {`Default for ${template.default_for_kind}`}} + {template.behaviour?.fixed_route && Skips triage} + {template.behaviour && !template.behaviour.offered_to_triage && Runs only when named}
diff --git a/frontend/src/pages/__tests__/AgentFormPage.test.tsx b/frontend/src/pages/__tests__/AgentFormPage.test.tsx index dd36c923..52513c29 100644 --- a/frontend/src/pages/__tests__/AgentFormPage.test.tsx +++ b/frontend/src/pages/__tests__/AgentFormPage.test.tsx @@ -71,8 +71,10 @@ const catalogs = vi.hoisted(() => ({ skills: [ { name: "superpowers:writing-plans", source_id: "superpowers", description: "Plans", available: true, status: "ready" }, ], + templates: [{ template_id: "t-1", name: "scout-pass", description: "", created_at: "2026-11-01T00:00:00Z" }], })); vi.mock("@/hooks/useTools", () => ({ useTools: () => ({ data: catalogs.tools, isLoading: false }) })); +vi.mock("@/hooks/usePipelines", () => ({ useTemplates: () => ({ data: catalogs.templates, isLoading: false }) })); vi.mock("@/hooks/useSkills", () => ({ useSkills: () => ({ data: catalogs.skills, isLoading: false }) })); const toastWarning = vi.fn(); vi.mock("sonner", () => ({ @@ -616,3 +618,70 @@ describe("AgentFormPage: picking a skill triage may add", () => { ); }); }); + +describe("AgentFormPage — the guild (#167)", () => { + it("opens on the row's station and task, and saves what is chosen", async () => { + const user = userEvent.setup(); + mockUseAgent.mockReturnValue({ + data: { ...GROK_DEV_ROW, type: "scout", station: "lectern", quest_template: "scout-pass" }, + isLoading: false, + }); + renderEdit("scout"); + expect(screen.getByLabelText("Station")).toHaveValue("lectern"); + expect(screen.getByLabelText("Task from the guild")).toHaveValue("scout-pass"); + + const task = screen.getByLabelText("Task from the guild"); + expect([...task.querySelectorAll("option")].map((o) => o.value)).not.toContain("__triage__"); + await user.selectOptions(screen.getByLabelText("Station"), "map_table"); + await user.click(screen.getByRole("button", { name: "Save Agent" })); + expect(mockUpdateMutate).toHaveBeenLastCalledWith( + expect.objectContaining({ type: "scout", station: "map_table", quest_template: "scout-pass" }), + expect.anything(), + ); + + await user.selectOptions(screen.getByLabelText("Task from the guild"), "__none__"); + await user.click(screen.getByRole("button", { name: "Save Agent" })); + expect(mockUpdateMutate).toHaveBeenLastCalledWith( + expect.objectContaining({ quest_template: null }), + expect.anything(), + ); + }); + + it("offers a coding agent a task that names no template, and only a coding agent", async () => { + const user = userEvent.setup(); + mockUseAgent.mockReturnValue({ + data: { ...EXISTING, station: "anvil", quest_template: null, coding_agent: true }, + isLoading: false, + }); + renderEdit(); + await user.selectOptions(screen.getByLabelText("Task from the guild"), "__triage__"); + await user.click(screen.getByRole("button", { name: "Save Agent" })); + expect(mockUpdateMutate).toHaveBeenLastCalledWith( + expect.objectContaining({ type: "developer", quest_template: "" }), + expect.anything(), + ); + }); + + it("starts a new agent at a desk with no task from the guild", async () => { + const user = userEvent.setup(); + mockUseAgent.mockReturnValue({ data: undefined, isLoading: false }); + renderNew(); + expect(screen.getByLabelText("Station")).toHaveValue("desk"); + const task = screen.getByLabelText("Task from the guild"); + expect(task).toHaveValue("__none__"); + expect([...task.querySelectorAll("option")].map((o) => o.value)).not.toContain("__triage__"); + expect([...task.querySelectorAll("option")].map((o) => o.textContent)).toContain("Runs scout-pass"); + await user.type(screen.getByLabelText("Type (slug)"), "scout"); + await user.click(screen.getByRole("button", { name: "Save Agent" })); + expect(mockCreateMutate).toHaveBeenCalledWith( + expect.objectContaining({ type: "scout", station: "desk", quest_template: null }), + expect.anything(), + ); + }); + + it("keeps a task naming a template the list no longer has", () => { + mockUseAgent.mockReturnValue({ data: { ...GROK_DEV_ROW, quest_template: "gone-route" }, isLoading: false }); + renderEdit("grok-dev"); + expect(screen.getByLabelText("Task from the guild")).toHaveValue("gone-route"); + }); +}); diff --git a/tests/fixtures/builtin_rows.py b/tests/fixtures/builtin_rows.py new file mode 100644 index 00000000..7bfb64f7 --- /dev/null +++ b/tests/fixtures/builtin_rows.py @@ -0,0 +1,26 @@ +"""What a job of a built-in template records when plan_route plans it (#167), for tests that write jobs directly. + +Progression and the world credit a job from what it recorded when it was +planned: ``jobs.coding_agent`` (the agent its route's coding step runs as) +and ``jobs.leader_agent`` (its template's leader). A test that inserts a +job of a built-in template, or builds its journal row, gives it these, as +a job planned since migration 143 has them (and an older one was given by +the migration). +""" + +from __future__ import annotations + +from typing import Any + +from embry0.workflows.issue_to_pr.route_plan import build_route_plan, coding_agent_of +from embry0.workflows.row_behaviour import builtin_template, leader + + +def planned_credit(template: str | None) -> dict[str, Any]: + """``coding_agent`` and ``leader_agent`` as plan_route records them for a job of the built-in ``template`` + (a pipeline the code defines records none of its own: plan_route never runs for it).""" + row = builtin_template(template) + graph = (row or {}).get("graph_definition") + if not graph: + return {"coding_agent": None, "leader_agent": None} + return {"coding_agent": coding_agent_of(build_route_plan(graph)), "leader_agent": leader(row)} diff --git a/tests/fixtures/name_lists_before_rows.py b/tests/fixtures/name_lists_before_rows.py new file mode 100644 index 00000000..fb3ad404 --- /dev/null +++ b/tests/fixtures/name_lists_before_rows.py @@ -0,0 +1,133 @@ +"""What the code said by built-in name before the rows said it (#167), copied as it stood. + +The lists and the literals the template and agent columns replaced, kept +here only to prove the seeded rows, and an upgraded database's, answer +exactly what they did: ``SPECIALIST_TEMPLATE_NAMES`` (specialist_agents.py), +``_TEMPLATE_DELIVERS`` (job_briefs.py), ``LEADER_BY_TEMPLATE`` and +``QUEST_TEMPLATE_BY_AGENT`` (services/world.py), ``STATION_OF`` +(frontend/src/lib/worldHall.ts), the coding agents (route_plan.py and +_salvage.py), ``specialist_job_kind`` (route_plan.py) and the coder of a +job (progression.py's ``_CODER``, world.py's ``quest_xp``). Nothing outside +the tests reads them. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import Any + +SPECIALIST_TEMPLATE_NAMES = frozenset( + { + "kintsugi-trace", + "garden", + "interrogate", + "why", + "how", + "blast-radius", + "comment-pass", + "design-audit", + "context-audit", + "map-areas", + } +) + +SPECIALIST_AGENT_TYPES = ( + "kintsugi", + "gardener", + "interrogator", + "why", + "how", + "blast-radius", + "comment-sicko", + "design-auditor", + "context-auditor", + "area-mapper", +) + +TEMPLATE_DELIVERS = { + "analysis-default": "an analysis of the production logs, and a ticket when it finds a problem worth one", + "context-audit": "a report of the claims that look wrong in the files that steer coding agents", + "map-areas": "a pull request that maps the repository's areas", + "pr-review": "a review on the pull request", + "qa": "a test report", + "onboard": "a test setup for the repository", +} + +LEADER_BY_TEMPLATE = { + "analysis-default": "analysis", + "research-default": "research", + "ops-default": "ops", + "qa": "qa", + "pr-review": "pr-reviewer", + "onboard": "onboarding", + "kintsugi-trace": "kintsugi", + "garden": "gardener", + "interrogate": "interrogator", + "why": "why", + "how": "how", + "blast-radius": "blast-radius", + "comment-pass": "comment-sicko", + "design-audit": "design-auditor", + "context-audit": "context-auditor", + "map-areas": "area-mapper", + "slop-cleanup": "slopmaster", +} + +QUEST_TEMPLATE_BY_AGENT: dict[str, str | None] = { + "developer": None, + **{ + agent: template + for template, agent in LEADER_BY_TEMPLATE.items() + if template not in ("qa", "pr-review", "onboard") + }, +} + +STATION_OF = { + "developer": "anvil", + "review": "lectern", + "pr-reviewer": "lectern", + "interrogator": "lectern", + "qa": "alchemy_table", + "kintsugi": "alchemy_table", + "analysis": "map_table", + "research": "map_table", + "ops": "map_table", + "area-mapper": "map_table", + "blast-radius": "map_table", +} + +CODING_AGENTS = frozenset({"developer", "slopmaster"}) + + +def specialist_job_kind(agents: Iterable[str]) -> str | None: + """``"analysis"`` for a route whose agents (``output`` aside) are all specialists, else None.""" + steps = [a for a in agents if a != "output"] + if steps and all(a in SPECIALIST_AGENT_TYPES for a in steps): + return "analysis" + return None + + +def route_agents(graph: Mapping[str, Any]) -> list[str]: + """A graph's steps' agent types, as build_route_plan linearized them (triage dropped).""" + return [str(n.get("agent_type")) for n in graph.get("nodes") or [] if n.get("agent_type") != "triage"] + + +def recorded_kind(template: str, graph: Mapping[str, Any], default_for_kind: str | None) -> str | None: + """The kind plan_route recorded a job of ``template`` as: its default kind, else the specialist rule.""" + return default_for_kind or specialist_job_kind(route_agents(graph)) + + +def coder(template: str | None) -> str: + """The character a job's pull request was credited to.""" + return "slopmaster" if template == "slop-cleanup" else "developer" + + +def delivers(template: str, kind: str) -> str: + """What a job's brief said it delivers.""" + kinds = { + "code": "a pull request with the change", + "research": "a written report", + "analysis": "a written analysis", + "ops": "the operation done, and a report of it", + } + return TEMPLATE_DELIVERS.get(template) or kinds.get(kind) or "the result of the work" diff --git a/tests/fixtures/progression_history.py b/tests/fixtures/progression_history.py index 859a1b06..636d640c 100644 --- a/tests/fixtures/progression_history.py +++ b/tests/fixtures/progression_history.py @@ -11,6 +11,7 @@ from typing import Any from embry0.storage.database import DatabasePool +from tests.fixtures.builtin_rows import planned_credit BASE = datetime(2025, 10, 1, 12, tzinfo=UTC) # a Wednesday @@ -76,6 +77,9 @@ def t(day: float, hour: float = 0.0) -> datetime: async def _job(db: DatabasePool, job_id: str, **row: Any) -> None: row = {"repo": "acme/widgets", "task": "t", "status": "completed", "budget_overrun_usd": 0, **row} + # What plan_route records for a job of its template (#167): whom its work is credited to. + for key, value in planned_credit(row.get("pipeline_template")).items(): + row.setdefault(key, value) created = row.pop("at") ran = row.pop("ran_hours", 0.5) row.setdefault("created_at", created) diff --git a/tests/unit/api/v1/test_behaviour_writes_api.py b/tests/unit/api/v1/test_behaviour_writes_api.py new file mode 100644 index 00000000..df23d922 --- /dev/null +++ b/tests/unit/api/v1/test_behaviour_writes_api.py @@ -0,0 +1,274 @@ +"""What an operator sets on an agent's or a template's row about its behaviour (#167), through the API. + +An agent's ``station`` and ``quest_template``, and a template's +``fixed_route``, ``offered_to_triage``, ``job_kind``, ``delivers`` and +``leader_agent``: each is stored as sent, a malformed one is a 422 naming +its field, and a name that names nothing is refused the same way. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from httpx import ASGITransport, AsyncClient + +from embry0.api.app import create_app +from embry0.config import Embry0Config + +AGENT = {"type": "scout", "description": "d", "model": "m", "tools": ["Read"], "skills": [], "is_builtin": False} +GRAPH = { + "nodes": [{"node_id": "t", "agent_type": "triage"}, {"node_id": "s", "agent_type": "scout"}], + "edges": [{"edge_id": "e1", "source": "t", "target": "s", "edge_type": "flow"}], +} +QA_GRAPH = { + "nodes": [ + {"node_id": "t", "agent_type": "triage"}, + {"node_id": "d", "agent_type": "developer"}, + {"node_id": "q", "agent_type": "qa"}, + ], + "edges": [ + {"edge_id": "e1", "source": "t", "target": "d", "edge_type": "flow"}, + {"edge_id": "e2", "source": "d", "target": "q", "edge_type": "flow"}, + ], +} +TEMPLATE = {"id": "t-1", "name": "scout-pass", "graph_definition": GRAPH, "sandbox_profile": None} + + +@pytest.fixture +def app() -> Any: + app = create_app(Embry0Config(_env_file=None, auth_dev_mode=True, webhook_dev_mode=True)) + agents = MagicMock() + agents.list_all = AsyncMock(return_value=[AGENT, {**AGENT, "type": "developer"}]) + agents.get = AsyncMock(return_value=AGENT) + agents.create = AsyncMock(return_value=AGENT) + agents.update = AsyncMock(return_value=AGENT) + app.state.agent_defs_repo = agents + templates = MagicMock() + templates.list_all = AsyncMock(return_value=[{"id": "t-1", "name": "scout-pass"}]) + templates.get = AsyncMock(return_value=TEMPLATE) + templates.create = AsyncMock(return_value=TEMPLATE) + templates.update = AsyncMock(return_value=TEMPLATE) + app.state.templates_repo = templates + return app + + +async def _send(app: Any, method: str, path: str, body: dict[str, Any]) -> Any: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + return await client.request(method, path, json=body, headers={"X-Requested-With": "XMLHttpRequest"}) + + +# --- agents --------------------------------------------------------------------------------------------------- + +NEW_AGENT = {"type": "scout", "description": "d", "model": "m", "tools": ["Read"]} + + +async def test_an_agent_is_stored_with_its_station_and_its_task(app: Any) -> None: + resp = await _send( + app, "POST", "/api/v1/agents", {**NEW_AGENT, "station": "lectern", "quest_template": "scout-pass"} + ) + assert resp.status_code == 201, resp.text + created = app.state.agent_defs_repo.create.call_args.kwargs + assert (created.get("station"), created.get("quest_template")) == ("lectern", "scout-pass"), created + + resp = await _send(app, "POST", "/api/v1/agents", NEW_AGENT) + created = app.state.agent_defs_repo.create.call_args.kwargs + assert (created.get("station"), created.get("quest_template")) == ("desk", None), ( + f"a new agent sits at a desk and the guild gives it no task: {created}" + ) + + for path, body, want in ( + ("scout", {"station": "map_table"}, {"station": "map_table"}), + ("scout", {"quest_template": "scout-pass"}, {"quest_template": "scout-pass"}), + ("scout", {"quest_template": None}, {"quest_template": None}), + ("developer", {"quest_template": ""}, {"quest_template": ""}), + ): + resp = await _send(app, "PUT", f"/api/v1/agents/{path}", body) + assert resp.status_code == 200, resp.text + assert app.state.agent_defs_repo.update.call_args.kwargs == want, f"{body} reached the repository as sent" + + +@pytest.mark.parametrize( + ("method", "path", "base"), [("POST", "/api/v1/agents", NEW_AGENT), ("PUT", "/api/v1/agents/scout", {})] +) +async def test_a_task_naming_no_template_is_a_coding_agents_only(app: Any, method, path, base) -> None: + """'' runs the route triage picks, whose coding step is the developer's: an agent that does not code + cannot take it.""" + resp = await _send(app, method, path, {**base, "quest_template": ""}) + assert resp.status_code == 422, resp.text + assert resp.json()["detail"].startswith("quest_template: ''"), resp.json() + + +async def test_a_save_that_keeps_a_stored_task_or_leader_is_not_refused_for_it(app: Any) -> None: + """The forms send every field: a stored task whose template is gone, or a stored leader that no longer runs + in jobs, must not stop the rest from saving. Only a value the write changes is checked.""" + app.state.agent_defs_repo.get = AsyncMock(return_value={**AGENT, "quest_template": "deleted-route"}) + resp = await _send(app, "PUT", "/api/v1/agents/scout", {"description": "new", "quest_template": "deleted-route"}) + assert resp.status_code == 200, f"an unchanged task was refused: {resp.text}" + resp = await _send(app, "PUT", "/api/v1/agents/scout", {"quest_template": "other-route"}) + assert resp.status_code == 422, "a task the write changes is still checked" + + app.state.templates_repo.get = AsyncMock(return_value={**TEMPLATE, "leader_agent": "retired"}) + resp = await _send( + app, "PUT", "/api/v1/pipelines/templates/t-1", {"leader_agent": "retired", "delivers": "a report"} + ) + assert resp.status_code == 200, f"an unchanged leader was refused: {resp.text}" + resp = await _send(app, "PUT", "/api/v1/pipelines/templates/t-1", {"leader_agent": "ghost"}) + assert resp.status_code == 422, "a leader the write changes is still checked" + + +async def test_an_agent_says_whether_it_codes(app: Any) -> None: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + listed = (await client.get("/api/v1/agents")).json() + one = (await client.get("/api/v1/agents/scout")).json() + assert {a["type"]: a["coding_agent"] for a in listed} == {"scout": False, "developer": True}, listed + assert one["coding_agent"] is False + + +@pytest.mark.parametrize("name", ["qa", "onboard", "pr-review"]) +async def test_a_template_cannot_take_a_code_pipelines_name(app: Any, name: str) -> None: + for method, path, body in ( + ("POST", "/api/v1/pipelines/templates", {"name": name, "graph_definition": GRAPH}), + ("PUT", "/api/v1/pipelines/templates/t-1", {"name": name}), + ("POST", "/api/v1/pipelines/templates/t-1/duplicate", {"name": name}), + ): + resp = await _send(app, method, path, body) + assert resp.status_code == 422, f"{method} {path} named {name}: {resp.status_code}" + assert "a pipeline embry0 defines" in resp.text, resp.text + app.state.templates_repo.create.assert_not_awaited() + app.state.templates_repo.update.assert_not_awaited() + + +@pytest.mark.parametrize( + ("method", "path", "base"), + [("POST", "/api/v1/agents", NEW_AGENT), ("PUT", "/api/v1/agents/scout", {})], + ids=["create", "update"], +) +@pytest.mark.parametrize( + ("field", "value"), + [("station", "throne"), ("station", 3), ("quest_template", ["scout-pass"]), ("quest_template", "x" * 101)], +) +async def test_a_malformed_agent_value_is_a_422_naming_its_field(app: Any, method, path, base, field, value) -> None: + resp = await _send(app, method, path, {**base, field: value}) + assert resp.status_code == 422, f"{field}={value!r} must be a 422, got {resp.status_code}" + assert field in resp.text, f"the 422 must name {field}: {resp.text}" + app.state.agent_defs_repo.create.assert_not_awaited() + app.state.agent_defs_repo.update.assert_not_awaited() + + +@pytest.mark.parametrize( + ("method", "path", "base"), [("POST", "/api/v1/agents", NEW_AGENT), ("PUT", "/api/v1/agents/scout", {})] +) +async def test_a_task_naming_no_template_is_refused(app: Any, method, path, base) -> None: + resp = await _send(app, method, path, {**base, "quest_template": "deleted-route"}) + assert resp.status_code == 422, resp.text + assert resp.json()["detail"] == "quest_template: no pipeline template named 'deleted-route'" + + +async def test_a_station_cannot_be_cleared(app: Any) -> None: + resp = await _send(app, "PUT", "/api/v1/agents/scout", {"station": None}) + assert resp.status_code == 422 and "station cannot be null" in resp.text, resp.text + + +# --- templates ------------------------------------------------------------------------------------------------ + +NEW_TEMPLATE = {"name": "scout-pass", "graph_definition": GRAPH} + + +async def test_a_template_is_stored_with_what_it_says_of_its_jobs(app: Any) -> None: + behaviour = { + "fixed_route": True, + "offered_to_triage": False, + "job_kind": "analysis", + "delivers": "a list of dead flags", + "leader_agent": "scout", + } + resp = await _send(app, "POST", "/api/v1/pipelines/templates", {**NEW_TEMPLATE, **behaviour}) + assert resp.status_code == 201, resp.text + created = app.state.templates_repo.create.call_args.kwargs + assert {k: created.get(k) for k in behaviour} == behaviour, created + + await _send(app, "POST", "/api/v1/pipelines/templates", NEW_TEMPLATE) + created = app.state.templates_repo.create.call_args.kwargs + assert {k: created.get(k) for k in behaviour} == { + "fixed_route": False, + "offered_to_triage": True, + "job_kind": None, + "delivers": "", + "leader_agent": None, + }, f"a template that says nothing behaves as one of the deployment's own did: {created}" + + +async def test_a_templates_update_writes_what_it_sends_and_clears_what_it_nulls(app: Any) -> None: + for body, want in ( + ({"fixed_route": True}, {"fixed_route": True}), + ({"offered_to_triage": False}, {"offered_to_triage": False}), + ({"job_kind": "ops"}, {"job_kind": "ops"}), + ({"job_kind": None}, {"job_kind": None}), + ({"leader_agent": "scout"}, {"leader_agent": "scout"}), + ({"leader_agent": None}, {"leader_agent": None}), + ({"delivers": ""}, {"delivers": ""}), + ): + resp = await _send(app, "PUT", "/api/v1/pipelines/templates/t-1", body) + assert resp.status_code == 200, resp.text + assert app.state.templates_repo.update.call_args.kwargs == want, ( + f"{body}: {app.state.templates_repo.update.call_args}" + ) + + +@pytest.mark.parametrize( + ("method", "path", "base"), + [("POST", "/api/v1/pipelines/templates", NEW_TEMPLATE), ("PUT", "/api/v1/pipelines/templates/t-1", {})], + ids=["create", "update"], +) +@pytest.mark.parametrize( + ("field", "value"), + [ + ("job_kind", "chores"), + ("delivers", "two\nlines"), + ("delivers", "x" * 301), + ("leader_agent", ""), + ("fixed_route", "sometimes"), + ("offered_to_triage", [1]), + ], +) +async def test_a_malformed_template_value_is_a_422_naming_its_field(app: Any, method, path, base, field, value) -> None: + resp = await _send(app, method, path, {**base, field: value}) + assert resp.status_code == 422, f"{field}={value!r} must be a 422, got {resp.status_code}" + assert field in resp.text, f"the 422 must name {field}: {resp.text}" + app.state.templates_repo.create.assert_not_awaited() + app.state.templates_repo.update.assert_not_awaited() + + +@pytest.mark.parametrize( + ("method", "path", "base"), + [("POST", "/api/v1/pipelines/templates", NEW_TEMPLATE), ("PUT", "/api/v1/pipelines/templates/t-1", {})], + ids=["create", "update"], +) +async def test_a_leader_naming_no_agent_is_refused(app: Any, method, path, base) -> None: + resp = await _send(app, method, path, {**base, "leader_agent": "ghost"}) + assert resp.status_code == 422, resp.text + assert resp.json()["detail"] == "leader_agent: no agent named 'ghost' runs in jobs" + + +@pytest.mark.parametrize("field", ["fixed_route", "offered_to_triage", "delivers"]) +async def test_a_behaviour_column_that_is_never_null_refuses_a_null(app: Any, field: str) -> None: + resp = await _send(app, "PUT", "/api/v1/pipelines/templates/t-1", {field: None}) + assert resp.status_code == 422 and f"{field} cannot be null" in resp.text, resp.text + + +async def test_a_fixed_route_with_a_qa_step_is_told_its_qa_never_runs(app: Any) -> None: + resp = await _send( + app, + "POST", + "/api/v1/pipelines/templates", + {"name": "fixed-fix", "graph_definition": QA_GRAPH, "fixed_route": True}, + ) + assert resp.status_code == 201, resp.text + assert any("qa step never runs" in w for w in resp.json()["warnings"]), resp.json()["warnings"] + app.state.templates_repo.get = AsyncMock(return_value={**TEMPLATE, "graph_definition": QA_GRAPH}) + resp = await _send(app, "PUT", "/api/v1/pipelines/templates/t-1", {"fixed_route": True}) + assert any("qa step never runs" in w for w in resp.json()["warnings"]), resp.json()["warnings"] + resp = await _send(app, "PUT", "/api/v1/pipelines/templates/t-1", {"fixed_route": False}) + assert not any("qa step" in w for w in resp.json()["warnings"]), resp.json()["warnings"] diff --git a/tests/unit/api/v1/test_org_context_audit_api.py b/tests/unit/api/v1/test_org_context_audit_api.py index a2836bff..73c72b64 100644 --- a/tests/unit/api/v1/test_org_context_audit_api.py +++ b/tests/unit/api/v1/test_org_context_audit_api.py @@ -47,8 +47,10 @@ async def newest_per_repo( pipeline_template: str, dispatched_by: str | None = None, exclude_dispatched_by: Collection[str] = (), + leader_agent: str | None = None, ) -> dict[str, dict[str, Any]]: assert pipeline_template == TEMPLATE and dispatched_by == "embry0:context-audit" + assert leader_agent == "context-auditor", "every audit counts, a copy of the template's included (#167)" assert sorted(repos) == ["acme/plain", "acme/web"], "only the owner's own registered repositories" when = datetime(2026, 10, 1, tzinfo=UTC) return { diff --git a/tests/unit/api/v1/test_org_context_audit_run_api.py b/tests/unit/api/v1/test_org_context_audit_run_api.py index f8e57edb..9b5591e0 100644 --- a/tests/unit/api/v1/test_org_context_audit_run_api.py +++ b/tests/unit/api/v1/test_org_context_audit_run_api.py @@ -84,6 +84,7 @@ async def newest_per_repo( pipeline_template: str, dispatched_by: str | None = None, exclude_dispatched_by: Collection[str] = (), + leader_agent: str | None = None, ) -> dict[str, dict[str, Any]]: return {row["repo"]: {**row, "live": True} for row in self.rows if row["repo"] in repos} diff --git a/tests/unit/api/v1/test_world_api.py b/tests/unit/api/v1/test_world_api.py index 1629c352..f91f4b86 100644 --- a/tests/unit/api/v1/test_world_api.py +++ b/tests/unit/api/v1/test_world_api.py @@ -340,6 +340,7 @@ async def test_a_merged_cleanup_is_the_slopmasters_and_earns_its_removed_lines(a status="pr_merged", delivery_state="merged", pipeline_template="slop-cleanup", + coding_agent="slopmaster", # as plan_route records it for a slop cleanup (#167) work_nature={ "nature": "slop", "slop_score": 80, diff --git a/tests/unit/api/v1/test_world_rows.py b/tests/unit/api/v1/test_world_rows.py new file mode 100644 index 00000000..789cb23e --- /dev/null +++ b/tests/unit/api/v1/test_world_rows.py @@ -0,0 +1,188 @@ +"""The guild draws its characters and parties from the agent and template rows (#167). + +Before, a character could be given a task only when its type was one of the +built-in names the world knew, so an agent of the deployment's own never +could; its station and a template's leader were name lists too. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +import pytest +from httpx import ASGITransport, AsyncClient + +from embry0.api.app import create_app +from embry0.api.v1 import world as world_api +from embry0.config import Embry0Config +from embry0.services.progression import Character, Progression +from embry0.services.progression_pack import default_rules +from embry0.services.world import WorldCache +from tests.unit.api.v1.test_world_api import FakeProgression, FakeRepo + +NOW = datetime.now(UTC) + + +class _Rows: + def __init__(self, rows: list[dict[str, Any]]) -> None: + self.rows = rows + self.reads = 0 + + async def list_all(self) -> list[dict[str, Any]]: + self.reads += 1 + return [dict(r) for r in self.rows] + + +class _Characters(FakeProgression): + async def read(self) -> Progression: + rules = default_rules() + types = ("developer", "qa", "scout", "watchman", "lost") + return Progression(characters={a: Character(a, rules=rules) for a in types}, rules=rules) + + +@pytest.fixture +def app(monkeypatch: pytest.MonkeyPatch) -> Any: + app = create_app(Embry0Config(_env_file=None, auth_dev_mode=True, webhook_dev_mode=True)) + app.state.db = None + app.state.world_repo = FakeRepo() + app.state.world_cache = WorldCache() + app.state.progression = _Characters() + + async def nothing(request: Any) -> list[dict[str, Any]]: + return [] + + async def no_places(request: Any) -> dict[str, Any]: + return {"regions": [], "frontier": []} + + monkeypatch.setattr(world_api, "_leads", nothing) + monkeypatch.setattr(world_api, "_regions", no_places) + app.state.agent_defs_repo = _Rows( + [ + {"type": "developer", "station": "anvil", "quest_template": ""}, + {"type": "qa", "station": "alchemy_table", "quest_template": None}, + # Agents of the deployment's own: one the guild can send, one it cannot, one whose template is gone. + {"type": "scout", "station": "lectern", "quest_template": "scout-pass"}, + {"type": "watchman", "station": "desk", "quest_template": None}, + {"type": "lost", "station": "map_table", "quest_template": "deleted-route"}, + ] + ) + app.state.templates_repo = _Rows( + [ + {"name": "scout-pass", "leader_agent": "scout", "fixed_route": True, "offered_to_triage": False}, + {"name": "full-magnum-opus", "default_for_kind": "code"}, + ] + ) + return app + + +async def _world(app: Any) -> dict[str, Any]: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/api/v1/world") + assert resp.status_code == 200, resp.text + return resp.json() + + +async def test_an_agent_of_the_deployments_own_with_a_quest_template_can_be_sent_from_the_guild(app: Any) -> None: + characters = {c["agent_type"]: c for c in (await _world(app))["characters"]} + scout = characters["scout"] + assert scout["can_quest"] is True, f"the guild cannot send an agent whose row names its task: {scout}" + assert scout["quest_template"] == "scout-pass", "its task runs the template its row names" + assert scout["station"] == "lectern", "it works at the station its row names" + assert characters["watchman"]["can_quest"] is False, "a row naming no task gets none" + assert characters["lost"]["can_quest"] is False, "a task naming a template that is gone is none" + developer = characters["developer"] + assert (developer["can_quest"], developer["quest_template"], developer["station"]) == (True, None, "anvil"), ( + "the developer's task names no template, so triage picks its route" + ) + assert characters["qa"]["can_quest"] is False and characters["qa"]["station"] == "alchemy_table" + + +async def test_a_party_with_no_agent_out_yet_is_drawn_as_its_templates_leader(app: Any) -> None: + async def jobs(**_kw: Any) -> list[dict[str, Any]]: + return [ + { + "job_id": "job-new", + "repo": "acme/web", + "task": "Scout it", + "status": "pending", + "pipeline_template": "scout-pass", + "created_at": NOW, + } + ] + + async def no_runs(ids: list[str]) -> dict[str, list[tuple[str, str]]]: + return {} + + app.state.world_repo.jobs = jobs + app.state.world_repo.agent_runs = no_runs + party = (await _world(app))["parties"][0] + assert party["leader"] == "scout", f"the template's row names its leader: {party}" + + +async def test_a_write_to_the_rows_reaches_the_hall_on_its_next_read(app: Any) -> None: + from embry0.services.world import forget_rows + + await _world(app) + await _world(app) + assert app.state.agent_defs_repo.reads == 1, "the rows are kept between reads, like the board" + app.state.agent_defs_repo.rows.append({"type": "lost", "station": "desk", "quest_template": "scout-pass"}) + forget_rows(app.state, "agents") + await _world(app) + assert app.state.agent_defs_repo.reads == 2, "a write forgets the kept rows" + + +def test_forgetting_rows_without_a_cache_does_nothing() -> None: + from embry0.services.world import forget_rows + + class State: + world_cache = None + + forget_rows(State(), "agents") + state = State() + state.world_cache = WorldCache() + forget_rows(state, "templates") + + +class _Failing: + def __init__(self) -> None: + self.reads = 0 + + async def list_all(self) -> list[dict[str, Any]]: + self.reads += 1 + raise ConnectionError("the database is gone") + + +async def test_a_failed_read_draws_no_task_and_is_not_kept(app: Any) -> None: + """A read of the rows that fails is no answer: nobody is offered a task from the built-in rows the operator + may have changed, and the next read tries again instead of keeping the failure for a minute.""" + failing = _Failing() + app.state.agent_defs_repo = failing + characters = {c["agent_type"]: c for c in (await _world(app))["characters"]} + assert not any(c["can_quest"] for c in characters.values()), "a failed read offered tasks" + assert characters["developer"]["station"] == "desk" + await _world(app) + assert failing.reads == 2, "a failed read was kept" + + +async def test_a_planned_party_is_drawn_as_the_leader_it_recorded(app: Any) -> None: + async def jobs(**_kw: Any) -> list[dict[str, Any]]: + return [ + { + "job_id": "job-planned", + "repo": "acme/web", + "task": "Scout it", + "status": "running", + "pipeline_template": "scout-pass", + "leader_agent": "watchman", + "created_at": NOW, + } + ] + + async def no_runs(ids: list[str]) -> dict[str, list[tuple[str, str]]]: + return {} + + app.state.world_repo.jobs = jobs + app.state.world_repo.agent_runs = no_runs + party = (await _world(app))["parties"][0] + assert party["leader"] == "watchman", f"the leader it recorded when planned, not the row's now: {party}" diff --git a/tests/unit/mcp/test_agent_guild_fields.py b/tests/unit/mcp/test_agent_guild_fields.py new file mode 100644 index 00000000..88ffb69a --- /dev/null +++ b/tests/unit/mcp/test_agent_guild_fields.py @@ -0,0 +1,52 @@ +"""The MCP agent tools write an agent's ``station`` and ``quest_template`` (#167) through the agents API.""" + +from __future__ import annotations + +import pytest + +from embry0.mcp_server import server +from tests.unit.mcp.test_client import capture, make_client +from tests.unit.mcp.test_server import RecordingClient + + +@pytest.fixture +def fake_client(monkeypatch: pytest.MonkeyPatch) -> RecordingClient: + client = RecordingClient() + monkeypatch.setattr(server, "_client", client) + return client + + +async def test_agent_update_forwards_the_station_and_the_task(fake_client: RecordingClient) -> None: + await server.agent_update("scout", station="lectern", quest_template="scout-pass", confirm=True) + name, args, kwargs = fake_client.calls[-1] + assert (name, args) == ("agent_update", ("scout",)) + assert (kwargs.get("station"), kwargs.get("quest_template"), kwargs.get("clear_quest_template")) == ( + "lectern", + "scout-pass", + False, + ), kwargs + + +async def test_agent_update_refuses_a_task_and_its_clearing_at_once(fake_client: RecordingClient) -> None: + result = await server.agent_update("scout", quest_template="x", clear_quest_template=True, confirm=True) + assert "error" in result and fake_client.calls == [], result + + +async def test_the_client_sends_the_task_its_clearing_and_the_station() -> None: + routes = { + ("PUT", "/api/v1/agents/scout"): {"type": "scout"}, + ("POST", "/api/v1/agents"): (201, {"type": "scout"}), + } + handler = capture(routes) + client = make_client(handler) + await client.agent_update("scout", quest_template="") + assert handler.seen[("PUT", "/api/v1/agents/scout")] == {"quest_template": ""}, "'' names no template: it is sent" + await client.agent_update("scout", clear_quest_template=True) + assert handler.seen[("PUT", "/api/v1/agents/scout")] == {"quest_template": None}, ( + "clearing sends the one null that takes the task out of the guild" + ) + await client.agent_update("scout", station="map_table") + assert handler.seen[("PUT", "/api/v1/agents/scout")] == {"station": "map_table"}, "nothing else is cleared" + await client.agent_create("scout", description="d", model="m", station="anvil", quest_template="why") + sent = handler.seen[("POST", "/api/v1/agents")] + assert (sent.get("station"), sent.get("quest_template")) == ("anvil", "why"), sent diff --git a/tests/unit/mcp/test_server.py b/tests/unit/mcp/test_server.py index 6a92f0df..335882a2 100644 --- a/tests/unit/mcp/test_server.py +++ b/tests/unit/mcp/test_server.py @@ -573,6 +573,8 @@ async def test_agent_create_forwards_every_field(fake_client): color="sky", tagline="Sings.", triage_may_add_skills=["sp:a"], + station="lectern", + quest_template="why", confirm=True, ) assert fake_client.calls == [ @@ -581,6 +583,8 @@ async def test_agent_create_forwards_every_field(fake_client): ("lantern",), { "triage_may_add_skills": ["sp:a"], + "station": "lectern", + "quest_template": "why", "description": "lights", "model": "claude-sonnet-5", "tools": ["Read"], diff --git a/tests/unit/mcp/test_tool_surface.py b/tests/unit/mcp/test_tool_surface.py index 8955b87a..1bc896af 100644 --- a/tests/unit/mcp/test_tool_surface.py +++ b/tests/unit/mcp/test_tool_surface.py @@ -29,7 +29,9 @@ "execution_mode: string|null = null", "mcp_servers: object|null = null", "model: string", + "quest_template: string|null = null", "skills: array|null = null", + 'station: string = "desk"', 'system_prompt: string = ""', 'tagline: string = ""', "tools: array|null = null", @@ -45,6 +47,7 @@ "agent_type: string", "auth_mode: string|null = null", "avatar: string|null = null", + "clear_quest_template: boolean = false", "color: string|null = null", "confirm: boolean = false", "description: string|null = null", @@ -52,7 +55,9 @@ "execution_mode: string|null = null", "mcp_servers: object|null = null", "model: string|null = null", + "quest_template: string|null = null", "skills: array|null = null", + "station: string|null = null", "system_prompt: string|null = null", "tagline: string|null = null", "tools: array|null = null", diff --git a/tests/unit/services/test_context_audit.py b/tests/unit/services/test_context_audit.py index 5ff80118..6ab2c47c 100644 --- a/tests/unit/services/test_context_audit.py +++ b/tests/unit/services/test_context_audit.py @@ -176,6 +176,7 @@ async def newest_per_repo( pipeline_template: str, dispatched_by: str | None = None, exclude_dispatched_by: Collection[str] = (), + leader_agent: str | None = None, ) -> dict[str, dict[str, Any]]: out: dict[str, dict[str, Any]] = {} for repo in repos: @@ -186,6 +187,7 @@ async def newest_per_repo( and ( r["pipeline_template"] == pipeline_template or (dispatched_by and r["dispatched_by"] == dispatched_by) + or (leader_agent and r.get("leader_agent") == leader_agent) ) and (r["dispatched_by"] or "") not in exclude_dispatched_by ] diff --git a/tests/unit/services/test_context_audit_forge_budget.py b/tests/unit/services/test_context_audit_forge_budget.py index dc1fc4be..aafadd79 100644 --- a/tests/unit/services/test_context_audit_forge_budget.py +++ b/tests/unit/services/test_context_audit_forge_budget.py @@ -127,7 +127,13 @@ def __init__(self) -> None: self.rows: list[dict[str, Any]] = [] async def newest_per_repo( - self, repos: Any, *, pipeline_template: str, dispatched_by: Any = None, exclude_dispatched_by: Any = () + self, + repos: Any, + *, + pipeline_template: str, + dispatched_by: Any = None, + exclude_dispatched_by: Any = (), + leader_agent: Any = None, ) -> dict[str, dict[str, Any]]: out: dict[str, dict[str, Any]] = {} for repo in repos: diff --git a/tests/unit/services/test_context_audit_moved_settings.py b/tests/unit/services/test_context_audit_moved_settings.py index 95be2544..4ccf32f5 100644 --- a/tests/unit/services/test_context_audit_moved_settings.py +++ b/tests/unit/services/test_context_audit_moved_settings.py @@ -80,7 +80,13 @@ def _add(self, repo: str, status: str, created: datetime, finished: Any, by: str return str(self.rows[-1]["job_id"]) async def newest_per_repo( - self, repos: Any, *, pipeline_template: str, dispatched_by: Any = None, exclude_dispatched_by: Any = () + self, + repos: Any, + *, + pipeline_template: str, + dispatched_by: Any = None, + exclude_dispatched_by: Any = (), + leader_agent: Any = None, ) -> dict[str, dict[str, Any]]: out: dict[str, dict[str, Any]] = {} for repo in repos: diff --git a/tests/unit/services/test_fix_job_progression.py b/tests/unit/services/test_fix_job_progression.py index 953ab3f5..382ec885 100644 --- a/tests/unit/services/test_fix_job_progression.py +++ b/tests/unit/services/test_fix_job_progression.py @@ -49,13 +49,14 @@ async def _job( pr: int, merged: bool = False, ) -> None: - """A context-audit job created half an hour before it finished, with its pull request open or merged.""" + """A context-audit job created half an hour before it finished, with its pull request open or merged, led by + the context auditor as plan_route records it (#167).""" await db.execute( """ INSERT INTO jobs (job_id, repo, task, status, pipeline_template, dispatched_by, result_summary, pr_url, delivery_state, delivery_state_at, created_at, started_at, finished_at, updated_at, - budget_overrun_usd) - VALUES ($1, $2, 't', $3, 'context-audit', $4, $5, $6, $7, $8, $9, $9, $10, $10, 0) + budget_overrun_usd, leader_agent) + VALUES ($1, $2, 't', $3, 'context-audit', $4, $5, $6, $7, $8, $9, $9, $10, $10, 0, 'context-auditor') """, job_id, repo, diff --git a/tests/unit/services/test_progression_golden.py b/tests/unit/services/test_progression_golden.py index dda6247d..510026f4 100644 --- a/tests/unit/services/test_progression_golden.py +++ b/tests/unit/services/test_progression_golden.py @@ -123,6 +123,7 @@ def _quest_xp_cases() -> dict[str, Any]: { "status": "pr_merged", "pipeline_template": "slop-cleanup", + "coding_agent": "slopmaster", # as plan_route records it for a slop cleanup (#167) "pr_url": "https://forge/pull/2", "work_nature": {"cleanup": {"added": 3, "removed": 90}}, "finished_at": NOW, diff --git a/tests/unit/services/test_progression_rollup.py b/tests/unit/services/test_progression_rollup.py index 2ac7fca3..8a936334 100644 --- a/tests/unit/services/test_progression_rollup.py +++ b/tests/unit/services/test_progression_rollup.py @@ -38,6 +38,7 @@ from embry0.services.quests import Reward, quest_rewards, run_agents from embry0.storage.repositories.progression import FINISHED_WELL, Fact, JobRow, Journal, ProgressEvent, RunRow from embry0.storage.repositories.progression_rollup import DailyXp, Milestone, RollupHead, StoredRollup +from tests.fixtures.builtin_rows import planned_credit RULES = default_rules() @@ -852,7 +853,8 @@ def _journal_with_every_agent(rng: random.Random) -> Journal: status = rng.choice(("failed", "pr_merged", "completed")) template = rng.choice((None, "qa", "onboard", "context-audit")) report = "Stale: 0\nFiles: 3\n" if template == "context-audit" and status in FINISHED_WELL else None - jobs.append(JobRow(repo, issue, template, status, created, created + timedelta(hours=1), report)) + leader = planned_credit(template)["leader_agent"] # as plan_route records it (#167) + jobs.append(JobRow(repo, issue, template, status, created, created + timedelta(hours=1), report, leader=leader)) for _ in range(rng.randint(0, 3)): agent = rng.choice(("review", "pr-reviewer", "qa", "triage", "developer", "ops", "research")) runs.append(RunRow(agent, repo, issue, created + timedelta(minutes=rng.uniform(0, 50)))) diff --git a/tests/unit/services/test_quest_job_times.py b/tests/unit/services/test_quest_job_times.py index b1dfdc62..15c316f3 100644 --- a/tests/unit/services/test_quest_job_times.py +++ b/tests/unit/services/test_quest_job_times.py @@ -21,6 +21,7 @@ from embry0.services.quests import Diary, evaluate_diaries, evaluate_quests, run_agents from embry0.storage.repositories.job_delivery import JobDeliveryRepository from embry0.storage.repositories.progression import ProgressionRepository +from tests.fixtures.builtin_rows import planned_credit pytestmark = [pytest.mark.requires_postgres, pytest.mark.asyncio] @@ -63,12 +64,15 @@ async def _job( report: str | None = None, pr: int | None = None, ) -> None: - """A job created half an hour before it finished, with an open pull request numbered ``pr``.""" + """A job created half an hour before it finished, with an open pull request numbered ``pr``, and what + plan_route records for its template (#167).""" + credit = planned_credit(template) await db.execute( """ INSERT INTO jobs (job_id, repo, task, status, issue_id, pipeline_template, result_summary, pr_url, - delivery_state, created_at, started_at, finished_at, updated_at, budget_overrun_usd) - VALUES ($1, $2, 't', $3, $4, $5, $6, $7, $8, $9, $9, $10, $10, 0) + delivery_state, created_at, started_at, finished_at, updated_at, budget_overrun_usd, + coding_agent, leader_agent) + VALUES ($1, $2, 't', $3, $4, $5, $6, $7, $8, $9, $9, $10, $10, 0, $11, $12) """, job_id, REPO, @@ -80,6 +84,8 @@ async def _job( "pr_open" if pr is not None else "none", finished - timedelta(minutes=30), finished, + credit["coding_agent"], + credit["leader_agent"], ) diff --git a/tests/unit/services/test_quests.py b/tests/unit/services/test_quests.py index 44d5f985..76094dbf 100644 --- a/tests/unit/services/test_quests.py +++ b/tests/unit/services/test_quests.py @@ -10,6 +10,7 @@ from embry0.services.progression_pack import default_rules from embry0.services.quests import evaluate_diaries, evaluate_quests, quest_rewards from embry0.storage.repositories.progression import JobRow, Journal, RunRow +from tests.fixtures.builtin_rows import planned_credit T0 = datetime(2026, 10, 1, tzinfo=UTC) RULES = default_rules() @@ -29,7 +30,9 @@ def job( template: str | None = None, report: str | None = None, ) -> JobRow: - return JobRow(repo, issue, template, status, at(hours) - timedelta(minutes=30), at(hours), report) + # Led by its template's leader, as plan_route records it (#167): a context-audit job is an audit. + leader = planned_credit(template)["leader_agent"] + return JobRow(repo, issue, template, status, at(hours) - timedelta(minutes=30), at(hours), report, leader=leader) def run(hours: float, agent: str, *, repo: str = "o/app", issue: str | None = None) -> RunRow: diff --git a/tests/unit/services/test_world.py b/tests/unit/services/test_world.py index 018b8800..7e16614c 100644 --- a/tests/unit/services/test_world.py +++ b/tests/unit/services/test_world.py @@ -7,7 +7,6 @@ from embry0.services.progression_pack import default_rules from embry0.services.world import ( IDLE_DAYS, - QUEST_TEMPLATE_BY_AGENT, STUCK_SECONDS, character_entries, failure_words, @@ -189,7 +188,8 @@ def test_each_character_is_where_its_newest_quest_is(): assert entries["qa"]["idle_too_long"], "a character that never went out is idle too long" assert entries["developer"]["can_quest"] and entries["why"]["quest_template"] == "why" assert not entries["qa"]["can_quest"], "QA works only inside another quest" - assert "triage" not in QUEST_TEMPLATE_BY_AGENT + triage = character_entries([_character("triage")], [], {}, NOW)[0] + assert not triage["can_quest"], "triage works only inside another quest" def test_a_quest_earns_its_party_experience_and_the_merge_its_developer(): diff --git a/tests/unit/storage/test_behaviour_rows_repo.py b/tests/unit/storage/test_behaviour_rows_repo.py new file mode 100644 index 00000000..44391e3b --- /dev/null +++ b/tests/unit/storage/test_behaviour_rows_repo.py @@ -0,0 +1,391 @@ +"""The behaviour columns (#167) in the database: written, read and copied by the repositories, and read by +the queries that credit a job's work and brief it. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest + +from embry0.storage.database import DatabasePool +from embry0.storage.repositories.agent_definitions import BUILTIN_SEED, AgentDefinitionsRepository +from embry0.storage.repositories.job_briefs import JobBriefsRepository +from embry0.storage.repositories.pipeline_templates import PipelineTemplatesRepository +from embry0.storage.repositories.progression import ProgressionRepository +from embry0.storage.repositories.world import WorldRepository +from embry0.storage.seeds.pipeline_templates_builtin import BUILTIN_PIPELINE_TEMPLATES +from embry0.workflows import row_behaviour as rb + +pytestmark = pytest.mark.requires_postgres + +GRAPH = { + "nodes": [{"node_id": "t", "agent_type": "triage"}, {"node_id": "s", "agent_type": "slopmaster"}], + "edges": [{"edge_id": "e1", "source": "t", "target": "s", "edge_type": "flow"}], +} +BEHAVIOUR = { + "fixed_route": True, + "offered_to_triage": False, + "job_kind": "analysis", + "delivers": "a list of dead flags", + "leader_agent": "why", +} + + +async def test_a_template_keeps_what_it_says_of_its_jobs(db_with_migrations: DatabasePool) -> None: + repo = PipelineTemplatesRepository(db_with_migrations) + plain = await repo.create(name="plain", graph_definition=GRAPH) + assert {k: plain[k] for k in rb.TEMPLATE_DEFAULTS} == dict(rb.TEMPLATE_DEFAULTS), plain + + made = await repo.create(name="scout-pass", graph_definition=GRAPH, **BEHAVIOUR) + assert {k: made[k] for k in BEHAVIOUR} == BEHAVIOUR, made + listed = {r["name"]: r for r in await repo.list_all()} + assert {k: listed["scout-pass"][k] for k in BEHAVIOUR} == BEHAVIOUR, "the summary carries what the row says" + + updated = await repo.update(made["id"], job_kind=None, leader_agent=None, delivers=" a report ") + assert (updated["job_kind"], updated["leader_agent"], updated["delivers"]) == (None, None, "a report") + + copy = await repo.duplicate(made["id"], new_name="scout-pass-2") + assert {k: copy[k] for k in BEHAVIOUR} == {k: updated[k] for k in BEHAVIOUR}, ( + "a copy treats its jobs as its source does" + ) + assert copy["default_for_kind"] is None, "a copy is no kind's default" + + with pytest.raises(ValueError, match="job_kind"): + await repo.update(made["id"], job_kind="chores") + with pytest.raises(ValueError, match="delivers"): + await repo.create(name="bad", graph_definition=GRAPH, delivers="two\nlines") + with pytest.raises(ValueError, match="Unknown template fields"): + await repo.create(name="bad", graph_definition=GRAPH, roles=["coder"]) + + +async def test_an_agent_keeps_its_station_and_its_task(db_with_migrations: DatabasePool) -> None: + repo = AgentDefinitionsRepository(db_with_migrations) + made = await repo.create("scout", "d", "m", tools=["Read"]) + assert (made["station"], made["quest_template"]) == ("desk", None), made + made = await repo.update("scout", station="lectern", quest_template="scout-pass") + assert (made["station"], made["quest_template"]) == ("lectern", "scout-pass"), made + made = await repo.update("scout", quest_template="") + assert made["quest_template"] == "", "'' is a task naming no template, not none" + with pytest.raises(ValueError, match="station"): + await repo.update("scout", station="throne") + with pytest.raises(ValueError, match="station"): + await repo.create("other", "d", "m", tools=["Read"], station="throne") + + +async def test_every_seeded_builtin_agent_holds_its_station_and_task(db_with_migrations: DatabasePool) -> None: + """The qa, onboarding and pr-reviewer rows come from their own seeders; each writes its seed's values.""" + from embry0.storage.seeds.agents_builtin import seed_missing_builtin_agents + from embry0.workflows.onboard.agent_seed import seed_onboarding_agent + from embry0.workflows.pr_review.agent_seed import seed_pr_reviewer_agent + from embry0.workflows.qa.agent_seed import seed_qa_agent + + db = db_with_migrations + await db.execute("DELETE FROM agent_definitions") + repo = AgentDefinitionsRepository(db) + for seed in (seed_qa_agent, seed_onboarding_agent, seed_pr_reviewer_agent, seed_missing_builtin_agents): + await seed(repo) + rows = {r["type"]: r for r in await repo.list_all()} + differ = { + t: ((rows[t]["station"], rows[t]["quest_template"]), (seed["station"], seed["quest_template"])) + for t, seed in BUILTIN_SEED.items() + if (rows[t]["station"], rows[t]["quest_template"]) != (seed["station"], seed["quest_template"]) + } + assert not differ, f"seeded rows that differ from their seed (got, want): {differ}" + + +async def test_a_reset_puts_the_station_and_the_task_back(db_with_migrations: DatabasePool) -> None: + from embry0.storage.seeds.agents_builtin import seed_missing_builtin_agents + + repo = AgentDefinitionsRepository(db_with_migrations) + await seed_missing_builtin_agents(repo) + await repo.update("developer", station="desk", quest_template=None) + reset = await repo.reset("developer") + assert (reset["station"], reset["quest_template"]) == ("anvil", ""), reset + + +# --- what reads the rows -------------------------------------------------------------------------------------- + +AT = datetime(2026, 3, 2, 12, tzinfo=UTC) + + +async def _job(db: DatabasePool, job_id: str, template: str | None, **row: Any) -> None: + row = {"status": "pr_merged", "pr_url": f"https://forge/pull/{job_id}", "delivery_state": "merged", **row} + await db.execute( + "INSERT INTO jobs (job_id, repo, area, task, status, pipeline_template, pr_url, delivery_state, " + "delivery_state_at, finished_at, created_at, budget_overrun_usd, job_kind) " + "VALUES ($1, 'acme/widgets', 'Billing', 'Clean the flags', $2, $3, $4, $5, $6, $6, $7, 0, $8)", + job_id, + row["status"], + template, + row["pr_url"], + row["delivery_state"], + row.get("finished_at", AT), + AT - timedelta(hours=1), + row.get("job_kind", "code"), + ) + + +async def _seed(db: DatabasePool) -> None: + """The built-in templates and agents, as the boot seeders write them.""" + from embry0.storage.seeds.agents_builtin import seed_missing_builtin_agents + from embry0.storage.seeds.pipeline_templates_builtin import seed_builtin_pipeline_templates + + await seed_builtin_pipeline_templates(PipelineTemplatesRepository(db)) + await seed_missing_builtin_agents(AgentDefinitionsRepository(db)) + + +async def _plan(db: DatabasePool, job_id: str, template: str, kind: str = "code") -> dict[str, Any]: + """Plan ``job_id``, which names ``template``, as the job's plan_route step does.""" + from unittest.mock import patch + + from embry0.storage.repositories.jobs import JobsRepository + from embry0.workflows.issue_to_pr import plan_route + + config = { + "configurable": { + "pipeline_templates_repo": PipelineTemplatesRepository(db), + "jobs_repo": JobsRepository(db), + "db": db, + } + } + state = { + "job_id": job_id, + "task": "Clean the flags", + "pipeline_template_request": template, + "triage_decision": {"job_kind": kind, "pipeline_template": template}, + } + with patch.object(plan_route, "get_stream_writer", return_value=lambda _e: None): + return await plan_route.plan_route_node(state, config) + + +def _credit(events: list[Any]) -> list[tuple[str, str | None]]: + return sorted((e.source, e.agent_type) for e in events if e.source in ("merged", "opened", "cleaned", "audited")) + + +async def test_a_job_keeps_the_credit_it_recorded_whatever_its_template_row_says_later( + db_with_migrations: DatabasePool, +) -> None: + """Credit is what plan_route recorded on the job (#167): an edit or a deletion of the template row after + changes no past credit, and a new job is credited as the row says by then.""" + db = db_with_migrations + await _seed(db) + repo = PipelineTemplatesRepository(db) + slop_graph = BUILTIN_PIPELINE_TEMPLATES["slop-cleanup"]["graph_definition"] + await repo.create(name="my-slop", graph_definition=slop_graph, leader_agent="slopmaster") + planned = {"j-slop": "slop-cleanup", "j-my-slop": "my-slop", "j-fix": "quick-fix", "j-audit": "context-audit"} + for job_id, template in planned.items(): + await _job(db, job_id, template, status="running", pr_url=None, delivery_state="none") + await _plan(db, job_id, template, kind="analysis" if template == "context-audit" else "code") + recorded = { + r["job_id"]: (r["coding_agent"], r["leader_agent"]) + for r in await db.fetch("SELECT job_id, coding_agent, leader_agent FROM jobs") + } + assert recorded == { + "j-slop": ("slopmaster", "slopmaster"), + "j-my-slop": ("slopmaster", "slopmaster"), + "j-fix": ("developer", None), + "j-audit": ("developer", "context-auditor"), + }, f"what plan_route records: {recorded}" + await db.execute( + "UPDATE jobs SET status = 'pr_merged', delivery_state = 'merged', pr_url = 'https://forge/pull/' || job_id " + "WHERE job_id <> 'j-audit'" + ) + await db.execute("UPDATE jobs SET status = 'completed', result_summary = 'Stale: 1' WHERE job_id = 'j-audit'") + before_edit = _credit(await ProgressionRepository(db).events()) + cleaned = await WorldRepository(db).cleaned(days=100_000) + assert ("audited", "context-auditor") in before_edit and before_edit.count(("cleaned", "slopmaster")) == 2 + assert cleaned == {("acme/widgets", "Billing"): 2}, cleaned + + # The operator clears the audit's leader, gives the slop route to the developer and deletes their own. + fix_graph = BUILTIN_PIPELINE_TEMPLATES["quick-fix"]["graph_definition"] + await db.execute("UPDATE pipeline_templates SET leader_agent = NULL WHERE name = 'context-audit'") + await db.execute( + "UPDATE pipeline_templates SET leader_agent = 'developer', graph_definition = $1 WHERE name = 'slop-cleanup'", + fix_graph, + ) + await db.execute("DELETE FROM pipeline_templates WHERE name = 'my-slop'") + assert _credit(await ProgressionRepository(db).events()) == before_edit, "an edit of the rows moved past credit" + assert await WorldRepository(db).cleaned(days=100_000) == cleaned, "an edit of the rows moved the dungeon" + + # A job planned since is credited as the rows say now. + await _job(db, "j-after", "slop-cleanup", status="running", pr_url=None, delivery_state="none") + await _plan(db, "j-after", "slop-cleanup") + after = await db.fetchrow("SELECT coding_agent, leader_agent FROM jobs WHERE job_id = 'j-after'") + assert (after["coding_agent"], after["leader_agent"]) == ("developer", "developer"), dict(after) + + +async def test_an_audit_is_a_job_the_context_auditor_led(db_with_migrations: DatabasePool) -> None: + """A copy of the context-audit template is an audit (it keeps the leader); a template led by another agent + is not, whatever its name (#167).""" + db = db_with_migrations + await _seed(db) + repo = PipelineTemplatesRepository(db) + audit = next(r for r in await repo.list_all() if r["name"] == "context-audit") + await repo.duplicate(audit["id"], new_name="my-audit") + other = await repo.create( + name="audit-led-by-why", graph_definition=BUILTIN_PIPELINE_TEMPLATES["why"]["graph_definition"] + ) + await repo.update(other["id"], leader_agent="why") + for job_id, template in (("j-copy", "my-audit"), ("j-why", "audit-led-by-why"), ("j-builtin", "context-audit")): + await _job(db, job_id, template, status="running", pr_url=None, delivery_state="none") + await _plan(db, job_id, template, kind="analysis") + await db.execute("UPDATE jobs SET status = 'completed', result_summary = 'Stale: 2'") + audited = sorted( + (e.agent_type, e.report) for e in await ProgressionRepository(db).events() if e.source == "audited" + ) + assert audited == [("context-auditor", "Stale: 2"), ("context-auditor", "Stale: 2")], ( + f"the copy and the built-in are audits, the template led by why is none: {audited}" + ) + journal = await ProgressionRepository(db).journal() + assert sorted(j.template for j in journal.jobs if j.is_audit) == ["context-audit", "my-audit"] + + +async def test_a_brief_says_what_the_jobs_template_row_delivers(db_with_migrations: DatabasePool) -> None: + from embry0.services.job_briefs import input_hash, user_prompt + + db = db_with_migrations + await _seed(db) + await PipelineTemplatesRepository(db).create(name="scout-pass", graph_definition=GRAPH, **BEHAVIOUR) + for job_id, template in (("j-own", "scout-pass"), ("j-map", "map-areas"), ("j-qa", "qa"), ("j-none", None)): + await _job(db, job_id, template, status="running", pr_url=None, delivery_state="none", finished_at=None) + candidates = {c["job_id"]: c for c in await JobBriefsRepository(db).candidates()} + said = { + job_id: user_prompt(c).split("What it delivers: ", 1)[1].split("\n", 1)[0] for job_id, c in candidates.items() + } + assert said == { + "j-own": "a list of dead flags", + "j-map": "a pull request that maps the repository's areas", + "j-qa": "a test report", + "j-none": "a pull request with the change", + }, said + stripped = {k: v for k, v in candidates["j-own"].items() if k != "template_delivers"} + assert input_hash(candidates["j-own"]) == input_hash(stripped), "what a template delivers is no input of the hash" + + +async def test_a_rename_carries_each_agents_guild_task_with_it(db_with_migrations: DatabasePool) -> None: + """An agent's quest_template names a template: renaming the template moves it in the same transaction, so + the guild keeps sending the agent on what the operator chose (#167).""" + db = db_with_migrations + templates = PipelineTemplatesRepository(db) + agents = AgentDefinitionsRepository(db) + made = await templates.create(name="scout-pass", graph_definition=GRAPH) + await agents.create("scout", "d", "m", tools=["Read"], quest_template="scout-pass") + await agents.create("watcher", "d", "m", tools=["Read"], quest_template="other") + await templates.update(made["id"], name="scout-run") + tasks = {r["type"]: r["quest_template"] for r in await agents.list_all() if r["type"] in ("scout", "watcher")} + assert tasks == {"scout": "scout-run", "watcher": "other"}, f"the task did not follow the rename: {tasks}" + await templates.update(made["id"], description="same name") + assert (await agents.get("scout"))["quest_template"] == "scout-run", "a save that keeps the name moves nothing" + + +async def _audit_job( + db: DatabasePool, job_id: str, repo: str, template: str, leader: str, by: str | None = None +) -> None: + await db.execute( + "INSERT INTO jobs (job_id, repo, task, status, pipeline_template, leader_agent, dispatched_by, result_summary, " + "finished_at, created_at, budget_overrun_usd) " + "VALUES ($1, $2, 't', 'completed', $3, $4, $5, 'Stale: 3', $6, $7, 0)", + job_id, + repo, + template, + leader, + by, + AT, + AT - timedelta(hours=1), + ) + + +async def test_progression_the_findings_feed_and_the_scheduler_agree_on_what_an_audit_is( + db_with_migrations: DatabasePool, +) -> None: + """One predicate (audit_jobs): a copy of the context-audit template, led by the context auditor, is an audit + to all three; a context-audit job led by another agent, after an operator changed the template's leader, earns + no audit credit and offers no findings. The scheduler counts every audit, and also its own template's jobs + and its own dispatches whatever led them, so it never starts an audit again on every tick.""" + from embry0.services.context_audit import ACTOR, FIX_ACTOR, ContextAuditLoop + from embry0.storage.repositories.jobs import JobsRepository + from embry0.storage.repositories.proposals import ProposalsRepository + + db = db_with_migrations + await _audit_job(db, "j-copy", "acme/copy", "my-audit", "context-auditor") + await _audit_job(db, "j-changed", "acme/changed", "context-audit", "interrogator") + await _audit_job(db, "j-scheduled", "acme/scheduled", "context-audit", "interrogator", by=ACTOR) + await _audit_job(db, "j-fix", "acme/fix", "context-audit", "context-auditor", by=FIX_ACTOR) + + journal = await ProgressionRepository(db).journal() + progression = sorted(j.repo for j in journal.jobs if j.is_audit) + audited = [e.agent_type for e in await ProgressionRepository(db).events() if e.source == "audited"] + offered = sorted(r["repo"] for r in await ProposalsRepository(db).audits()) + loop = ContextAuditLoop.__new__(ContextAuditLoop) # only _audits is read; it needs the jobs repository alone + loop._jobs = JobsRepository(db) # noqa: SLF001 + newest, live = await loop._audits(["acme/copy", "acme/changed", "acme/scheduled", "acme/fix"]) # noqa: SLF001 + + assert progression == ["acme/copy"], f"progression's audits: {progression}" + assert audited == ["context-auditor"], f"one audit earns its credit: {audited}" + assert offered == progression, f"the findings feed offers what progression counts as audits: {offered}" + assert sorted(newest) == ["acme/changed", "acme/copy", "acme/scheduled"], ( + f"the scheduler's newest: every audit, the copy included, and its own template's and dispatches' jobs, " + f"never a fix job: {sorted(newest)}" + ) + assert live == set(), live + + +async def test_a_job_that_names_a_template_takes_its_leader_when_it_is_created( + db_with_migrations: DatabasePool, +) -> None: + """Before plan_route records it: a pending audit is known for one (the scheduler's live check reads it).""" + from embry0.storage.repositories.jobs import JobsRepository + + db = db_with_migrations + await PipelineTemplatesRepository(db).create( + name="my-audit", graph_definition=GRAPH, leader_agent="context-auditor" + ) + jobs = JobsRepository(db) + named = await jobs.create(repo="acme/widgets", task="t", pipeline_template="my-audit") + plain = await jobs.create(repo="acme/widgets", task="t") + unknown = await jobs.create(repo="acme/widgets", task="t", pipeline_template="qa") + leaders = {r["job_id"]: r["leader_agent"] for r in await db.fetch("SELECT job_id, leader_agent FROM jobs")} + assert leaders == {named: "context-auditor", plain: None, unknown: None}, leaders + + +async def test_two_renames_at_once_carry_the_task_to_the_last_name(db_with_migrations: DatabasePool) -> None: + """The rename reads the old name under the row's lock: a rename beside it waits, then reads the name it left, + so an agent's task follows both renames to the template's final name.""" + import asyncio + + import asyncpg + + db = db_with_migrations + templates = PipelineTemplatesRepository(db) + made = await templates.create(name="alpha", graph_definition=GRAPH) + await AgentDefinitionsRepository(db).create("scout", "d", "m", tools=["Read"], quest_template="alpha") + # Two of the pool's connections open, one for each rename, before the row is held. + await asyncio.gather(*(db.fetchval("SELECT pg_sleep(0.05)") for _ in range(3))) + blocker = await asyncpg.connect(db._url) # noqa: SLF001 - a second session, holding the row + try: + held = blocker.transaction() + await held.start() + await blocker.execute("SELECT 1 FROM pipeline_templates WHERE id = $1 FOR UPDATE", made["id"]) + first = asyncio.create_task(templates.update(made["id"], name="beta")) + second = asyncio.create_task(templates.update(made["id"], name="gamma")) + for _ in range(200): # until both renames wait on the row + waiting = await blocker.fetchval( + "SELECT count(*) FROM pg_stat_activity WHERE datname = current_database() AND wait_event_type = 'Lock'" + ) + if waiting >= 2: + break + await asyncio.sleep(0.02) + activity = await blocker.fetch( + "SELECT state, wait_event_type, wait_event, left(query, 60) AS q FROM pg_stat_activity " + "WHERE datname = current_database()" + ) + assert waiting >= 2, f"both renames should wait on the row before it is let go: {[dict(a) for a in activity]}" + await held.rollback() + finally: + await blocker.close() + await asyncio.gather(first, second) + name = await db.fetchval("SELECT name FROM pipeline_templates WHERE id = $1", made["id"]) + task = (await AgentDefinitionsRepository(db).get("scout"))["quest_template"] + assert task == name, f"template now {name}, the agent's task {task}: a rename read a stale name" diff --git a/tests/unit/storage/test_migration_142.py b/tests/unit/storage/test_migration_142.py index e8824cd8..d48dbfcf 100644 --- a/tests/unit/storage/test_migration_142.py +++ b/tests/unit/storage/test_migration_142.py @@ -18,10 +18,8 @@ DEVELOPER = ["superpowers:brainstorming", "superpowers:writing-plans"] -def test_the_triage_may_add_migration_is_the_last_entry() -> None: - """The newest migration owns this assertion; it passes to the next one - (it came from tests/unit/storage/test_migration_141.py).""" - assert MIGRATIONS[-1][0] == VERSION, f"migration {VERSION} must add agent_definitions.triage_may_add_skills" +def test_the_triage_may_add_migration_is_listed_once() -> None: + """The "last entry" assertion passed to tests/unit/storage/test_migration_143.py.""" assert [v for v, _, _ in MIGRATIONS].count(VERSION) == 1, f"migration {VERSION} must be listed once" diff --git a/tests/unit/storage/test_migration_143.py b/tests/unit/storage/test_migration_143.py new file mode 100644 index 00000000..7b37b7f9 --- /dev/null +++ b/tests/unit/storage/test_migration_143.py @@ -0,0 +1,263 @@ +"""Migration 143 — behaviour from agent and template rows, not built-in names (#167). + +An upgraded database's rows must say what the code said by their names +before (tests/fixtures/name_lists_before_rows.py), and the boot seed must +never overwrite what an operator changed since. + +The number is ``VERSION`` alone: renumbering the migration changes that line and this file's name. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from embry0.storage.database import DatabasePool +from embry0.storage.migrations.runner import MIGRATIONS, run_migrations +from embry0.storage.repositories.pipeline_templates import PipelineTemplatesRepository +from embry0.storage.seeds.pipeline_templates_builtin import BUILTIN_PIPELINE_TEMPLATES +from embry0.workflows.row_behaviour import recorded_kind +from tests.fixtures import name_lists_before_rows as before + +#: This migration's number. +VERSION = 143 + +TEMPLATE_COLUMNS = ("fixed_route", "offered_to_triage", "job_kind", "delivers", "leader_agent") +AGENT_COLUMNS = ("station", "quest_template") +JOB_COLUMNS = ("coding_agent", "leader_agent") + + +def test_the_behaviour_rows_migration_is_the_last_entry() -> None: + """The newest migration owns this assertion; it passes to the next one + (it came from tests/unit/storage/test_migration_142.py).""" + assert MIGRATIONS[-1][0] == VERSION, f"migration {VERSION} must add the template and agent behaviour columns" + assert [v for v, _, _ in MIGRATIONS].count(VERSION) == 1, f"migration {VERSION} must be listed once" + + +async def _columns(db: DatabasePool, table: str, names: tuple[str, ...]) -> list[str]: + rows = await db.fetch( + "SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND column_name = ANY($2::text[])", + table, + list(names), + ) + return sorted(str(r["column_name"]) for r in rows) + + +async def _undo(db: DatabasePool) -> None: + """The database as it stood before the migration.""" + assert await _columns(db, "pipeline_templates", TEMPLATE_COLUMNS) == sorted(TEMPLATE_COLUMNS), ( + "pipeline_templates lacks a behaviour column after migrating" + ) + assert await _columns(db, "agent_definitions", AGENT_COLUMNS) == sorted(AGENT_COLUMNS), ( + "agent_definitions lacks station or quest_template after migrating" + ) + await db.execute("ALTER TABLE pipeline_templates " + ", ".join(f"DROP COLUMN {c}" for c in TEMPLATE_COLUMNS)) + assert await _columns(db, "jobs", JOB_COLUMNS) == sorted(JOB_COLUMNS), "jobs lacks the recorded credit" + await db.execute("ALTER TABLE agent_definitions DROP COLUMN station, DROP COLUMN quest_template") + await db.execute("ALTER TABLE jobs DROP COLUMN coding_agent, DROP COLUMN leader_agent") + await db.execute("DELETE FROM embry0_migrations WHERE version = $1", VERSION) + + +async def _old_template( + db: DatabasePool, name: str, graph: dict[str, Any], *, builtin: bool, kind: str | None = None +) -> None: + """A template row as a release before this migration wrote it.""" + await db.execute( + "INSERT INTO pipeline_templates (id, name, graph_definition, is_builtin, default_for_kind) " + "VALUES ($1, $1, $2::jsonb, $3, $4)", + name, + graph, # the pool's jsonb codec encodes it + builtin, + kind, + ) + + +def _route(*agents: str) -> dict[str, Any]: + nodes = [{"node_id": f"n{i}", "agent_type": a} for i, a in enumerate(("triage", *agents))] + edges = [ + {"edge_id": f"e{i}", "source": f"n{i}", "target": f"n{i + 1}", "edge_type": "flow"} for i in range(len(agents)) + ] + return {"nodes": nodes, "edges": edges} + + +async def _templates(db: DatabasePool) -> dict[str, dict[str, Any]]: + rows = await db.fetch(f"SELECT name, {', '.join(TEMPLATE_COLUMNS)} FROM pipeline_templates ORDER BY name") + return {str(r["name"]): {c: r[c] for c in TEMPLATE_COLUMNS} for r in rows} + + +async def _agents(db: DatabasePool) -> dict[str, dict[str, Any]]: + rows = await db.fetch("SELECT type, station, quest_template FROM agent_definitions ORDER BY type") + return {str(r["type"]): {"station": r["station"], "quest_template": r["quest_template"]} for r in rows} + + +def _what_the_name_said(name: str) -> dict[str, Any]: + specialist = name in before.SPECIALIST_TEMPLATE_NAMES + return { + "fixed_route": specialist, + "offered_to_triage": not specialist, + # No override: the kind is read from the graph, as the old rule read it (row_behaviour.recorded_kind). + "job_kind": None, + "delivers": before.TEMPLATE_DELIVERS.get(name, ""), + "leader_agent": before.LEADER_BY_TEMPLATE.get(name), + } + + +@pytest.mark.requires_postgres +async def test_an_upgrade_writes_each_template_what_its_name_said(db_with_migrations: DatabasePool) -> None: + db = db_with_migrations + await _undo(db) + for name, seed in BUILTIN_PIPELINE_TEMPLATES.items(): + await _old_template(db, name, seed["graph_definition"], builtin=True, kind=seed.get("default_for_kind")) + # The deployment's own: a copy of a specialist route, one of two specialists, a coding route and an + # empty graph; none of them was offered anything by name. + await _old_template(db, "my-why", BUILTIN_PIPELINE_TEMPLATES["why"]["graph_definition"], builtin=False) + await _old_template(db, "two-specialists", _route("why", "how", "output"), builtin=False) + await _old_template(db, "my-fix", _route("developer", "reviewer"), builtin=False) + await _old_template(db, "specialist-then-code", _route("kintsugi", "developer"), builtin=False) + await _old_template(db, "empty", {"nodes": [], "edges": []}, builtin=False) + await run_migrations(db) + + after = await _templates(db) + graphs = {name: seed["graph_definition"] for name, seed in BUILTIN_PIPELINE_TEMPLATES.items()} + graphs.update( + { + "my-why": BUILTIN_PIPELINE_TEMPLATES["why"]["graph_definition"], + "two-specialists": _route("why", "how", "output"), + "my-fix": _route("developer", "reviewer"), + "specialist-then-code": _route("kintsugi", "developer"), + "empty": {"nodes": [], "edges": []}, + } + ) + expected = {name: _what_the_name_said(name) for name in graphs} + differ = {name: (after.get(name), want) for name, want in expected.items() if after.get(name) != want} + assert not differ, f"rows that say other than their names did (got, want): {differ}" + assert expected["my-why"]["fixed_route"] is False, "a copy of a specialist route was triaged before, by name" + full = {r["name"]: dict(r) for r in await db.fetch("SELECT * FROM pipeline_templates")} + kinds = { + name: (recorded_kind(full[name]), before.recorded_kind(name, graph, full[name]["default_for_kind"])) + for name, graph in graphs.items() + } + assert all(got == want for got, want in kinds.values()), f"each row records the kind its name did: {kinds}" + assert kinds["two-specialists"][0] == "analysis", "a route of specialists only stays analysis" + + +@pytest.mark.requires_postgres +async def test_an_upgrade_writes_each_agent_what_its_name_said(db_with_migrations: DatabasePool) -> None: + db = db_with_migrations + await _undo(db) + types = sorted({*before.STATION_OF, *before.QUEST_TEMPLATE_BY_AGENT, "triage", "my-own-agent"}) + for agent_type in types: + await db.execute( + "INSERT INTO agent_definitions (type, description, model) VALUES ($1, 'd', 'm') ON CONFLICT (type) DO NOTHING", + agent_type, + ) + # An agent of the deployment's own under a built-in's name kept what the name gave it. + await db.execute("UPDATE agent_definitions SET is_builtin = false WHERE type = 'why'") + await run_migrations(db) + + after = await _agents(db) + for agent_type in types: + offered = agent_type in before.QUEST_TEMPLATE_BY_AGENT + want = { + "station": before.STATION_OF.get(agent_type, "desk"), + "quest_template": (before.QUEST_TEMPLATE_BY_AGENT.get(agent_type) or "") if offered else None, + } + assert after[agent_type] == want, f"{agent_type}: got {after[agent_type]}, want {want}" + + column = await db.fetchrow( + "SELECT is_nullable, column_default FROM information_schema.columns " + "WHERE table_name = 'agent_definitions' AND column_name = 'station'" + ) + assert column is not None and column["is_nullable"] == "NO", "station is NOT NULL" + assert column["column_default"] == "'desk'::text", f"a new agent sits at a desk: {column['column_default']}" + + +@pytest.mark.requires_postgres +async def test_the_boot_seed_keeps_what_an_operator_changed(db_with_migrations: DatabasePool) -> None: + """Built-in templates are upserted at every boot: the behaviour columns are the operator's, like + default_for_kind, so only a first insert takes the seed's.""" + db = db_with_migrations + repo = PipelineTemplatesRepository(db) + from embry0.storage.seeds.pipeline_templates_builtin import seed_builtin_pipeline_templates + + await seed_builtin_pipeline_templates(repo) + seeded = await _templates(db) + assert seeded["why"] == { + "fixed_route": True, + "offered_to_triage": False, + "job_kind": None, + "delivers": "", + "leader_agent": "why", + }, seeded["why"] + assert seeded["map-areas"]["delivers"] == before.TEMPLATE_DELIVERS["map-areas"] + + changed = { + "fixed_route": False, + "offered_to_triage": True, + "job_kind": None, + "delivers": "an answer to the question", + "leader_agent": "how", + } + await db.execute( + "UPDATE pipeline_templates SET fixed_route = $1, offered_to_triage = $2, job_kind = $3, delivers = $4, " + "leader_agent = $5, graph_definition = '{}'::jsonb WHERE name = 'why'", + *changed.values(), + ) + await db.execute("DELETE FROM pipeline_templates WHERE name = 'how'") + await seed_builtin_pipeline_templates(repo) + + after = await _templates(db) + assert after["why"] == changed, f"the boot seed overwrote what the operator chose: {after['why']}" + graph = await db.fetchval("SELECT graph_definition FROM pipeline_templates WHERE name = 'why'") + assert graph == BUILTIN_PIPELINE_TEMPLATES["why"]["graph_definition"], "the seed still restores the graph" + assert after["how"] == seeded["how"], "a built-in inserted again takes the seed's values" + + +@pytest.mark.requires_postgres +async def test_an_upgrade_credits_each_job_as_its_name_did(db_with_migrations: DatabasePool) -> None: + """A job's pull request, cleanup and audit are credited from what it recorded (#167); a job planned before + the migration gets what the old name rules gave it.""" + db = db_with_migrations + await _undo(db) + templates = (*before.LEADER_BY_TEMPLATE, "quick-fix", "full-magnum-opus", "pr-followup", "mine", None) + for n, template in enumerate(templates): + await db.execute( + "INSERT INTO jobs (job_id, repo, task, status, pipeline_template, budget_overrun_usd) " + "VALUES ($1, 'acme/widgets', 't', 'completed', $2, 0)", + f"job-{n}", + template, + ) + stamped = await db.fetchval("SELECT updated_at FROM jobs WHERE job_id = 'job-0'") + await run_migrations(db) + + rows = { + r["pipeline_template"]: (r["coding_agent"], r["leader_agent"]) + for r in await db.fetch("SELECT pipeline_template, coding_agent, leader_agent FROM jobs") + } + for template in templates: + coder, lead = rows[template] + assert (coder or "developer") == before.coder(template), f"{template}: coded by {coder}" + led = before.LEADER_BY_TEMPLATE.get(template or "") + want = None if template in ("qa", "pr-review", "onboard") else led + assert lead == want, f"{template}: led by {lead}, want {want} (a code pipeline is never planned)" + assert rows["context-audit"][1] == "context-auditor", "a context audit stays an audit" + assert rows["slop-cleanup"][0] == "slopmaster", "a slop cleanup stays the Slopmaster's" + assert await db.fetchval("SELECT updated_at FROM jobs WHERE job_id = 'job-0'") == stamped, ( + "the backfill must not move a job's update time: the reaper reads it" + ) + + +@pytest.mark.requires_postgres +async def test_a_template_named_like_a_code_pipeline_is_renamed(db_with_migrations: DatabasePool) -> None: + """A job's pipeline_template naming qa, onboard or pr-review means that pipeline: no template may hold the + name, so the migration renames one that did.""" + db = db_with_migrations + await _undo(db) + for name in ("qa", "pr-review", "onboard-template", "onboard"): + await _old_template(db, name, _route("developer"), builtin=False) + await run_migrations(db) + names = sorted(r["name"] for r in await db.fetch("SELECT name FROM pipeline_templates")) + assert "qa" not in names and "pr-review" not in names and "onboard" not in names, names + assert {"qa-template", "pr-review-template", "onboard-template"} <= set(names), names + assert any(n.startswith("onboard-template-") for n in names), f"a taken name gets the id's head: {names}" diff --git a/tests/unit/storage/test_progression_repo.py b/tests/unit/storage/test_progression_repo.py index 99e4299f..272b15f4 100644 --- a/tests/unit/storage/test_progression_repo.py +++ b/tests/unit/storage/test_progression_repo.py @@ -11,6 +11,7 @@ from embry0.services.progression_vocabulary import METRICS from embry0.storage.repositories.job_delivery import DeliveryWrite, JobDeliveryRepository from embry0.storage.repositories.progression import ProgressionRepository +from tests.fixtures.builtin_rows import planned_credit pytestmark = [pytest.mark.requires_postgres, pytest.mark.asyncio] @@ -25,11 +26,12 @@ async def _job( error: str | None = None, report: str | None = None, ) -> None: + credit = planned_credit(template) # what plan_route records for a job of the template (#167) await db.execute( """ INSERT INTO jobs (job_id, repo, task, status, pipeline_template, dispatched_by, error_code, result_summary, - finished_at, budget_overrun_usd) - VALUES ($1, 'o/r', 't', $2, $3, $4, $5, $6, NOW(), 0) + finished_at, budget_overrun_usd, coding_agent, leader_agent) + VALUES ($1, 'o/r', 't', $2, $3, $4, $5, $6, NOW(), 0, $7, $8) """, job_id, status, @@ -37,6 +39,8 @@ async def _job( by, error, report, + credit["coding_agent"], + credit["leader_agent"], ) @@ -148,12 +152,13 @@ async def job( await db.execute( """ INSERT INTO jobs (job_id, repo, task, status, issue_id, total_cost_usd, pr_url, delivery_state, - pipeline_template, created_at, started_at, finished_at, delivery_state_at) + pipeline_template, created_at, started_at, finished_at, delivery_state_at, + leader_agent) VALUES ($1, 'o/r', 't', $2, $3, $4, $5, $6, $7, TIMESTAMPTZ '2026-10-06 12:00Z' + make_interval(mins => $8), TIMESTAMPTZ '2026-10-06 12:00Z' + make_interval(mins => $8), TIMESTAMPTZ '2026-10-06 12:00Z' + make_interval(mins => $8 + $9), - TIMESTAMPTZ '2026-10-06 12:00Z' + make_interval(mins => $8 + $9 + 1)) + TIMESTAMPTZ '2026-10-06 12:00Z' + make_interval(mins => $8 + $9 + 1), $10) """, job_id, status, @@ -164,6 +169,7 @@ async def job( template, minutes, ran_minutes, + planned_credit(template)["leader_agent"], # as plan_route records it (#167) ) await db.execute("INSERT INTO issues (id, title, repo) VALUES ('iss-1', 'Fix the login', 'o/r')") diff --git a/tests/unit/storage/test_proposals_repo.py b/tests/unit/storage/test_proposals_repo.py index cd80315b..17d8863a 100644 --- a/tests/unit/storage/test_proposals_repo.py +++ b/tests/unit/storage/test_proposals_repo.py @@ -46,6 +46,10 @@ async def test_the_issues_are_open_with_no_job(db_with_migrations) -> None: assert [i["id"] for i in await ProposalsRepository(db).issues()] == ["iss-new"] +# Each context-audit job below is led by the context auditor, as one records when it is created and planned +# (jobs.leader_agent, #167): what makes a job an audit to the findings feed and to progression alike. + + async def test_each_repo_s_newest_completed_audit(db_with_migrations) -> None: db = db_with_migrations for job_id, repo, status, days_ago in ( @@ -56,8 +60,8 @@ async def test_each_repo_s_newest_completed_audit(db_with_migrations) -> None: ): await db.execute( """ - INSERT INTO jobs (job_id, repo, task, status, pipeline_template, result_summary, finished_at) - VALUES ($1, $2, 't', $3, 'context-audit', 'Stale: 0', NOW() - make_interval(days => $4)) + INSERT INTO jobs (job_id, repo, task, status, pipeline_template, leader_agent, result_summary, finished_at) + VALUES ($1, $2, 't', $3, 'context-audit', 'context-auditor', 'Stale: 0', NOW() - make_interval(days => $4)) """, job_id, repo, @@ -79,9 +83,9 @@ async def _audits(db, *rows: tuple[str, str, str, int | None]) -> dict[str, str] for job_id, repo, status, days_ago in rows: await db.execute( """ - INSERT INTO jobs (job_id, repo, task, status, pipeline_template, result_summary, created_at, finished_at, - delivery_state, delivery_state_at, updated_at) - VALUES ($1, $2, 't', $3, 'context-audit', 'Stale: 1', + INSERT INTO jobs (job_id, repo, task, status, pipeline_template, leader_agent, result_summary, created_at, + finished_at, delivery_state, delivery_state_at, updated_at) + VALUES ($1, $2, 't', $3, 'context-audit', 'context-auditor', 'Stale: 1', NOW() - make_interval(days => COALESCE($4, 0)::int, hours => 1), NOW() - make_interval(days => $4::int), CASE $3 WHEN 'pr_merged' THEN 'merged' WHEN 'pr_closed' THEN 'closed' ELSE 'none' END, @@ -165,9 +169,9 @@ async def _offered_of(db, *runs: _Run) -> str | None: delivery = {"pr_merged": "merged", "pr_closed": "closed"}.get(run.status, "none") await db.execute( """ - INSERT INTO jobs (job_id, repo, task, status, pipeline_template, dispatched_by, result_summary, - created_at, finished_at, delivery_state, delivery_state_at, updated_at) - VALUES ($1, $2, 't', $3, 'context-audit', $4, 'Stale: 1', + INSERT INTO jobs (job_id, repo, task, status, pipeline_template, leader_agent, dispatched_by, + result_summary, created_at, finished_at, delivery_state, delivery_state_at, updated_at) + VALUES ($1, $2, 't', $3, 'context-audit', 'context-auditor', $4, 'Stale: 1', NOW() - make_interval(hours => $5), NOW() - make_interval(hours => $6), $7, CASE WHEN $9 THEN NOW() - make_interval(hours => $8::int) END, NOW() - make_interval(hours => COALESCE($8::int, $6))) @@ -297,9 +301,9 @@ async def _offered_with_a_fix_job(db, fix_status: str) -> tuple[str, list[tuple[ ): await db.execute( """ - INSERT INTO jobs (job_id, repo, task, status, pipeline_template, dispatched_by, result_summary, - created_at, finished_at) - VALUES ($1, 'o/r', 't', $2, 'context-audit', $3, $4, + INSERT INTO jobs (job_id, repo, task, status, pipeline_template, leader_agent, dispatched_by, + result_summary, created_at, finished_at) + VALUES ($1, 'o/r', 't', $2, 'context-audit', 'context-auditor', $3, $4, NOW() - make_interval(days => $5, hours => 1), NOW() - make_interval(days => $5)) """, job_id, diff --git a/tests/unit/workflows/issue_to_pr/test_template_rows_route.py b/tests/unit/workflows/issue_to_pr/test_template_rows_route.py new file mode 100644 index 00000000..355bac55 --- /dev/null +++ b/tests/unit/workflows/issue_to_pr/test_template_rows_route.py @@ -0,0 +1,300 @@ +"""A template of the deployment's own routes the way its row says (#167), as a built-in specialist's did by name. + +Before, only the ten built-in specialist templates skipped triage, were left +out of triage's list and were recorded as analysis: an operator's own was +triaged like any other, so its task reached the classifier with the +repository's tools. +""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from langgraph.types import Command + +from embry0.workflows.issue_to_pr import plan_route +from embry0.workflows.issue_to_pr.nodes import triage_node + +#: A route of the deployment's own: triage, then one agent of its own, then the output step. +_GRAPH = { + "nodes": [ + {"node_id": "triage", "agent_type": "triage"}, + {"node_id": "scout", "agent_type": "scout"}, + {"node_id": "output", "agent_type": "output"}, + ], + "edges": [ + {"edge_id": "e1", "source": "triage", "target": "scout", "edge_type": "flow"}, + {"edge_id": "e2", "source": "scout", "target": "output", "edge_type": "flow"}, + ], +} +_CODE = { + "nodes": [{"node_id": "triage", "agent_type": "triage"}, {"node_id": "dev", "agent_type": "developer"}], + "edges": [{"edge_id": "e1", "source": "triage", "target": "dev", "edge_type": "flow"}], +} + + +def _row(name: str, graph: dict[str, Any], **behaviour: Any) -> dict[str, Any]: + return {"id": name, "name": name, "description": f"the {name} route", "graph_definition": graph, **behaviour} + + +class _Templates: + """The pipeline templates repository: summaries from list_all, the whole row from get.""" + + def __init__(self, *rows: dict[str, Any]) -> None: + self._rows = {row["id"]: row for row in rows} + + async def list_all(self) -> list[dict[str, Any]]: + return [{k: v for k, v in row.items() if k != "graph_definition"} for row in self._rows.values()] + + async def get(self, template_id: str) -> dict[str, Any] | None: + return self._rows.get(template_id) + + +def _state(**extra: Any) -> dict[str, Any]: + return { + "job_id": "job-1", + "repo": "acme/widgets", + "task": "Scout the widgets service for dead flags.", + "sandbox_container_id": "container-1", + "agent_outputs": [], + "errors": [], + "total_cost_usd": 0.0, + **extra, + } + + +def _triage_answers(decision: dict[str, Any]) -> AsyncMock: + return AsyncMock( + return_value={ + "agent_outputs": [{"agent_type": "triage", "is_error": False, "output": json.dumps(decision)}], + "total_cost_usd": 0.0, + "current_stage": "triage_complete", + } + ) + + +async def _triage(state: dict[str, Any], templates: _Templates, run: AsyncMock) -> Any: + config = { + "configurable": { + "agent_runner": MagicMock(), + "credentials": {}, + "repo_preferences_repo": None, + "pipeline_templates_repo": templates, + } + } + with ( + patch("embry0.workflows.issue_to_pr.nodes.run_agent_node", new=run), + patch("embry0.workflows.issue_to_pr.nodes.get_stream_writer", return_value=lambda _e: None), + ): + return await triage_node(state, config) + + +@pytest.mark.asyncio +async def test_a_template_of_the_deployments_own_marked_fixed_route_skips_triage() -> None: + templates = _Templates(_row("scout-pass", _GRAPH, fixed_route=True, offered_to_triage=False, job_kind="analysis")) + run = _triage_answers({"action": "proceed", "confidence": 0.9, "job_kind": "code", "reasoning": "x"}) + + update = await _triage(_state(pipeline_template_request="scout-pass"), templates, run) + + assert run.await_count == 0, "a job naming a template whose row fixes its route ran the triage agent" + assert isinstance(update, Command) and update.goto == "plan_route", f"triage did not hand over: {update!r}" + decision = update.update["triage_decision"] + assert (decision["pipeline_template"], decision["job_kind"]) == ("scout-pass", "analysis"), decision + + +@pytest.mark.asyncio +async def test_a_fixed_route_that_says_no_kind_leaves_triages_default_kind() -> None: + templates = _Templates(_row("quick-scout", _CODE, fixed_route=True)) + run = _triage_answers({"action": "proceed", "confidence": 0.9, "job_kind": "ops", "reasoning": "x"}) + update = await _triage(_state(pipeline_template_request="quick-scout"), templates, run) + assert run.await_count == 0, "a fixed route ran the triage agent" + assert isinstance(update, Command), f"triage did not hand over: {update!r}" + assert update.update["triage_decision"]["job_kind"] == "code" + + +@pytest.mark.asyncio +async def test_a_template_whose_row_does_not_fix_its_route_is_triaged() -> None: + """A built-in specialist template an operator unmarked is triaged like any other: the row decides, not the + name.""" + from embry0.storage.seeds.pipeline_templates_builtin import BUILTIN_PIPELINE_TEMPLATES + + why = BUILTIN_PIPELINE_TEMPLATES["why"] + templates = _Templates( + _row("why", why["graph_definition"], fixed_route=False, offered_to_triage=False, job_kind="analysis") + ) + run = _triage_answers({"action": "proceed", "confidence": 0.9, "job_kind": "analysis", "reasoning": "x"}) + + await _triage(_state(pipeline_template_request="why"), templates, run) + + assert run.await_count == 1, "the row says the route is not fixed, so triage decides" + + +@pytest.mark.asyncio +async def test_triage_is_not_offered_a_template_whose_row_says_so() -> None: + templates = _Templates( + _row("scout-pass", _GRAPH, offered_to_triage=False), + _row("scout-open", _GRAPH), + _row("quick-fix", _CODE), + ) + run = _triage_answers({"action": "proceed", "confidence": 0.9, "job_kind": "code", "reasoning": "x"}) + + await _triage(_state(), templates, run) + + prompt = run.await_args_list[0].kwargs["prompt"] + listed = prompt.split("Available pipeline templates:\n", 1)[1].split("\n\n", 1)[0] + names = {line[2:].split()[0].rstrip(":") for line in listed.splitlines()} + assert names == {"scout-open", "quick-fix"}, f"triage was offered {sorted(names)}" + + +@pytest.fixture +def _no_stream_writer(): + with patch("embry0.workflows.issue_to_pr.plan_route.get_stream_writer", return_value=lambda _e: None): + yield + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("_no_stream_writer") +async def test_triages_pick_of_a_template_it_is_not_offered_is_dropped() -> None: + templates = _Templates( + _row("scout-pass", _GRAPH, offered_to_triage=False, job_kind="analysis"), + _row("quick-fix", _CODE, default_for_kind="code"), + ) + config = {"configurable": {"pipeline_templates_repo": templates, "jobs_repo": None, "db": None}} + state = {"job_id": "job-1", "triage_decision": {"job_kind": "code", "pipeline_template": "scout-pass"}} + + # The scout runs in jobs, so the template is valid: only its row keeps triage's pick from running. + with patch.object(plan_route, "_known_agent_types", AsyncMock(return_value={"scout"})): + out = await plan_route.plan_route_node(state, config) + assert out["route_template_name"] == "quick-fix", "triage's pick of a template it is not offered ran" + + templates._rows["scout-pass"]["offered_to_triage"] = True + offered = await plan_route.plan_route_node(state, config) + assert offered["route_template_name"] == "scout-pass", "triage's pick of a template it is offered runs" + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("_no_stream_writer") +async def test_a_named_template_records_the_kind_its_row_says() -> None: + templates = _Templates( + _row("scout-pass", _GRAPH, fixed_route=True, offered_to_triage=False, job_kind="research"), + _row("quick-fix", _CODE, default_for_kind="code"), + ) + config = {"configurable": {"pipeline_templates_repo": templates, "jobs_repo": None, "db": None}} + state = { + "job_id": "job-1", + "triage_decision": {"job_kind": "code", "pipeline_template": "scout-pass"}, + "pipeline_template_request": "scout-pass", + } + + with patch.object(plan_route, "_known_agent_types", AsyncMock(return_value={"scout"})): + out = await plan_route.plan_route_node(state, config) + + assert out["route_template_name"] == "scout-pass" + assert out["job_kind"] == "research", "the row's job_kind, not triage's code" + + +_SPECIALISTS = { + "nodes": [ + {"node_id": "triage", "agent_type": "triage"}, + {"node_id": "why", "agent_type": "why"}, + {"node_id": "how", "agent_type": "how"}, + {"node_id": "output", "agent_type": "output"}, + ], + "edges": [ + {"edge_id": "e1", "source": "triage", "target": "why", "edge_type": "flow"}, + {"edge_id": "e2", "source": "why", "target": "how", "edge_type": "flow"}, + {"edge_id": "e3", "source": "how", "target": "output", "edge_type": "flow"}, + ], +} +_WHY_THEN_CODE = { + "nodes": [ + {"node_id": "triage", "agent_type": "triage"}, + {"node_id": "why", "agent_type": "why"}, + {"node_id": "dev", "agent_type": "developer"}, + ], + "edges": [ + {"edge_id": "e1", "source": "triage", "target": "why", "edge_type": "flow"}, + {"edge_id": "e2", "source": "why", "target": "dev", "edge_type": "flow"}, + ], +} + + +async def _planned(templates: _Templates, name: str) -> dict[str, Any]: + jobs = AsyncMock() + config = {"configurable": {"pipeline_templates_repo": templates, "jobs_repo": jobs, "db": None}} + state = { + "job_id": "job-1", + "task": "t", + "triage_decision": {"job_kind": "code", "pipeline_template": name}, + "pipeline_template_request": name, + } + out = await plan_route.plan_route_node(state, config) + return {**out, "recorded": jobs.update.await_args.kwargs} + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("_no_stream_writer") +async def test_a_new_template_of_specialists_only_records_its_jobs_as_analysis() -> None: + """Probe C: a template written after the upgrade, every step a specialist and no job_kind, is analysis, + as the route made it before #167, however it is named.""" + out = await _planned(_Templates(_row("explain-twice", _SPECIALISTS)), "explain-twice") + assert out["job_kind"] == "analysis", "a route of specialists only is analysis" + assert out["recorded"]["job_kind"] == "analysis" + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("_no_stream_writer") +async def test_a_specialist_route_given_a_coding_step_is_no_longer_analysis() -> None: + """Probe D: the built-in why template edited to add a developer step records triage's kind: the kind is + read from the route as it is now, and only an operator's job_kind overrides it.""" + from embry0.workflows.row_behaviour import builtin_template + + # The built-in row as the seed wrote it, its graph edited since. + edited = {**(builtin_template("why") or {}), "id": "why", "graph_definition": _WHY_THEN_CODE} + out = await _planned(_Templates(edited), "why") + assert out["job_kind"] == "code", "an edited route keeps no stale analysis kind" + overridden = await _planned(_Templates({**edited, "job_kind": "research"}), "why") + assert overridden["job_kind"] == "research", "the operator's job_kind overrides what the route makes it" + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("_no_stream_writer") +async def test_plan_route_records_who_codes_the_job_and_who_leads_its_template() -> None: + slop_route = { + "nodes": [ + {"node_id": "triage", "agent_type": "triage"}, + {"node_id": "slop", "agent_type": "slopmaster"}, + ], + "edges": [{"edge_id": "e1", "source": "triage", "target": "slop", "edge_type": "flow"}], + } + out = await _planned(_Templates(_row("tidy", slop_route, leader_agent="slopmaster")), "tidy") + assert (out["recorded"]["coding_agent"], out["recorded"]["leader_agent"]) == ("slopmaster", "slopmaster") + fix = await _planned(_Templates(_row("quick-fix", _CODE)), "quick-fix") + assert (fix["recorded"]["coding_agent"], fix["recorded"]["leader_agent"]) == ("developer", None) + + +class _Unreadable(_Templates): + async def list_all(self) -> list[dict[str, Any]]: + raise ConnectionError("the database is gone") + + +@pytest.mark.asyncio +async def test_a_failed_read_fixes_no_route_and_triage_runs() -> None: + """When the templates cannot be read nothing is known: the seed's word that why is fixed is not taken, + since the operator may have changed it (#167).""" + run = _triage_answers({"action": "proceed", "confidence": 0.9, "job_kind": "analysis", "reasoning": "x"}) + await _triage(_state(pipeline_template_request="why"), _Unreadable(), run) + assert run.await_count == 1, "a failed read fixed the route from the seed" + prompt = run.await_args_list[0].kwargs["prompt"] + assert "Available pipeline templates" not in prompt, "a template list that could not be read offers none" + + +@pytest.mark.asyncio +async def test_triage_may_not_pick_a_template_whose_row_cannot_be_read() -> None: + assert await plan_route._offered_to_triage(_Unreadable(), "scout-pass") is False + assert await plan_route._offered_to_triage(None, "why") is False, "without a database the seed says it" + assert await plan_route._offered_to_triage(None, "quick-fix") is True + assert await plan_route._offered_to_triage(_Templates(), "unknown") is True, "a name with no row is not found later" diff --git a/tests/unit/workflows/issue_to_pr/test_triage_fixed_route.py b/tests/unit/workflows/issue_to_pr/test_triage_fixed_route.py index f70b915b..9d0930b7 100644 --- a/tests/unit/workflows/issue_to_pr/test_triage_fixed_route.py +++ b/tests/unit/workflows/issue_to_pr/test_triage_fixed_route.py @@ -14,7 +14,13 @@ import pytest from langgraph.types import Command -from embry0.workflows.issue_to_pr.nodes._triage import fixed_route_decision +from embry0.workflows.issue_to_pr.nodes._triage import fixed_route_decision as _fixed_route_decision +from embry0.workflows.row_behaviour import builtin_template + + +def fixed_route_decision(state: dict[str, Any]) -> dict[str, Any] | None: + """The decision for ``state`` under the built-in row of the template it names, as seeded (#167).""" + return _fixed_route_decision(state, builtin_template(state.get("pipeline_template_request"))) def _state(**extra: Any) -> dict[str, Any]: diff --git a/tests/unit/workflows/test_developer_salvage.py b/tests/unit/workflows/test_developer_salvage.py index e3458a9c..76ec8a25 100644 --- a/tests/unit/workflows/test_developer_salvage.py +++ b/tests/unit/workflows/test_developer_salvage.py @@ -310,10 +310,10 @@ def _reasons() -> list[str]: def test_the_default_subject_is_the_old_one_for_every_reason(): - from embry0.workflows.issue_to_pr.nodes import _salvage + from embry0.workflows.row_behaviour import CODING_AGENT_TYPES template = _default_subject() - for agent in sorted(_salvage.CODING_AGENTS): + for agent in sorted(CODING_AGENT_TYPES): for reason in _reasons(): old = f"wip: unfinished — {agent} stopped ({reason})" assert _subject(agent, reason, template) == old, f"the default must render the code's old subject: {old!r}" diff --git a/tests/unit/workflows/test_no_builtin_name_lists.py b/tests/unit/workflows/test_no_builtin_name_lists.py new file mode 100644 index 00000000..772c0d5c --- /dev/null +++ b/tests/unit/workflows/test_no_builtin_name_lists.py @@ -0,0 +1,56 @@ +"""The lists of built-in names that decided behaviour are gone (#167): the rows decide. + +Each name below held behaviour an operator's own agent or template could +not take part in. A test that imported one would keep it alive, so this +checks they are not there to import. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[3] + + +@pytest.mark.parametrize( + ("module", "name"), + [ + ("embry0.workflows.issue_to_pr.specialist_agents", "SPECIALIST_TEMPLATE_NAMES"), + ("embry0.workflows.issue_to_pr.route_plan", "specialist_job_kind"), + ("embry0.workflows.issue_to_pr.nodes._salvage", "CODING_AGENTS"), + ("embry0.services.job_briefs", "_TEMPLATE_DELIVERS"), + ("embry0.services.world", "LEADER_BY_TEMPLATE"), + ("embry0.services.world", "QUEST_TEMPLATE_BY_AGENT"), + ("embry0.storage.repositories.progression", "_CODER"), + ("embry0.storage.repositories.progression", "AUDIT_TEMPLATE"), + ], +) +def test_the_name_list_is_gone(module: str, name: str) -> None: + import importlib + + assert not hasattr(importlib.import_module(module), name), f"{module}.{name} still decides by built-in name" + + +def test_the_coding_agents_are_listed_once() -> None: + from embry0.workflows import row_behaviour + from embry0.workflows.issue_to_pr import route_plan + + assert route_plan.CODING_AGENT_TYPES is row_behaviour.CODING_AGENT_TYPES + + +def test_the_progression_and_world_queries_credit_from_the_rows() -> None: + from embry0.storage.repositories import world + from embry0.storage.repositories.progression import events_sql + + for sql in (events_sql(), world._CLEANED_SQL): + for literal in ("'slop-cleanup'", "'context-audit'"): + assert literal not in sql, f"a query still names the template {literal}: the job's recorded credit says it" + assert "coding_agent" in sql, "the query credits the job from what it recorded when it was planned" + assert "pipeline_templates" not in sql, "a past job's credit must not move with today's template rows" + + +def test_the_guild_hall_draws_each_station_from_the_row() -> None: + hall = (REPO / "frontend" / "src" / "lib" / "worldHall.ts").read_text() + assert "STATION_OF" not in hall, "the hall still maps agent names to stations" diff --git a/tests/unit/workflows/test_row_behaviour.py b/tests/unit/workflows/test_row_behaviour.py new file mode 100644 index 00000000..bbce465b --- /dev/null +++ b/tests/unit/workflows/test_row_behaviour.py @@ -0,0 +1,205 @@ +"""What a template's or an agent's row says about its behaviour (#167), on its own. + +The second half proves that every built-in row, as seeded, answers exactly +what the code said by its name before (tests/fixtures/name_lists_before_rows.py). +""" + +from __future__ import annotations + +import pytest + +from embry0.orchestration.state import JobKind +from embry0.services import job_briefs +from embry0.storage.repositories.agent_definitions import BUILTIN_SEED +from embry0.storage.seeds.pipeline_templates_builtin import BUILTIN_PIPELINE_TEMPLATES +from embry0.workflows import row_behaviour as rb +from embry0.workflows._validation import FIXED_NODE_TYPES +from embry0.workflows.issue_to_pr.route_plan import build_route_plan, coding_agent_of +from tests.fixtures import name_lists_before_rows as before + +# --- the helper on its own ---------------------------------------------------------------------------------- + + +def test_a_row_without_the_columns_reads_as_a_template_of_the_deployments_own() -> None: + for row in (None, {}, {"name": "mine", "fixed_route": None, "offered_to_triage": None}): + assert rb.fixed_route(row) is False, f"{row!r} is no fixed route" + assert rb.offered_to_triage(row) is True, f"{row!r} is offered to triage" + assert rb.recorded_kind(row) is None, f"{row!r} records no kind" + assert rb.delivers(row) == "", f"{row!r} says nothing it delivers" + assert rb.leader(row) is None, f"{row!r} has no leader" + + +def _graph(*agents: str) -> dict: + return {"nodes": [{"node_id": a, "agent_type": a} for a in ("triage", *agents)]} + + +def test_the_kind_a_template_records_is_its_default_kind_then_its_override_then_its_route() -> None: + assert rb.recorded_kind({"default_for_kind": "ops", "job_kind": "analysis"}) == "ops" + assert rb.recorded_kind({"job_kind": "research", "graph_definition": _graph("why")}) == "research", ( + "job_kind overrides what the route makes it" + ) + assert rb.recorded_kind({"default_for_kind": "", "job_kind": ""}) is None + + +def test_a_route_of_specialists_only_is_analysis_whatever_it_is_named() -> None: + """The column is an override; NULL reads the graph as it is now, as specialist_job_kind did (#167).""" + assert rb.recorded_kind({"name": "my-pass", "graph_definition": _graph("why", "how", "output")}) == "analysis" + assert rb.recorded_kind({"name": "why", "graph_definition": _graph("why", "developer")}) is None, ( + "a specialist route given a coding step since is no analysis" + ) + assert rb.derived_kind(_graph("output")) is None and rb.derived_kind(None) is None + assert rb.derived_kind(_graph("analysis")) is None, "the analysis agent is no specialist" + + +def test_the_coding_agents_are_the_developer_nodes_fixed_types() -> None: + assert set(rb.CODING_AGENT_TYPES) <= FIXED_NODE_TYPES, "a coding agent is a node of the graph's vocabulary" + assert rb.DEFAULT_CODER in rb.CODING_AGENT_TYPES + + +def test_the_job_kinds_are_the_job_kind_vocabulary() -> None: + assert set(rb.JOB_KINDS) == {k.value for k in JobKind} + + +def test_a_station_is_one_the_hall_draws() -> None: + assert rb.station({"station": "anvil"}) == "anvil" + assert rb.station({"station": "throne"}) == "desk", "a station the art lacks is drawn as a desk" + assert rb.station(None) == rb.station({}) == "desk" + + +def test_a_quest_template_is_none_triage_or_a_template() -> None: + assert rb.quest({"quest_template": None}) == rb.Quest(False, None), "NULL: the guild gives it no task" + assert rb.quest({}) == rb.Quest(False, None) + assert rb.quest({"quest_template": ""}) == rb.Quest(True, None), "'': a task that names no template" + assert rb.quest({"quest_template": "why"}) == rb.Quest(True, "why") + assert rb.quest({"quest_template": "why"}, templates={"why"}) == rb.Quest(True, "why") + assert rb.quest({"quest_template": "gone"}, templates={"why"}) == rb.Quest(False, None), ( + "a task naming a template that no longer exists would run the kind default: it is no task" + ) + assert rb.quest({"quest_template": ""}, templates=set()) == rb.Quest(True, None) + + +@pytest.mark.parametrize( + ("check", "value", "field"), + [ + (rb.checked_job_kind, "chores", "job_kind"), + (rb.checked_job_kind, 3, "job_kind"), + (rb.checked_delivers, "a\nb", "delivers"), + (rb.checked_delivers, "x" * (rb.DELIVERS_MAX + 1), "delivers"), + (rb.checked_delivers, None, "delivers"), + (rb.checked_leader_agent, "", "leader_agent"), + (rb.checked_leader_agent, 7, "leader_agent"), + (rb.checked_quest_template, "x" * (rb.NAME_MAX + 1), "quest_template"), + (rb.checked_quest_template, ["why"], "quest_template"), + (rb.checked_station, "throne", "station"), + (rb.checked_station, None, "station"), + ], +) +def test_a_malformed_value_is_refused_naming_its_field(check, value, field) -> None: + with pytest.raises(ValueError, match=field): + check(value) + + +def test_a_well_formed_value_is_stored_trimmed() -> None: + assert rb.checked_job_kind(None) is None and rb.checked_job_kind("research") == "research" + assert rb.checked_delivers(" a report ") == "a report" + assert rb.checked_leader_agent(None) is None and rb.checked_leader_agent(" why ") == "why" + assert rb.checked_quest_template("") == "" and rb.checked_quest_template(None) is None + assert rb.checked_station("map_table") == "map_table" + + +def test_a_task_naming_no_template_is_a_coding_agents_only() -> None: + assert rb.untemplated_task_problem("developer", "") is None + assert rb.untemplated_task_problem("slopmaster", "") is None + assert rb.untemplated_task_problem("scout", "why") is None and rb.untemplated_task_problem("scout", None) is None + problem = rb.untemplated_task_problem("scout", "") + assert problem is not None and problem.startswith("quest_template: ") and "'scout'" in problem, problem + + +def test_a_template_cannot_take_a_code_pipelines_name() -> None: + for name in rb.WORKFLOW_PIPELINES: + problem = rb.template_name_problem(name) + assert problem is not None and problem.startswith("name: "), (name, problem) + assert rb.template_name_problem(" qa ") is not None + assert rb.template_name_problem("qa-pass") is None and rb.template_name_problem(None) is None + + +def test_what_names_nothing_is_said() -> None: + assert rb.leader_problem("ghost", {"why"}) == "leader_agent: no agent named 'ghost' runs in jobs" + assert rb.leader_problem("why", {"why"}) is None and rb.leader_problem("ghost", None) is None + assert rb.quest_template_problem("ghost", {"why"}) == "quest_template: no pipeline template named 'ghost'" + assert rb.quest_template_problem("", {"why"}) is None, "'' names no template on purpose" + assert rb.quest_template_problem("ghost", None) is None, "without the templates nothing is checked" + + +def test_the_pipelines_a_job_can_name_are_the_rows_and_the_codes_own() -> None: + rows = rb.pipelines_by_name([{"name": "mine", "leader_agent": "why"}]) + assert set(rows) == {"mine", "qa", "pr-review", "onboard"} + assert rb.leader(rows["mine"]) == "why" and rb.leader(rows["qa"]) == "qa" + assert rb.pipelines_by_name([{"name": "qa", "leader_agent": "x"}])["qa"]["leader_agent"] == "x", ( + "a row would win over the code's pipeline, which is why no template may take its name" + ) + + +def test_the_codes_pipelines_are_the_workflows_with_no_template_row() -> None: + from embry0.workflows.onboard.graph import OnboardWorkflow + from embry0.workflows.pr_review.graph import PrReviewWorkflow + from embry0.workflows.qa.graph import QAWorkflow + + assert set(rb.WORKFLOW_PIPELINES) == {QAWorkflow.name, PrReviewWorkflow.name, OnboardWorkflow.name} + assert not set(rb.WORKFLOW_PIPELINES) & set(BUILTIN_PIPELINE_TEMPLATES), "no built-in template shares a name" + + +# --- every built-in row answers what the code said by its name -------------------------------------------- + + +@pytest.mark.parametrize("name", sorted(BUILTIN_PIPELINE_TEMPLATES)) +def test_each_builtin_template_says_what_its_name_said(name: str) -> None: + seed = BUILTIN_PIPELINE_TEMPLATES[name] + row = rb.builtin_template(name) + assert row is not None and row["name"] == name + old_kind = before.recorded_kind(name, seed["graph_definition"], seed.get("default_for_kind")) + assert rb.fixed_route(row) is (name in before.SPECIALIST_TEMPLATE_NAMES), f"{name}: fixed_route" + assert rb.offered_to_triage(row) is (name not in before.SPECIALIST_TEMPLATE_NAMES), f"{name}: offered_to_triage" + assert rb.recorded_kind(row) == old_kind, f"{name}: the kind its jobs are recorded as" + assert rb.leader(row) == before.LEADER_BY_TEMPLATE.get(name), f"{name}: its leader" + plan = build_route_plan(seed["graph_definition"]) + assert coding_agent_of(plan) == before.coder(name), f"{name}: whom plan_route records its pull request is to" + for kind in (*rb.JOB_KINDS, ""): + job = {"pipeline_template": name, "job_kind": kind} + assert job_briefs.what_it_delivers(job) == before.delivers(name, kind), f"{name}/{kind}: what it delivers" + read = {**job, "template_delivers": rb.delivers(row)} + assert job_briefs.what_it_delivers(read) == before.delivers(name, kind), f"{name}/{kind}, read from the row" + + +@pytest.mark.parametrize("name", sorted(rb.WORKFLOW_PIPELINES)) +def test_each_pipeline_the_code_defines_says_what_its_name_said(name: str) -> None: + row = rb.builtin_template(name) + assert rb.leader(row) == before.LEADER_BY_TEMPLATE[name], f"{name}: its leader" + assert before.coder(name) == rb.DEFAULT_CODER, "it is never planned: it records no coder, the developer" + for kind in (*rb.JOB_KINDS, ""): + job = {"pipeline_template": name, "job_kind": kind} + assert job_briefs.what_it_delivers(job) == before.delivers(name, kind), f"{name}/{kind}" + assert job_briefs.what_it_delivers({**job, "template_delivers": None}) == before.delivers(name, kind), ( + f"{name}/{kind}: a pipeline with no row, read from the database" + ) + + +def test_a_template_of_the_deployments_own_says_what_no_name_said() -> None: + assert coding_agent_of([]) == before.coder("mine") == before.coder(None) + for kind in (*rb.JOB_KINDS, ""): + job = {"pipeline_template": "mine", "job_kind": kind, "template_delivers": ""} + assert job_briefs.what_it_delivers(job) == before.delivers("mine", kind), kind + + +@pytest.mark.parametrize("agent_type", sorted(BUILTIN_SEED)) +def test_each_builtin_agent_says_what_its_name_said(agent_type: str) -> None: + row = rb.builtin_agent(agent_type) + assert rb.station(row) == before.STATION_OF.get(agent_type, "desk"), f"{agent_type}: its station" + task = rb.quest(row, templates=set(BUILTIN_PIPELINE_TEMPLATES)) + assert task.offered is (agent_type in before.QUEST_TEMPLATE_BY_AGENT), f"{agent_type}: can the guild send it" + assert task.template == before.QUEST_TEMPLATE_BY_AGENT.get(agent_type), f"{agent_type}: what its task runs" + + +def test_the_coding_agents_are_the_ones_both_lists_named() -> None: + assert frozenset(rb.CODING_AGENT_TYPES) == before.CODING_AGENTS + assert rb.builtin_agent("my-own-agent") is None and rb.builtin_template("my-own-template") is None diff --git a/tests/unit/workflows/test_specialist_agents.py b/tests/unit/workflows/test_specialist_agents.py index b3728006..74340a51 100644 --- a/tests/unit/workflows/test_specialist_agents.py +++ b/tests/unit/workflows/test_specialist_agents.py @@ -27,14 +27,19 @@ GENERIC_AGENT_TYPES, build_route_plan, next_route, - specialist_job_kind, step_template_config, ) from embry0.workflows.issue_to_pr.specialist_agents import ( CONTEXT_AUDITOR_SYSTEM_PROMPT, REPORT_TARGET_CHARS, SPECIALIST_AGENT_TYPES, - SPECIALIST_TEMPLATE_NAMES, +) +from embry0.workflows.row_behaviour import ( + TEMPLATE_DEFAULTS, + fixed_route, + leader, + offered_to_triage, + recorded_kind, ) # Agent type -> the template that routes a job to it. Written out rather than @@ -276,17 +281,17 @@ def test_template_validates_and_routes_to_the_generic_node(agent_type, template) assert [step["agent_type"] for step in plan] == [agent_type, "output"] assert next_route({"route_plan": plan, "route_cursor": 0, "qa": {}}) == "agent" assert AGENT_TYPE_TO_NODE[agent_type] == "generic_agent" - assert specialist_job_kind(plan) == "analysis" + # What its row says (#167): an analysis job, led by its specialist. + assert recorded_kind(seeded) == "analysis" + assert leader(seeded) == agent_type -def test_the_specialist_templates_are_the_nine_all_specialist_routes(): - assert SPECIALIST_TEMPLATE_NAMES == set(SPECIALISTS.values()) - all_specialist = { - name - for name, seeded in BUILTIN_PIPELINE_TEMPLATES.items() - if specialist_job_kind(build_route_plan(seeded["graph_definition"])) - } - assert all_specialist == SPECIALIST_TEMPLATE_NAMES +def test_the_fixed_routes_triage_is_not_offered_are_the_specialist_templates(): + """The seeded rows say what SPECIALIST_TEMPLATE_NAMES said before #167: those ten, and no other.""" + fixed = {name for name, seeded in BUILTIN_PIPELINE_TEMPLATES.items() if fixed_route(seeded)} + hidden = {name for name, seeded in BUILTIN_PIPELINE_TEMPLATES.items() if not offered_to_triage(seeded)} + assert fixed == set(SPECIALISTS.values()), f"the fixed routes: {sorted(fixed)}" + assert hidden == set(SPECIALISTS.values()), f"the templates triage is not offered: {sorted(hidden)}" def test_every_agent_type_list_accepts_the_specialists(): @@ -305,14 +310,6 @@ def test_every_agent_type_list_accepts_the_specialists(): assert step_template_config(state, agent_type) == {"agent_models": {agent_type: "m-x"}} -def test_specialist_kind_needs_every_agent_to_be_a_specialist(): - assert specialist_job_kind([{"agent_type": "developer"}, {"agent_type": "kintsugi"}]) is None - assert specialist_job_kind([{"agent_type": "analysis"}, {"agent_type": "output"}]) is None - assert specialist_job_kind([{"agent_type": "output"}]) is None - assert specialist_job_kind([]) is None - assert specialist_job_kind([{"agent_type": "why"}, {"agent_type": "how"}, {"agent_type": "output"}]) == "analysis" - - # ---- running one ----------------------------------------------------------------- @@ -375,6 +372,8 @@ def __init__(self, names): "graph_definition": BUILTIN_PIPELINE_TEMPLATES[name]["graph_definition"], "default_for_kind": BUILTIN_PIPELINE_TEMPLATES[name].get("default_for_kind"), "description": "", + # What the row says of its jobs (#167), as seeded. + **{key: BUILTIN_PIPELINE_TEMPLATES[name][key] for key in TEMPLATE_DEFAULTS}, } for i, name in enumerate(names) } @@ -412,7 +411,14 @@ async def test_a_named_specialist_route_is_an_analysis_job_whatever_triage_guess assert out["route_template_name"] == template assert [step["agent_type"] for step in out["route_plan"]] == [agent_type, "output"] assert out["job_kind"] == "analysis", "a specialist route is recorded as analysis, not triage's 'code'" - jobs_repo.update.assert_awaited_once_with("job-1", job_kind="analysis", pipeline_template=template) + jobs_repo.update.assert_awaited_once_with( + "job-1", + job_kind="analysis", + pipeline_template=template, + # Who codes it (no coding step: the developer by default) and who leads it, recorded for its credit (#167). + coding_agent="developer", + leader_agent=agent_type, + ) @pytest.mark.asyncio @@ -467,7 +473,7 @@ async def test_triage_is_not_offered_the_specialist_templates(): prompt = run.await_args_list[0].kwargs["prompt"] listed_block = prompt.split("Available pipeline templates:\n", 1)[1].split("\n\n", 1)[0] listed = {line[2:].split()[0].rstrip(":") for line in listed_block.splitlines()} - assert listed == set(BUILTIN_PIPELINE_TEMPLATES) - SPECIALIST_TEMPLATE_NAMES + assert listed == set(BUILTIN_PIPELINE_TEMPLATES) - set(SPECIALISTS.values()) assert "quick-fix" in listed