From 9c01691c20806943019fc79dd5c4f3087f3ace38 Mon Sep 17 00:00:00 2001 From: Tomas McMonigal <74835766+sudoTomas@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:43:07 +0000 Subject: [PATCH 1/3] refactor(agents): behaviour from agent and template rows Operators write templates and agents as rows, but what embry0 did with one depended on the names of the built-in ones. An operator's own specialist template was triaged and offered to triage, its jobs' briefs said "the result of the work", and an agent of their own could never be given a task from the guild. The progression and world queries credited a job's pull request, cleanup and audit by template and agent names. Each of those is now a column of the row, read through one module, embry0/workflows/row_behaviour.py: - pipeline_templates.fixed_route (a job that names it skips triage), offered_to_triage, job_kind, delivers and leader_agent; - agent_definitions.station and quest_template (NULL no guild task, '' a task naming no template, else that template). SPECIALIST_TEMPLATE_NAMES, specialist_job_kind, _TEMPLATE_DELIVERS, LEADER_BY_TEMPLATE, QUEST_TEMPLATE_BY_AGENT, the frontend's STATION_OF and progression's _CODER are gone; the coding agents are listed once. A job's pull request is its template leader's when the leader codes (coder_sql and row_behaviour.coder, pinned to agree), else the developer's. The pipelines the code defines (qa, onboard, pr-review) keep their leader and deliverable in WORKFLOW_PIPELINES. Migration 143 adds the columns and writes every row what its name said, so an upgraded deployment behaves as before; the boot seed writes a built-in template's columns on its first insert only, like default_for_kind. The columns are set through the API (422 naming the field, including a leader or a task that names nothing), MCP agent_create and agent_update, the agent form's Guild section and a template's Behaviour dialog on the Pipelines page. The issue's agent roles column is left out: its one consumer would put an operator's agent on the developer node, a capability of its own. Refs: #167 --- docs/api.md | 25 +- docs/architecture.md | 1 + docs/configuration.md | 2 + docs/contracts/identity.json | 8 + docs/glossary.md | 2 +- docs/specialist-agents.md | 6 + .../2026-11-05-behaviour-from-rows-design.md | 110 +++++++ docs/world.md | 2 +- embry0/agents/identity.py | 14 +- embry0/api/schemas/__init__.py | 74 ++++- embry0/api/v1/agents.py | 35 ++- embry0/api/v1/pipeline_templates.py | 65 ++++- embry0/api/v1/world.py | 52 +++- embry0/mcp_server/client.py | 12 + embry0/mcp_server/server.py | 19 +- embry0/services/job_briefs.py | 33 ++- embry0/services/progression_rollup.py | 12 +- embry0/services/slop.py | 2 +- embry0/services/world.py | 102 +++---- embry0/storage/migrations/runner.py | 128 +++++++++ .../storage/repositories/agent_definitions.py | 66 ++++- embry0/storage/repositories/job_briefs.py | 5 +- .../repositories/pipeline_templates.py | 84 +++++- embry0/storage/repositories/progression.py | 70 +++-- embry0/storage/repositories/world.py | 18 +- embry0/storage/seeds/agents_builtin.py | 2 + .../seeds/pipeline_templates_builtin.py | 40 ++- .../workflows/issue_to_pr/nodes/_developer.py | 14 +- .../workflows/issue_to_pr/nodes/_salvage.py | 9 +- embry0/workflows/issue_to_pr/nodes/_triage.py | 47 +-- embry0/workflows/issue_to_pr/plan_route.py | 98 ++++--- embry0/workflows/issue_to_pr/route_plan.py | 28 +- .../issue_to_pr/specialist_agents.py | 25 +- embry0/workflows/pr_review/agent_seed.py | 3 + embry0/workflows/qa/agent_seed.py | 3 + embry0/workflows/row_behaviour.py | 269 ++++++++++++++++++ frontend/src/api/__tests__/pipelines.test.ts | 39 +++ frontend/src/api/pipelines.ts | 37 ++- frontend/src/api/world.ts | 5 +- .../components/pipelines/TemplateActions.tsx | 12 +- .../pipelines/TemplateBehaviourDialog.tsx | 136 +++++++++ .../__tests__/TemplateActions.test.tsx | 61 +++- frontend/src/components/world/HallScene.tsx | 9 +- .../world/__tests__/HallStations.test.tsx | 38 +++ .../world/__tests__/WriteQuestDialog.test.tsx | 40 +++ frontend/src/hooks/usePipelines.ts | 13 +- .../__tests__/agentIdentity.contract.test.ts | 15 +- frontend/src/lib/__tests__/worldHall.test.ts | 7 +- frontend/src/lib/agentIdentity.ts | 10 + frontend/src/lib/types/agents.ts | 11 + frontend/src/lib/types/pipelines.ts | 25 ++ frontend/src/lib/worldHall.ts | 18 +- frontend/src/pages/AgentFormPage.tsx | 99 ++++++- frontend/src/pages/PipelinesPage.tsx | 2 + .../pages/__tests__/AgentFormPage.test.tsx | 52 ++++ tests/fixtures/builtin_rows.py | 19 ++ tests/fixtures/name_lists_before_rows.py | 133 +++++++++ tests/fixtures/progression_history.py | 6 +- .../unit/api/v1/test_behaviour_writes_api.py | 222 +++++++++++++++ tests/unit/api/v1/test_world_rows.py | 144 ++++++++++ tests/unit/mcp/test_agent_guild_fields.py | 52 ++++ tests/unit/mcp/test_server.py | 4 + tests/unit/mcp/test_tool_surface.py | 5 + .../unit/services/test_fix_job_progression.py | 3 + tests/unit/services/test_world.py | 4 +- .../unit/storage/test_behaviour_rows_repo.py | 203 +++++++++++++ tests/unit/storage/test_migration_142.py | 6 +- tests/unit/storage/test_migration_143.py | 205 +++++++++++++ tests/unit/storage/test_progression_repo.py | 2 + .../storage/test_progression_rollup_repo.py | 3 + .../issue_to_pr/test_template_rows_route.py | 196 +++++++++++++ .../issue_to_pr/test_triage_fixed_route.py | 8 +- .../unit/workflows/test_developer_salvage.py | 4 +- .../workflows/test_no_builtin_name_lists.py | 54 ++++ tests/unit/workflows/test_row_behaviour.py | 176 ++++++++++++ .../unit/workflows/test_specialist_agents.py | 39 ++- 76 files changed, 3283 insertions(+), 319 deletions(-) create mode 100644 docs/superpowers/specs/2026-11-05-behaviour-from-rows-design.md create mode 100644 embry0/workflows/row_behaviour.py create mode 100644 frontend/src/components/pipelines/TemplateBehaviourDialog.tsx create mode 100644 frontend/src/components/world/__tests__/HallStations.test.tsx create mode 100644 frontend/src/components/world/__tests__/WriteQuestDialog.test.tsx create mode 100644 tests/fixtures/builtin_rows.py create mode 100644 tests/fixtures/name_lists_before_rows.py create mode 100644 tests/unit/api/v1/test_behaviour_writes_api.py create mode 100644 tests/unit/api/v1/test_world_rows.py create mode 100644 tests/unit/mcp/test_agent_guild_fields.py create mode 100644 tests/unit/storage/test_behaviour_rows_repo.py create mode 100644 tests/unit/storage/test_migration_143.py create mode 100644 tests/unit/workflows/issue_to_pr/test_template_rows_route.py create mode 100644 tests/unit/workflows/test_no_builtin_name_lists.py create mode 100644 tests/unit/workflows/test_row_behaviour.py diff --git a/docs/api.md b/docs/api.md index 3d5e8d46..af52ea3e 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, as for the developer), 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 '...'`). A task whose template is deleted later is no task: the guild stops offering it. `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,19 @@ 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` | 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` 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, and the one its outcomes are credited to: its pull request when the leader is a coding agent (`developer`, `slopmaster`; the developer otherwise), its audit for a context audit. Must name an agent that runs in jobs | + +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`. 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..4195f527 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`, `delivers` (NOT NULL, default `''`) and `leader_agent`; `agent_definitions.station` (NOT NULL, default `desk`) and `quest_template` (NULL none, `''` a task naming no template). Every row gets what the code said of its name before: the ten specialist templates fixed, not offered and analysis, the leaders and deliverables of the old maps, the old stations and guild tasks, and a template of the deployment's own whose every step is a specialist keeps being recorded as analysis | `pipeline_templates`, `agent_definitions` | 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..db07f1e6 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 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. 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..63916987 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`, `job_kind`; 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..c23a1581 100644 --- a/docs/specialist-agents.md +++ b/docs/specialist-agents.md @@ -150,6 +150,12 @@ 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, `job_kind` analysis, and +`leader_agent` its specialist; [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..479d514b --- /dev/null +++ b/docs/superpowers/specs/2026-11-05-behaviour-from-rows-design.md @@ -0,0 +1,110 @@ +# 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` | `specialist_job_kind` | 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`, the progression `_CODER` and audit credit, the world's cleanup count | world, progression | +| `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 | + +- **A job's coder** is its template's leader when the leader is one of the + developer node's agents (`CODING_AGENT_TYPES`: developer, slopmaster), else + the developer: `row_behaviour.coder` in Python, `progression.coder_sql` in + SQL, pinned to agree. A slop cleanup is a merged job the Slopmaster coded. +- **`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** (or when it cannot be read), the built-in rows as + seeded answer (`builtin_template`, `builtin_agent`), as the graph + validator already falls back to `BUILTIN_SEED`. + +### 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 and a `quest_template` that names no template are 422s too. On + `PUT`, null clears `job_kind`, `leader_agent` and `quest_template` and is a + 422 for the NOT NULL columns. +- 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; a +template of the deployment's own whose every step is a specialist keeps its +analysis kind. 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 rollup now reads `pipeline_templates`; a change to a +template's `leader_agent` reaches the folded days at the next nightly +rebuild, like any change to a row of a folded day. + +## Deviation from the issue: no `roles` column + +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. + +## 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..903fd38c 100644 --- a/embry0/api/schemas/__init__.py +++ b/embry0/api/schemas/__init__.py @@ -16,6 +16,13 @@ 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, +) # Slash-separated alnum + . _ - : segments. Colon allowed for ISO timestamps in # filenames. No leading/trailing slash. No '..' segments. No double slashes. @@ -435,12 +442,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 +508,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 +604,28 @@ 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) + + +class TemplateCreateRequest(_TemplateWriteRequest, _TemplateBehaviour): name: str = Field(..., min_length=1, max_length=100) description: str = "" graph_definition: dict[str, Any] @@ -577,9 +633,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,6 +652,13 @@ 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): diff --git a/embry0/api/v1/agents.py b/embry0/api/v1/agents.py index 53595575..991e8a28 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 quest_template_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")]) @@ -147,6 +149,24 @@ 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_unknown_quest_template(request: Request, value: str | None) -> None: + """A ``quest_template`` that names no template is a 422 naming the field (#167): the guild's task would + run the kind default instead of what the operator chose.""" + if not value: + return + problem = quest_template_problem(value, await _template_names(request)) + if problem: + raise HTTPException(status_code=422, detail=problem) + + @router.post("/agents", status_code=201, dependencies=[require_admin]) async def create_agent( req: AgentCreateRequest, @@ -154,6 +174,7 @@ async def create_agent( repo: AgentDefinitionsRepository = Depends(get_agent_defs_repo), ) -> dict[str, Any]: await _reject_if_disabled(request, req.model) + await _reject_unknown_quest_template(request, req.quest_template) warnings = await check_added_skills(request, req.skills or []) try: created = await repo.create( @@ -171,11 +192,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 +223,11 @@ 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")) + await _reject_unknown_quest_template(request, updates.get("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 +259,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 +275,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..19aa85bf 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: @@ -223,6 +264,9 @@ async def update_template( _reject_unpriced(graph) if "graph_definition" in updates: added = _added_skills(graph) + if updates.get("leader_agent") is not None: + rows = rows if rows is not None else await _agent_rows(request) + _reject_unknown_leader(rows, updates["leader_agent"]) # 2. The template must exist: the added names are measured against it. stored = await repo.get(template_id) if repo is not None else None if stored is None: @@ -231,6 +275,11 @@ async def update_template( 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 +288,18 @@ 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") 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 +307,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..f84afe01 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,40 @@ 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 or it cannot be read, so the built-in rows as seeded answer (#167).""" + repo = getattr(request.app.state, name, None) + if repo is None: + return None + try: + return [dict(r) for r in await repo.list_all()] + except Exception: # noqa: BLE001 - the hall is drawn from the built-in rows instead + logger.warning("world_rows_unread", repository=name, exc_info=True) + return None + + +@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( + cache.get("agents", lambda: _rows(request, "agent_defs_repo")), + cache.get("templates", lambda: _rows(request, "templates_repo")), + ) + 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 +212,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 +239,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 +250,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 +316,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": [ @@ -374,13 +417,14 @@ async def read_quest(job_id: str, request: Request) -> dict[str, Any]: repo = _state(request, "world_repo") verifications = getattr(request.app.state, "job_verifications_repo", None) rules = await _state(request, "progression").rules() - runs, approval, links, verification, pr, titles = await asyncio.gather( + runs, approval, links, verification, pr, titles, rows = await asyncio.gather( repo.quest_runs(job_id), repo.merge_approval(job_id), repo.lead_links([job_id]), verifications.get(job_id) if verifications is not None else asyncio.sleep(0, result=None), _pr_stats(request, quest.get("pr_url")), _brief_titles(request, [job_id]), + _behaviour_rows(request, _cache(request)), ) party: dict[str, dict[str, Any]] = {} for run in runs: @@ -424,7 +468,7 @@ async def read_quest(job_id: str, request: Request) -> dict[str, Any]: "account_wait": account_wait_view(quest), # Slop dungeon: the slopometer's verdict, and a cleanup's diff counts. "work_nature": quest.get("work_nature"), - "xp": quest_xp(quest, runs, rules=rules), + "xp": quest_xp(quest, runs, rules=rules, pipelines=rows.pipelines), "cost_usd": round(float(quest.get("total_cost_usd") or 0), 4), "seconds": (finished - started).total_seconds() if isinstance(started, datetime) and isinstance(finished, datetime) 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/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..ec6a99ef 100644 --- a/embry0/services/progression_rollup.py +++ b/embry0/services/progression_rollup.py @@ -29,7 +29,9 @@ What stays stale: an event recorded more than :data:`SETTLE` after its own time, for a day already folded, counts from the next full rebuild, so at most a day late. So does a change to a row of a folded day (a job's last -run names whom its resumed event credits). An event's time never moves (a +run names whom its resumed event credits), and a change to a template's +``leader_agent``, which names whom its jobs' pull requests and audits credit +(#167). An event's time never moves (a job's events happen when it finished, never at its update time), so none is counted twice, and a job's event does not come and go with its pull request (a job that finished its work keeps it when the pull request @@ -64,9 +66,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 +#: coding agents, the Slopmaster, 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 +78,10 @@ "embry0.services.progression_rollup", "embry0.services.progression_vocabulary", "embry0.services.quests", + "embry0.services.slop", "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..c870c33a 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,22 @@ 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 (its row's ``leader_agent``, #167).""" 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 = 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 +248,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 +262,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 +311,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 +344,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 @@ -388,8 +382,11 @@ def quest_fallbacks(runs: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]: return list(entries.values()) -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.""" +def quest_xp( + job: Mapping[str, Any], runs: Iterable[Mapping[str, Any]], *, rules: Rules, pipelines: Pipelines | None = None +) -> 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, credited to its template's coder (``row_behaviour.coder``).""" gains: dict[tuple[str, str], int] = {} at = job.get("finished_at") or job.get("created_at") or datetime.now(UTC) events = [ @@ -399,9 +396,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 leader of its + # template, and its merge earns the code it removed (spec 2026-10-25, slop + # dungeon); any other job's is the developer's. + coder = row_behaviour.coder(pipeline_of(job, pipelines)) 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 +440,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..f1784052 100644 --- a/embry0/storage/migrations/runner.py +++ b/embry0/storage/migrations/runner.py @@ -3999,6 +3999,134 @@ 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: the kind its jobs are recorded as when it is no kind's + # default; delivers: what its jobs hand back, for their briefs; + # leader_agent: who its jobs are drawn as and credited to; + # - 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). + # + # Every row is written what the code said of its name before, so an + # upgraded deployment behaves as it did: the ten specialist templates + # are fixed, not offered and analysis; the leaders and deliverables of + # the maps they came from; a template of the deployment's own whose + # every step is a specialist keeps being recorded as analysis. Rows + # are matched by name, as the code matched them, built-in or not. The + # boot seed writes these columns on a first insert only, so what an + # operator changes later stays. + """ + 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; + + UPDATE pipeline_templates t + SET fixed_route = true, offered_to_triage = false, job_kind = 'analysis', leader_agent = v.agent + FROM (VALUES + ('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') + ) AS v(name, agent) + WHERE t.name = v.name; + UPDATE pipeline_templates t SET leader_agent = v.agent + FROM (VALUES + ('analysis-default', 'analysis'), + ('research-default', 'research'), + ('ops-default', 'ops'), + ('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 pipeline_templates t SET job_kind = 'analysis' + WHERE t.default_for_kind IS NULL AND t.job_kind IS NULL + AND jsonb_typeof(t.graph_definition -> 'nodes') = 'array' + AND EXISTS ( + SELECT 1 FROM jsonb_array_elements(t.graph_definition -> 'nodes') n + WHERE COALESCE(n ->> 'agent_type', '') NOT IN ('triage', 'output')) + AND NOT EXISTS ( + SELECT 1 FROM jsonb_array_elements(t.graph_definition -> 'nodes') n + WHERE COALESCE(n ->> 'agent_type', '') NOT IN ( + 'triage', 'output', 'kintsugi', 'gardener', 'interrogator', 'why', 'how', + 'blast-radius', 'comment-sicko', 'design-auditor', 'context-auditor', 'area-mapper')); + + 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; + + 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: the kind a job of this template is recorded as when it is no kind''s default; NULL leaves triage''s.'; + 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, and its outcomes are credited to.'; + 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.'; + """, + ), ] 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/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/pipeline_templates.py b/embry0/storage/repositories/pipeline_templates.py index bcec83c1..79602cf7 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,19 @@ 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. + """ 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] @@ -93,6 +141,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 +156,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 +175,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 +184,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 +200,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 +209,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..b0de27f2 100644 --- a/embry0/storage/repositories/progression.py +++ b/embry0/storage/repositories/progression.py @@ -24,12 +24,10 @@ 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.schemas import FINISHED_WELL_STATUSES - -# 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" +from embry0.workflows.row_behaviour import CODING_AGENT_TYPES, DEFAULT_CODER, WORKFLOW_PIPELINES #: The template a context audit runs on. AUDIT_TEMPLATE = "context-audit" @@ -44,6 +42,29 @@ def _literal(value: str) -> str: return "'" + value.replace("'", "''") + "'" +#: 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 template_join(job: str, template: str) -> str: + """The join of the job aliased ``job`` to its template's row, aliased ``template`` (#167): by name, as jobs + name their template, so a job whose template has no row (a pipeline the code defines, a template since + renamed) reads as one whose row says nothing.""" + return f"LEFT JOIN pipeline_templates {template} ON {template}.name = {job}.pipeline_template" + + +def coder_sql(template: str) -> str: + """The character a job's pull request is credited to, from its template's row aliased ``template``: the + row's leader when that is a coding agent (the Slopmaster, for a slop cleanup; spec 2026-10-25, slop + dungeon), else the developer. ``embry0.workflows.row_behaviour.coder`` is the same rule in Python.""" + coders = ", ".join(_literal(agent) for agent in CODING_AGENT_TYPES) + return ( + f"COALESCE(CASE WHEN {template}.leader_agent IN ({coders}) THEN {template}.leader_agent END, " + f"{_literal(DEFAULT_CODER)})" + ) + + 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 ( @@ -85,30 +106,30 @@ 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("jt")}, COALESCE(j.delivery_state_at, j.finished_at), NULL, NULL, NULL -FROM jobs j WHERE j.status = 'pr_merged'""", +FROM jobs j {template_join("j", "jt")} 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 -FROM jobs j WHERE j.pr_url IS NOT NULL AND j.finished_at IS NOT NULL""", + f"""-- the job's coder: a job that opened a pull request, once it finished +SELECT 2, j.job_id, 'opened', {coder_sql("jt")}, j.finished_at, NULL, NULL, NULL +FROM jobs j {template_join("j", "jt")} 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', {coder_sql("jt")}, 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 {template_join("j", "jt")} WHERE j.status = 'pr_merged' AND {coder_sql("jt")} = {_literal(SLOPMASTER)}""", ), ( "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("mt")}, COALESCE(m.delivery_state_at, m.finished_at), NULL, NULL, NULL -FROM jobs m +FROM jobs m {template_join("m", "mt")} WHERE m.status = 'pr_merged' AND m.issue_id IS NOT NULL AND EXISTS ( SELECT 1 FROM jobs f @@ -125,15 +146,16 @@ 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 -FROM jobs j WHERE {an_audit("j")} AND {finished_well("j")}""", + f"""-- the audit template's leader (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', jt.leader_agent, j.finished_at, j.result_summary, NULL, NULL +FROM jobs j {template_join("j", "jt")} WHERE {an_audit("j")} AND {finished_well("j")}""", ), ( "a.created_at", @@ -401,9 +423,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 diff --git a/embry0/storage/repositories/world.py b/embry0/storage/repositories/world.py index 20013c15..0460d3c6 100644 --- a/embry0/storage/repositories/world.py +++ b/embry0/storage/repositories/world.py @@ -10,7 +10,9 @@ from collections.abc import Sequence from typing import Any +from embry0.services.slop import SLOPMASTER from embry0.storage.database import DatabasePool +from embry0.storage.repositories.progression import coder_sql, template_join #: Job statuses a party is still out on. LIVE_STATUSES = ("pending", "running", "awaiting_input", "paused") @@ -84,13 +86,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: its template's leader (#167, +# progression.coder_sql). +_CLEANED_SQL = f""" +SELECT j.repo, j.area, COUNT(*) AS cleaned +FROM jobs j {template_join("j", "jt")} +WHERE {coder_sql("jt")} = '{SLOPMASTER}' 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 """ 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..994ef0fc 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 (``job_kind``), and the world + draws it as its specialist (``leader_agent``). """ return { "description": description, + "fixed_route": True, + "offered_to_triage": False, + "job_kind": "analysis", + "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..d5a1d66d 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, offered_to_triage, recorded_kind logger = structlog.get_logger(__name__) @@ -263,21 +262,45 @@ 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, or when it cannot be read, the built-in template as + seeded answers (``row_behaviour.builtin_template``): every deployment has + those rows. A lookup failure is logged. """ - 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) try: - row = await _find_template(templates_repo, name=requested) + return await _find_template(templates_repo, name=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 builtin_template(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``). + """ + return await template_row(templates_repo, str(state.get("pipeline_template_request") or "")) + + +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 +331,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,40 +364,45 @@ 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 offered_to_triage(await template_row(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 @@ -407,7 +436,7 @@ 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 break @@ -425,8 +454,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 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..ed6eca04 --- /dev/null +++ b/embry0/workflows/row_behaviour.py @@ -0,0 +1,269 @@ +"""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 when the + template is no kind's default (``default_for_kind`` wins); +- ``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, and the one its outcomes are credited to; + a pull request is its leader's only when the leader is a coding agent + (:func:`coder`). + +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, as for the developer), 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. +""" + +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 a job's +#: pull request is credited to one of them (:func:`coder`). +CODING_AGENT_TYPES: Final[tuple[str, ...]] = ("developer", "slopmaster") +#: The coding agent of a route that names no other, and of a job whose template's leader is no coding agent. +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 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``.""" + row = template or {} + return str(row.get("default_for_kind") or row.get("job_kind") or "") or None + + +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 + + +def coder(template: Mapping[str, Any] | None) -> str: + """The character a job's pull request is credited to: its template's leader when that is a coding agent, + else the developer. ``embry0.storage.repositories.progression.coder_sql`` is the same rule in SQL.""" + agent = leader(template) + return agent if agent in CODING_AGENT_TYPES else DEFAULT_CODER + + +# --- 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(ch in text for ch in "\r\n") or 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 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 (or when it cannot be read): the rows + every deployment has. + """ + 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..b17e9d67 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,53 @@ 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"); + + await user.selectOptions(screen.getByLabelText("Station"), "map_table"); + 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: "scout", station: "map_table", quest_template: "" }), + 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("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.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..f7c1e3e3 --- /dev/null +++ b/tests/fixtures/builtin_rows.py @@ -0,0 +1,19 @@ +"""The built-in rows a deployment always has, for a test whose database holds only what the migrations wrote. + +The boot seeder upserts the built-in pipeline templates before anything +reads them (``embry0.storage.seeds.pipeline_templates_builtin``); the +migrations write none. What a job's template row says (#167, its +``leader_agent`` above all) is what progression credits its work to, so a +test of that reads the rows the seeder writes. +""" + +from __future__ import annotations + +from embry0.storage.database import DatabasePool +from embry0.storage.repositories.pipeline_templates import PipelineTemplatesRepository +from embry0.storage.seeds.pipeline_templates_builtin import seed_builtin_pipeline_templates + + +async def seed_builtin_templates(db: DatabasePool) -> None: + """Write the built-in pipeline templates as the boot seeder does.""" + await seed_builtin_pipeline_templates(PipelineTemplatesRepository(db)) 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..ed609af2 100644 --- a/tests/fixtures/progression_history.py +++ b/tests/fixtures/progression_history.py @@ -120,7 +120,11 @@ async def _trace( async def seed_history(db: DatabasePool) -> None: - """Write the history: jobs, runs, cases, answers, reviews, setups and a standard.""" + """Write the history: jobs, runs, cases, answers, reviews, setups and a standard, over the built-in + templates every deployment has (``tests/fixtures/builtin_rows.py``).""" + from tests.fixtures.builtin_rows import seed_builtin_templates + + await seed_builtin_templates(db) for agent in AGENTS: await db.execute( "INSERT INTO agent_definitions (type, description, model) VALUES ($1, 'd', 'm') ON CONFLICT (type) DO NOTHING", 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..8502268e --- /dev/null +++ b/tests/unit/api/v1/test_behaviour_writes_api.py @@ -0,0 +1,222 @@ +"""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 body, want in ( + ({"station": "map_table"}, {"station": "map_table"}), + ({"quest_template": ""}, {"quest_template": ""}), + ({"quest_template": None}, {"quest_template": None}), + ): + resp = await _send(app, "PUT", "/api/v1/agents/scout", 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", {})], + 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_world_rows.py b/tests/unit/api/v1/test_world_rows.py new file mode 100644 index 00000000..0e7ac5b6 --- /dev/null +++ b/tests/unit/api/v1/test_world_rows.py @@ -0,0 +1,144 @@ +"""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") 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_fix_job_progression.py b/tests/unit/services/test_fix_job_progression.py index 953ab3f5..7ec7bea5 100644 --- a/tests/unit/services/test_fix_job_progression.py +++ b/tests/unit/services/test_fix_job_progression.py @@ -22,6 +22,7 @@ from embry0.services.progression_pack import ShippedPack, default_rules from embry0.storage.repositories.progression import ProgressionRepository from embry0.storage.repositories.progression_rollup import ProgressionRollupRepository +from tests.fixtures.builtin_rows import seed_builtin_templates pytestmark = [pytest.mark.requires_postgres, pytest.mark.asyncio] @@ -130,6 +131,7 @@ def _audit_tasks(progression: Progression, repo: str) -> dict[str, datetime | No @pytest.mark.parametrize("through_rollup", [False, True], ids=["raw", "rollup"]) async def test_a_completed_fix_job_earns_no_audit_credit(db_with_migrations, through_rollup: bool) -> None: db = db_with_migrations + await seed_builtin_templates(db) # the context-audit template, whose leader an audit credits (#167) await _job( db, "job-audit", repo=AUDITED_REPO, by=CONTEXT_AUDIT, finished=hour(1), report="Stale: 3\nFiles: 2\n", pr=1 ) @@ -166,6 +168,7 @@ async def test_a_completed_fix_job_earns_no_audit_credit(db_with_migrations, thr @pytest.mark.parametrize("through_rollup", [False, True], ids=["raw", "rollup"]) async def test_a_merged_fix_job_is_credited_once_as_a_merged_proposal(db_with_migrations, through_rollup: bool) -> None: db = db_with_migrations + await seed_builtin_templates(db) # the context-audit template, whose leader an audit credits (#167) await _job( db, "job-audit", 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..982929dd --- /dev/null +++ b/tests/unit/storage/test_behaviour_rows_repo.py @@ -0,0 +1,203 @@ +"""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, coder_sql +from embry0.storage.repositories.world import WorldRepository +from embry0.workflows import row_behaviour as rb +from tests.fixtures.builtin_rows import seed_builtin_templates + +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 test_a_jobs_pull_request_is_credited_as_its_template_row_says(db_with_migrations: DatabasePool) -> None: + """The SQL rule (progression.coder_sql) and the Python one (row_behaviour.coder) agree for every job.""" + db = db_with_migrations + await seed_builtin_templates(db) + repo = PipelineTemplatesRepository(db) + # A slop template of the deployment's own, led by the Slopmaster; one led by an agent that does not code. + await repo.create(name="my-slop", graph_definition=GRAPH, leader_agent="slopmaster") + await repo.create(name="my-report", graph_definition=GRAPH, leader_agent="why") + jobs = { + "j-slop": "slop-cleanup", + "j-my-slop": "my-slop", + "j-report": "my-report", + "j-fix": "quick-fix", + "j-map": "map-areas", + "j-qa": "qa", + "j-none": None, + "j-gone": "renamed-since", + } + for job_id, template in jobs.items(): + await _job(db, job_id, template) + + events = await ProgressionRepository(db).events() + rows = rb.pipelines_by_name(await repo.list_all()) + credited = { + r["job_id"]: r["agent_type"] + for r in await db.fetch( + f"SELECT j.job_id, {coder_sql('jt')} AS agent_type " + "FROM jobs j LEFT JOIN pipeline_templates jt ON jt.name = j.pipeline_template" + ) + } + want = {job_id: rb.coder(rows.get(template or "")) for job_id, template in jobs.items()} + assert credited == want, f"the SQL and the Python credit differ: {credited} != {want}" + assert (want["j-slop"], want["j-my-slop"], want["j-report"]) == ("slopmaster", "slopmaster", "developer") + merged = sorted(str(e.agent_type) for e in events if e.source == "merged") + assert merged == sorted(want.values()), f"each merged job is credited to its coder: {merged}" + + cleaned = sorted(e.agent_type for e in events if e.source == "cleaned") + assert cleaned == ["slopmaster", "slopmaster"], f"each job the Slopmaster coded is a cleanup: {cleaned}" + by_area = await WorldRepository(db).cleaned(days=100_000) + assert by_area == {("acme/widgets", "Billing"): 2}, f"the dungeon counts both cleanups: {by_area}" + + +async def test_an_audit_is_credited_to_its_templates_leader(db_with_migrations: DatabasePool) -> None: + db = db_with_migrations + await seed_builtin_templates(db) + await _job(db, "j-audit", "context-audit", status="completed", pr_url=None, delivery_state="none") + await db.execute("UPDATE jobs SET result_summary = 'Stale: 1' WHERE job_id = 'j-audit'") + audited = [e.agent_type for e in await ProgressionRepository(db).events() if e.source == "audited"] + assert audited == ["context-auditor"], audited + await db.execute("UPDATE pipeline_templates SET leader_agent = 'why' WHERE name = 'context-audit'") + audited = [e.agent_type for e in await ProgressionRepository(db).events() if e.source == "audited"] + assert audited == ["why"], f"the row's leader, not a name in the query: {audited}" + + +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_builtin_templates(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" 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..1f5440af --- /dev/null +++ b/tests/unit/storage/test_migration_143.py @@ -0,0 +1,205 @@ +"""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 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") + + +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)) + await db.execute("ALTER TABLE agent_definitions DROP COLUMN station, DROP COLUMN quest_template") + 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, graph: dict[str, Any], kind: str | None) -> dict[str, Any]: + specialist = name in before.SPECIALIST_TEMPLATE_NAMES + return { + "fixed_route": specialist, + "offered_to_triage": not specialist, + # A kind default records its own kind; job_kind is what a template that is none records. + "job_kind": None if kind else before.recorded_kind(name, graph, 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) + expected = { + name: _what_the_name_said(name, seed["graph_definition"], seed.get("default_for_kind")) + for name, seed in BUILTIN_PIPELINE_TEMPLATES.items() + } + for name, graph, kind in ( + ("my-why", BUILTIN_PIPELINE_TEMPLATES["why"]["graph_definition"], None), + ("two-specialists", _route("why", "how", "output"), None), + ("my-fix", _route("developer", "reviewer"), None), + ("specialist-then-code", _route("kintsugi", "developer"), None), + ("empty", {"nodes": [], "edges": []}, None), + ): + expected[name] = _what_the_name_said(name, graph, kind) + 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["two-specialists"]["job_kind"] == "analysis", "a route of specialists only stays analysis" + assert expected["my-why"]["fixed_route"] is False, "a copy of a specialist route was triaged before, by name" + + +@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": "analysis", + "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" diff --git a/tests/unit/storage/test_progression_repo.py b/tests/unit/storage/test_progression_repo.py index 99e4299f..ef0908d6 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 seed_builtin_templates pytestmark = [pytest.mark.requires_postgres, pytest.mark.asyncio] @@ -69,6 +70,7 @@ async def _trace( async def test_every_source_yields_its_events_and_nothing_else(db_with_migrations) -> None: """The healed, watched and scheduled sources were read and never priced: they are gone (#176).""" db = db_with_migrations + await seed_builtin_templates(db) # the templates whose leaders a job's events credit (#167) await _job(db, "job-merged", status="pr_merged") await db.execute("UPDATE jobs SET pr_url = 'https://forge/pr/1' WHERE job_id = 'job-merged'") await _job(db, "job-qa", status="completed", template="qa") diff --git a/tests/unit/storage/test_progression_rollup_repo.py b/tests/unit/storage/test_progression_rollup_repo.py index b3db2ae2..8a50c958 100644 --- a/tests/unit/storage/test_progression_rollup_repo.py +++ b/tests/unit/storage/test_progression_rollup_repo.py @@ -27,6 +27,7 @@ from embry0.storage.repositories.job_delivery import DeliveryWrite, JobDeliveryRepository from embry0.storage.repositories.progression import Journal, ProgressEvent, ProgressionRepository from embry0.storage.repositories.progression_rollup import ProgressionRollupRepository +from tests.fixtures.builtin_rows import seed_builtin_templates from tests.fixtures.progression_history import _job, _trace from tests.unit.services.test_progression_rollup import KEYS, PACKS, Packs, differences @@ -208,6 +209,7 @@ async def test_events_at_one_time_come_in_one_order_whatever_the_bounds(db_with_ tie must not be broken by whatever order Postgres happens to return. """ db = db_with_migrations + await seed_builtin_templates(db) # the templates whose leaders a job's events credit (#167) created = datetime(2026, 3, 1, 10, tzinfo=UTC) await _job(db, "j-b", status="pr_merged", pr_url="https://forge/acme/widgets/pull/2", at=created) await _job( @@ -442,6 +444,7 @@ async def test_a_job_keeps_what_it_earned_when_its_pull_request_merges_after_its ) -> None: """A finished audit or QA run whose pull request merges reads the same from the rollup, a full read and a rebuild.""" db = db_with_migrations + await seed_builtin_templates(db) # the templates whose leaders a job's events credit (#167) for agent in ("developer", "qa", "context-auditor"): await db.execute( "INSERT INTO agent_definitions (type, description, model) VALUES ($1, 'd', 'm') ON CONFLICT DO NOTHING", 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..eee8c7f3 --- /dev/null +++ b/tests/unit/workflows/issue_to_pr/test_template_rows_route.py @@ -0,0 +1,196 @@ +"""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" 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..3b612e87 --- /dev/null +++ b/tests/unit/workflows/test_no_builtin_name_lists.py @@ -0,0 +1,54 @@ +"""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"), + ], +) +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-auditor'"): + assert literal not in sql, f"a query still names {literal}: a template's row says it now" + assert "pipeline_templates" in sql, "the query reads the job's template row" + + +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..b54a03ca --- /dev/null +++ b/tests/unit/workflows/test_row_behaviour.py @@ -0,0 +1,176 @@ +"""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 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" + assert rb.coder(row) == "developer", f"{row!r}'s pull request is the developer's" + + +def test_the_kind_a_template_records_is_its_default_kind_first() -> None: + assert rb.recorded_kind({"default_for_kind": "ops", "job_kind": "analysis"}) == "ops" + assert rb.recorded_kind({"job_kind": "analysis"}) == "analysis" + assert rb.recorded_kind({"default_for_kind": "", "job_kind": ""}) is None + + +def test_a_pull_request_is_its_leaders_only_when_the_leader_codes() -> None: + assert rb.coder({"leader_agent": "slopmaster"}) == "slopmaster" + assert rb.coder({"leader_agent": "developer"}) == "developer" + assert rb.coder({"leader_agent": "area-mapper"}) == "developer", "a map's pull request is the developer's" + assert rb.coder({"leader_agent": "my-own-coder"}) == "developer", "only the developer node's agents code" + + +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_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" + + +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" + assert rb.coder(row) == before.coder(name), f"{name}: whom its pull request is credited 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 rb.coder(row) == before.coder(name) + 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 rb.coder(None) == 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..028ddd83 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) } @@ -467,7 +466,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 From b32d095140cf603f2cef4b15e2c200c41b0c767d Mon Sep 17 00:00:00 2001 From: Tomas McMonigal <74835766+sudoTomas@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:42:52 +0000 Subject: [PATCH 2/3] refactor(agents): behaviour from agent and template rows Operators write templates and agents as rows, but what embry0 did with one depended on the names of the built-in ones. An operator's own specialist template was triaged and offered to triage, its jobs' briefs said "the result of the work", and an agent of their own could never be given a task from the guild. The progression and world queries credited a job's pull request, cleanup and audit by template and agent names. Each of those is now a column of the row, read through one module, embry0/workflows/row_behaviour.py: - pipeline_templates.fixed_route (a job that names it skips triage), offered_to_triage, job_kind (an override; NULL reads the route, so a route of specialists only is analysis), delivers and leader_agent; - agent_definitions.station and quest_template (NULL no guild task, '' a task naming no template, a coding agent's only, else that template). Credit is recorded on the job when it is planned: plan_route writes jobs.coding_agent and jobs.leader_agent, and progression and the world credit from them. A slop cleanup is a merged job the Slopmaster coded, an audit a job the context auditor led, so a copy of either template counts and a later edit of a template row changes no past credit. SPECIALIST_TEMPLATE_NAMES, specialist_job_kind, _TEMPLATE_DELIVERS, LEADER_BY_TEMPLATE, QUEST_TEMPLATE_BY_AGENT, the frontend's STATION_OF, progression's _CODER and AUDIT_TEMPLATE are gone; the coding agents are listed once. The pipelines the code defines (qa, onboard, pr-review) keep their leader and deliverable in WORKFLOW_PIPELINES, and no template may take their names. Migration 143 adds the columns and writes every row and job what its name gave it, and renames a template named like a code pipeline. The boot seed writes a built-in template's columns on its first insert only. The columns are set through the API (422 naming the field, checked only when a write changes the value), MCP agent_create and agent_update, the agent form's Guild section and a template's Behaviour dialog. A rename carries each agent's guild task with it. When the rows cannot be read, no route is fixed and the guild offers no task. Agent roles are left to #395. Fixes: #167 --- docs/api.md | 14 +- docs/architecture.md | 2 +- docs/configuration.md | 2 +- docs/glossary.md | 2 +- docs/specialist-agents.md | 5 +- .../2026-11-05-behaviour-from-rows-design.md | 59 ++++-- embry0/api/schemas/__init__.py | 18 ++ embry0/api/v1/agents.py | 32 +++- embry0/api/v1/pipeline_templates.py | 11 +- embry0/api/v1/world.py | 26 ++- embry0/services/progression_rollup.py | 6 +- embry0/services/world.py | 19 +- embry0/storage/migrations/runner.py | 106 +++++++---- embry0/storage/repositories/jobs.py | 3 + .../repositories/pipeline_templates.py | 20 +- embry0/storage/repositories/progression.py | 75 ++++---- embry0/storage/repositories/world.py | 15 +- .../seeds/pipeline_templates_builtin.py | 6 +- embry0/workflows/issue_to_pr/plan_route.py | 52 ++++-- embry0/workflows/row_behaviour.py | 80 +++++--- frontend/src/lib/types/agents.ts | 6 +- frontend/src/pages/AgentFormPage.tsx | 5 +- .../pages/__tests__/AgentFormPage.test.tsx | 21 ++- tests/fixtures/builtin_rows.py | 31 ++-- tests/fixtures/progression_history.py | 10 +- .../unit/api/v1/test_behaviour_writes_api.py | 62 ++++++- tests/unit/api/v1/test_world_api.py | 1 + tests/unit/api/v1/test_world_rows.py | 44 +++++ .../unit/services/test_fix_job_progression.py | 10 +- .../unit/services/test_progression_golden.py | 1 + .../unit/services/test_progression_rollup.py | 4 +- tests/unit/services/test_quest_job_times.py | 12 +- tests/unit/services/test_quests.py | 5 +- .../unit/storage/test_behaviour_rows_repo.py | 173 +++++++++++++----- tests/unit/storage/test_migration_143.py | 92 ++++++++-- tests/unit/storage/test_progression_repo.py | 16 +- .../storage/test_progression_rollup_repo.py | 3 - .../issue_to_pr/test_template_rows_route.py | 104 +++++++++++ .../workflows/test_no_builtin_name_lists.py | 8 +- tests/unit/workflows/test_row_behaviour.py | 51 ++++-- .../unit/workflows/test_specialist_agents.py | 9 +- 41 files changed, 901 insertions(+), 320 deletions(-) diff --git a/docs/api.md b/docs/api.md index af52ea3e..5badec49 100644 --- a/docs/api.md +++ b/docs/api.md @@ -326,9 +326,9 @@ The roster, the palette and the builtins' own values are in [`docs/contracts/ide | 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, as for the developer), a name a task that runs that template; at most 100 characters | +| `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 '...'`). A task whose template is deleted later is no task: the guild stops offering it. `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. +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. @@ -500,11 +500,15 @@ The saved pipeline graphs the editor and the Pipelines page work on, under `/api |---|---|---| | `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` | 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` leaves triage's kind | +| `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, and the one its outcomes are credited to: its pull request when the leader is a coding agent (`developer`, `slopmaster`; the developer otherwise), its audit for a context audit. Must name an agent that runs in jobs | +| `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`. 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 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.** When plan_route plans a job it records the agent its route's coding step runs as (`jobs.coding_agent`) and its template's leader (`jobs.leader_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, an audit to the context auditor when it led 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. + +**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 4195f527..4c751cce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1389,7 +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`, `delivers` (NOT NULL, default `''`) and `leader_agent`; `agent_definitions.station` (NOT NULL, default `desk`) and `quest_template` (NULL none, `''` a task naming no template). Every row gets what the code said of its name before: the ten specialist templates fixed, not offered and analysis, the leaders and deliverables of the old maps, the old stations and guild tasks, and a template of the deployment's own whose every step is a specialist keeps being recorded as analysis | `pipeline_templates`, `agent_definitions` | +| 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 db07f1e6..99b0ac62 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -280,7 +280,7 @@ 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 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. The rules: [api.md](api.md#pipeline-templates) and [api.md](api.md#agents). +- **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. diff --git a/docs/glossary.md b/docs/glossary.md index 63916987..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, 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`, `job_kind`; 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) +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 c23a1581..990f14f7 100644 --- a/docs/specialist-agents.md +++ b/docs/specialist-agents.md @@ -151,8 +151,9 @@ 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, `job_kind` analysis, and -`leader_agent` its specialist; [api.md](api.md#pipeline-templates)). A +(`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. 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 index 479d514b..668c6e4b 100644 --- a/docs/superpowers/specs/2026-11-05-behaviour-from-rows-design.md +++ b/docs/superpowers/specs/2026-11-05-behaviour-from-rows-design.md @@ -32,32 +32,50 @@ say about its behaviour", tested on its own |---|---|---|---| | `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` | `specialist_job_kind` | triage's fixed decision, 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`, the progression `_CODER` and audit credit, the world's cleanup count | world, progression | +| `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 | -- **A job's coder** is its template's leader when the leader is one of the - developer node's agents (`CODING_AGENT_TYPES`: developer, slopmaster), else - the developer: `row_behaviour.coder` in Python, `progression.coder_sql` in - SQL, pinned to agree. A slop cleanup is a merged job the Slopmaster coded. +- **Credit is recorded on the job.** plan_route records the agent its route's + coding step runs as (`jobs.coding_agent`, one of `CODING_AGENT_TYPES`) and + its template's leader (`jobs.leader_agent`). A job's pull request is its + coder's (the developer's when it recorded none), a slop cleanup is a merged + job the Slopmaster coded, and an audit is a job the context auditor led + (the agent whose report the audit's claims are read from), not a fix job. + So a later edit or deletion of a template row changes no past credit, and + a copy of the context-audit template is an audit. +- **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** (or when it cannot be read), the built-in rows as - seeded answer (`builtin_template`, `builtin_agent`), as the graph - validator already falls back to `BUILTIN_SEED`. +- **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 and a `quest_template` that names no template are 422s too. On - `PUT`, null clears `job_kind`, `leader_agent` and `quest_template` and is a - 422 for the NOT NULL columns. + 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. @@ -71,9 +89,11 @@ say about its behaviour", tested on its own ### 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; a -template of the deployment's own whose every step is a specialist keeps its -analysis kind. Built-in agents are insert-if-missing, as before. Built-in +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. @@ -82,11 +102,10 @@ 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 rollup now reads `pipeline_templates`; a change to a -template's `leader_agent` reaches the folded days at the next nightly -rebuild, like any change to a row of a folded day. +The progression and world queries read only the job's recorded columns, +never `pipeline_templates`, so the rollup's folded days stay true. -## Deviation from the issue: no `roles` column +## 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 @@ -98,7 +117,7 @@ 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. +helper for the coding agents and leaves roles to that decision: #395. ## Found, not changed here diff --git a/embry0/api/schemas/__init__.py b/embry0/api/schemas/__init__.py index 903fd38c..564a8d7e 100644 --- a/embry0/api/schemas/__init__.py +++ b/embry0/api/schemas/__init__.py @@ -22,6 +22,7 @@ checked_leader_agent, checked_quest_template, checked_station, + template_name_problem, ) # Slash-separated alnum + . _ - : segments. Colon allowed for ISO timestamps in @@ -624,6 +625,15 @@ def _delivers(cls, v: Any) -> str | None: 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) @@ -664,6 +674,14 @@ class TemplateUpdateRequest(_TemplateWriteRequest, _TemplateBehaviour): 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 991e8a28..49252075 100644 --- a/embry0/api/v1/agents.py +++ b/embry0/api/v1/agents.py @@ -26,7 +26,7 @@ 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 quest_template_problem +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")]) @@ -80,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}") @@ -93,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"] = ( @@ -157,16 +158,24 @@ async def _template_names(request: Request) -> set[str] | None: return {str(t["name"]) for t in await repo.list_all()} -async def _reject_unknown_quest_template(request: Request, value: str | None) -> None: - """A ``quest_template`` that names no template is a 422 naming the field (#167): the guild's task would - run the kind default instead of what the operator chose.""" - if not value: - return - problem = quest_template_problem(value, await _template_names(request)) +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, @@ -174,7 +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_unknown_quest_template(request, req.quest_template) + await _reject_unrunnable_task(request, req.type, req.quest_template) warnings = await check_added_skills(request, req.skills or []) try: created = await repo.create( @@ -227,7 +236,10 @@ async def update_agent( 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")) - await _reject_unknown_quest_template(request, updates.get("quest_template")) + 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. diff --git a/embry0/api/v1/pipeline_templates.py b/embry0/api/v1/pipeline_templates.py index 19aa85bf..a1d24b76 100644 --- a/embry0/api/v1/pipeline_templates.py +++ b/embry0/api/v1/pipeline_templates.py @@ -264,13 +264,15 @@ async def update_template( _reject_unpriced(graph) if "graph_definition" in updates: added = _added_skills(graph) - if updates.get("leader_agent") is not None: - rows = rows if rows is not None else await _agent_rows(request) - _reject_unknown_leader(rows, updates["leader_agent"]) # 2. The template must exist: the added names are measured against it. 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]) @@ -289,6 +291,9 @@ async def update_template( 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} diff --git a/embry0/api/v1/world.py b/embry0/api/v1/world.py index f84afe01..7361f885 100644 --- a/embry0/api/v1/world.py +++ b/embry0/api/v1/world.py @@ -151,15 +151,24 @@ async def _regions(request: Request) -> dict[str, Any]: 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 or it cannot be read, so the built-in rows as seeded answer (#167).""" + 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: - return [dict(r) for r in await repo.list_all()] - except Exception: # noqa: BLE001 - the hall is drawn from the built-in rows instead + 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 None + return [] @dataclass(frozen=True) @@ -173,8 +182,8 @@ class _Rows: async def _behaviour_rows(request: Request, cache: WorldCache) -> _Rows: agents, templates = await asyncio.gather( - cache.get("agents", lambda: _rows(request, "agent_defs_repo")), - cache.get("templates", lambda: _rows(request, "templates_repo")), + _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, @@ -417,14 +426,13 @@ async def read_quest(job_id: str, request: Request) -> dict[str, Any]: repo = _state(request, "world_repo") verifications = getattr(request.app.state, "job_verifications_repo", None) rules = await _state(request, "progression").rules() - runs, approval, links, verification, pr, titles, rows = await asyncio.gather( + runs, approval, links, verification, pr, titles = await asyncio.gather( repo.quest_runs(job_id), repo.merge_approval(job_id), repo.lead_links([job_id]), verifications.get(job_id) if verifications is not None else asyncio.sleep(0, result=None), _pr_stats(request, quest.get("pr_url")), _brief_titles(request, [job_id]), - _behaviour_rows(request, _cache(request)), ) party: dict[str, dict[str, Any]] = {} for run in runs: @@ -468,7 +476,7 @@ async def read_quest(job_id: str, request: Request) -> dict[str, Any]: "account_wait": account_wait_view(quest), # Slop dungeon: the slopometer's verdict, and a cleanup's diff counts. "work_nature": quest.get("work_nature"), - "xp": quest_xp(quest, runs, rules=rules, pipelines=rows.pipelines), + "xp": quest_xp(quest, runs, rules=rules), "cost_usd": round(float(quest.get("total_cost_usd") or 0), 4), "seconds": (finished - started).total_seconds() if isinstance(started, datetime) and isinstance(finished, datetime) diff --git a/embry0/services/progression_rollup.py b/embry0/services/progression_rollup.py index ec6a99ef..ce15c466 100644 --- a/embry0/services/progression_rollup.py +++ b/embry0/services/progression_rollup.py @@ -29,9 +29,7 @@ What stays stale: an event recorded more than :data:`SETTLE` after its own time, for a day already folded, counts from the next full rebuild, so at most a day late. So does a change to a row of a folded day (a job's last -run names whom its resumed event credits), and a change to a template's -``leader_agent``, which names whom its jobs' pull requests and audits credit -(#167). An event's time never moves (a +run names whom its resumed event credits). An event's time never moves (a job's events happen when it finished, never at its update time), so none is counted twice, and a job's event does not come and go with its pull request (a job that finished its work keeps it when the pull request @@ -67,7 +65,7 @@ #: The modules whose code decides what a fold of the history holds: what an event is (the events #: query, the actor that marks a finding's fix job as no audit, and whom a job's events credit: the -#: coding agents, the Slopmaster, the QA pipeline's leader), what it earns (pricing, the action +#: 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", diff --git a/embry0/services/world.py b/embry0/services/world.py index c870c33a..c683d54d 100644 --- a/embry0/services/world.py +++ b/embry0/services/world.py @@ -157,13 +157,14 @@ 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 (its row's ``leader_agent``, #167).""" + 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] - lead = row_behaviour.leader(pipeline_of(job, pipelines)) + lead = job.get("leader_agent") or row_behaviour.leader(pipeline_of(job, pipelines)) if lead: return lead if members: @@ -382,11 +383,9 @@ def quest_fallbacks(runs: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]: return list(entries.values()) -def quest_xp( - job: Mapping[str, Any], runs: Iterable[Mapping[str, Any]], *, rules: Rules, pipelines: Pipelines | None = None -) -> 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, credited to its template's coder (``row_behaviour.coder``).""" + 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 = [ @@ -396,10 +395,10 @@ def quest_xp( 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, the leader of its - # template, and its merge earns the code it removed (spec 2026-10-25, slop - # dungeon); any other job's is the developer's. - coder = row_behaviour.coder(pipeline_of(job, pipelines)) + # 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": diff --git a/embry0/storage/migrations/runner.py b/embry0/storage/migrations/runner.py index f1784052..a98fa451 100644 --- a/embry0/storage/migrations/runner.py +++ b/embry0/storage/migrations/runner.py @@ -4008,21 +4008,34 @@ def lit(value: str) -> str: # # - 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: the kind its jobs are recorded as when it is no kind's - # default; delivers: what its jobs hand back, for their briefs; - # leader_agent: who its jobs are drawn as and credited to; + # 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). + # 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, so an - # upgraded deployment behaves as it did: the ten specialist templates - # are fixed, not offered and analysis; the leaders and deliverables of - # the maps they came from; a template of the deployment's own whose - # every step is a specialist keeps being recorded as analysis. Rows - # are matched by name, as the code matched them, built-in or not. The - # boot seed writes these columns on a first insert only, so what an - # operator changes later stays. + # 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, @@ -4033,10 +4046,27 @@ def lit(value: str) -> str: 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, job_kind = 'analysis', leader_agent = v.agent + 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'), @@ -4046,14 +4076,7 @@ def lit(value: str) -> str: ('comment-pass', 'comment-sicko'), ('design-audit', 'design-auditor'), ('context-audit', 'context-auditor'), - ('map-areas', 'area-mapper') - ) AS v(name, agent) - WHERE t.name = v.name; - UPDATE pipeline_templates t SET leader_agent = v.agent - FROM (VALUES - ('analysis-default', 'analysis'), - ('research-default', 'research'), - ('ops-default', 'ops'), + ('map-areas', 'area-mapper'), ('slop-cleanup', 'slopmaster') ) AS v(name, agent) WHERE t.name = v.name; @@ -4064,17 +4087,6 @@ def lit(value: str) -> str: ('map-areas', 'a pull request that maps the repository''s areas') ) AS v(name, delivers) WHERE t.name = v.name; - UPDATE pipeline_templates t SET job_kind = 'analysis' - WHERE t.default_for_kind IS NULL AND t.job_kind IS NULL - AND jsonb_typeof(t.graph_definition -> 'nodes') = 'array' - AND EXISTS ( - SELECT 1 FROM jsonb_array_elements(t.graph_definition -> 'nodes') n - WHERE COALESCE(n ->> 'agent_type', '') NOT IN ('triage', 'output')) - AND NOT EXISTS ( - SELECT 1 FROM jsonb_array_elements(t.graph_definition -> 'nodes') n - WHERE COALESCE(n ->> 'agent_type', '') NOT IN ( - 'triage', 'output', 'kintsugi', 'gardener', 'interrogator', 'why', 'how', - 'blast-radius', 'comment-sicko', 'design-auditor', 'context-auditor', 'area-mapper')); UPDATE agent_definitions a SET station = v.station FROM (VALUES @@ -4111,20 +4123,46 @@ def lit(value: str) -> str: ) 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: the kind a job of this template is recorded as when it is no kind''s default; NULL leaves triage''s.'; + '#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, and its outcomes are credited to.'; + '#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/jobs.py b/embry0/storage/repositories/jobs.py index cbf7cd5d..e9ad90b2 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", } ) diff --git a/embry0/storage/repositories/pipeline_templates.py b/embry0/storage/repositories/pipeline_templates.py index 79602cf7..24c01e28 100644 --- a/embry0/storage/repositories/pipeline_templates.py +++ b/embry0/storage/repositories/pipeline_templates.py @@ -108,7 +108,9 @@ async def create( async def update(self, template_id: str, **fields: Any) -> dict[str, Any]: """Update allowed fields on a pipeline template and return the updated record. - A malformed behaviour column raises ValueError naming its field. + 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)) @@ -121,10 +123,18 @@ 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: + old_name = await conn.fetchval("SELECT name FROM pipeline_templates WHERE id = $1", 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] diff --git a/embry0/storage/repositories/progression.py b/embry0/storage/repositories/progression.py index b0de27f2..7001d48c 100644 --- a/embry0/storage/repositories/progression.py +++ b/embry0/storage/repositories/progression.py @@ -27,14 +27,16 @@ from embry0.services.slop import SLOPMASTER from embry0.storage.database import DatabasePool from embry0.storage.schemas import FINISHED_WELL_STATUSES -from embry0.workflows.row_behaviour import CODING_AGENT_TYPES, DEFAULT_CODER, WORKFLOW_PIPELINES +from embry0.workflows.row_behaviour import DEFAULT_CODER, WORKFLOW_PIPELINES -#: The template a context audit runs on. -AUDIT_TEMPLATE = "context-audit" +#: The agent a context audit is led by, whose report an audit's claims are read from (``context_audit``): a job +#: whose template's leader it was when the job was planned (``jobs.leader_agent``, #167) is an audit, a copy of +#: the context-audit template included. +AUDITOR = "context-auditor" #: 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 +#: accepting one finding. It is led by the :data:`AUDITOR` 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. +#: check of progression for an audit reads :func:`an_audit` or :attr:`JobRow.is_audit`, never the leader alone. FIX_ACTOR = PROPOSALS @@ -47,30 +49,22 @@ def _literal(value: str) -> str: _QA_AGENT = str(WORKFLOW_PIPELINES["qa"]["leader_agent"]) -def template_join(job: str, template: str) -> str: - """The join of the job aliased ``job`` to its template's row, aliased ``template`` (#167): by name, as jobs - name their template, so a job whose template has no row (a pipeline the code defines, a template since - renamed) reads as one whose row says nothing.""" - return f"LEFT JOIN pipeline_templates {template} ON {template}.name = {job}.pipeline_template" +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 coder_sql(template: str) -> str: - """The character a job's pull request is credited to, from its template's row aliased ``template``: the - row's leader when that is a coding agent (the Slopmaster, for a slop cleanup; spec 2026-10-25, slop - dungeon), else the developer. ``embry0.workflows.row_behaviour.coder`` is the same rule in Python.""" - coders = ", ".join(_literal(agent) for agent in CODING_AGENT_TYPES) - return ( - f"COALESCE(CASE WHEN {template}.leader_agent IN ({coders}) THEN {template}.leader_agent END, " - f"{_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)}" 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 SQL condition that the job aliased ``job`` is a context audit: led by the :data:`AUDITOR` when it was + planned, and not a finding's fix job.""" + return f"({job}.leader_agent = {_literal(AUDITOR)} AND {job}.dispatched_by IS DISTINCT FROM {_literal(FIX_ACTOR)})" #: The statuses of a job that finished its work, ``FINISHED_WELL_STATUSES`` in ``embry0/storage/schemas.py``: @@ -107,29 +101,29 @@ def finished_well(job: str) -> str: ( "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("jt")}, COALESCE(j.delivery_state_at, j.finished_at), +SELECT 1, j.job_id, 'merged', {coder_sql("j")}, COALESCE(j.delivery_state_at, j.finished_at), NULL, NULL, NULL -FROM jobs j {template_join("j", "jt")} WHERE j.status = 'pr_merged'""", +FROM jobs j WHERE j.status = 'pr_merged'""", ), ( "j.finished_at", f"""-- the job's coder: a job that opened a pull request, once it finished -SELECT 2, j.job_id, 'opened', {coder_sql("jt")}, j.finished_at, NULL, NULL, NULL -FROM jobs j {template_join("j", "jt")} WHERE j.pr_url IS NOT NULL AND j.finished_at IS NOT NULL""", +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)", 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', {coder_sql("jt")}, COALESCE(j.delivery_state_at, j.finished_at), +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 {template_join("j", "jt")} WHERE j.status = 'pr_merged' AND {coder_sql("jt")} = {_literal(SLOPMASTER)}""", +FROM jobs j WHERE j.status = 'pr_merged' AND {a_cleanup("j")}""", ), ( "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("mt")}, COALESCE(m.delivery_state_at, m.finished_at), +SELECT 4, m.job_id, 'comeback', {coder_sql("m")}, COALESCE(m.delivery_state_at, m.finished_at), NULL, NULL, NULL -FROM jobs m {template_join("m", "mt")} +FROM jobs m WHERE m.status = 'pr_merged' AND m.issue_id IS NOT NULL AND EXISTS ( SELECT 1 FROM jobs f @@ -152,10 +146,9 @@ def finished_well(job: str) -> str: ), ( "j.finished_at", - f"""-- the audit template's leader (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', jt.leader_agent, j.finished_at, j.result_summary, NULL, NULL -FROM jobs j {template_join("j", "jt")} WHERE {an_audit("j")} AND {finished_well("j")}""", + 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")}""", ), ( "a.created_at", @@ -245,6 +238,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 @@ -311,8 +305,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 @@ -525,11 +519,13 @@ class JobRow: delivery_state_at: datetime | None = None #: Who asked for it (``jobs.dispatched_by``): :data:`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 self.leader == AUDITOR and self.dispatched_by != FIX_ACTOR @property def merged_at(self) -> datetime | None: @@ -562,6 +558,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/world.py b/embry0/storage/repositories/world.py index 0460d3c6..4d77e335 100644 --- a/embry0/storage/repositories/world.py +++ b/embry0/storage/repositories/world.py @@ -10,9 +10,8 @@ from collections.abc import Sequence from typing import Any -from embry0.services.slop import SLOPMASTER from embry0.storage.database import DatabasePool -from embry0.storage.repositories.progression import coder_sql, template_join +from embry0.storage.repositories.progression import a_cleanup #: Job statuses a party is still out on. LIVE_STATUSES = ("pending", "running", "awaiting_input", "paused") @@ -51,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)) @@ -86,12 +85,12 @@ # 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. -# A slop cleanup is a job the Slopmaster coded: its template's leader (#167, -# progression.coder_sql). +# 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 {template_join("j", "jt")} -WHERE {coder_sql("jt")} = '{SLOPMASTER}' AND j.repo IS NOT NULL +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 @@ -145,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/pipeline_templates_builtin.py b/embry0/storage/seeds/pipeline_templates_builtin.py index 994ef0fc..ac82608d 100644 --- a/embry0/storage/seeds/pipeline_templates_builtin.py +++ b/embry0/storage/seeds/pipeline_templates_builtin.py @@ -65,14 +65,14 @@ def _specialist_route(name: str, agent_type: str, description: str, *, delivers: No ``default_for_kind``, and triage is not offered it: a job reaches it only by naming the template, and then skips triage (``fixed_route``). - plan_route records it as an analysis job (``job_kind``), and the world - draws it as its specialist (``leader_agent``). + 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, - "job_kind": "analysis", "leader_agent": agent_type, "delivers": delivers, "graph_definition": _graph( diff --git a/embry0/workflows/issue_to_pr/plan_route.py b/embry0/workflows/issue_to_pr/plan_route.py index d5a1d66d..68e48892 100644 --- a/embry0/workflows/issue_to_pr/plan_route.py +++ b/embry0/workflows/issue_to_pr/plan_route.py @@ -32,7 +32,7 @@ coding_agent_of, triage_template_config, ) -from embry0.workflows.row_behaviour import builtin_template, offered_to_triage, recorded_kind +from embry0.workflows.row_behaviour import builtin_template, leader, offered_to_triage, recorded_kind logger = structlog.get_logger(__name__) @@ -265,28 +265,42 @@ async def _find_template( 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. - Without a repository, or when it cannot be read, the built-in template as - seeded answers (``row_behaviour.builtin_template``): every deployment has - those rows. A lookup failure is logged. + 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. """ if not name or name in _LEGACY_TEMPLATE_VALUES: return None if templates_repo is None: return builtin_template(name) - try: - return await _find_template(templates_repo, name=name) - except Exception: - logger.warning("template_lookup_failed", template=name, exc_info=True) - 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``). + 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. """ - return await template_row(templates_repo, str(state.get("pipeline_template_request") or "")) + name = str(state.get("pipeline_template_request") or "") + try: + return await template_row(templates_repo, name) + except Exception: + 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: @@ -375,11 +389,7 @@ async def plan_route_node(state: dict[str, Any], config: RunnableConfig) -> dict # 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 offered_to_triage(await template_row(templates_repo, requested)) - ): + 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"), @@ -407,6 +417,7 @@ async def plan_route_node(state: dict[str, Any], config: RunnableConfig) -> dict 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: @@ -438,6 +449,7 @@ async def plan_route_node(state: dict[str, Any], config: RunnableConfig) -> dict resolved_name = row["name"] resolved_kind = recorded_kind(row) resolved_graph = graph + resolved_row = row break if plan is None: @@ -479,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 {}), @@ -528,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/row_behaviour.py b/embry0/workflows/row_behaviour.py index ed6eca04..21df6617 100644 --- a/embry0/workflows/row_behaviour.py +++ b/embry0/workflows/row_behaviour.py @@ -9,21 +9,25 @@ - ``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 when the - template is no kind's default (``default_for_kind`` wins); +- ``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, and the one its outcomes are credited to; - a pull request is its leader's only when the leader is a coding agent - (:func:`coder`). + 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, as for the developer), a name that template. + 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 @@ -32,6 +36,10 @@ 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 @@ -52,10 +60,10 @@ #: 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 a job's -#: pull request is credited to one of them (:func:`coder`). +#: 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 whose template's leader is no coding agent. +#: 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. @@ -99,10 +107,25 @@ def offered_to_triage(template: Mapping[str, Any] | None) -> bool: 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``.""" + """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 {} - return str(row.get("default_for_kind") or row.get("job_kind") or "") or None + 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: @@ -115,13 +138,6 @@ def leader(template: Mapping[str, Any] | None) -> str | None: return str(_column(template, "leader_agent", TEMPLATE_DEFAULTS) or "") or None -def coder(template: Mapping[str, Any] | None) -> str: - """The character a job's pull request is credited to: its template's leader when that is a coding agent, - else the developer. ``embry0.storage.repositories.progression.coder_sql`` is the same rule in SQL.""" - agent = leader(template) - return agent if agent in CODING_AGENT_TYPES else DEFAULT_CODER - - # --- what an agent's row says ------------------------------------------------------------------------------ @@ -174,7 +190,7 @@ def checked_delivers(value: Any) -> str: text = value.strip() if len(text) > DELIVERS_MAX: raise ValueError(f"delivers must be at most {DELIVERS_MAX} characters; got {len(text)}") - if any(ch in text for ch in "\r\n") or any(ord(ch) < 32 for ch in text): + if any(ord(ch) < 32 for ch in text): raise ValueError("delivers must be one line of text") return text @@ -217,6 +233,28 @@ def leader_problem(value: str | None, agent_types: Collection[str] | None) -> st 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).""" @@ -240,8 +278,8 @@ def pipelines_by_name(templates: Iterable[Mapping[str, Any]]) -> dict[str, Mappi 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 (or when it cannot be read): the rows - every deployment has. + 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 diff --git a/frontend/src/lib/types/agents.ts b/frontend/src/lib/types/agents.ts index b087de8e..f883bc93 100644 --- a/frontend/src/lib/types/agents.ts +++ b/frontend/src/lib/types/agents.ts @@ -60,9 +60,11 @@ export interface AgentDefinition { triage_may_add_skills?: string[]; /** Where the guild hall draws it (#167). Absent on a backend that predates it. */ station?: string; - /** The task the guild can give it: null none, "" a task naming no template (triage picks), else that - * template (#167). Absent on a backend that predates it. */ + /** The task the guild can give it: null none, "" a task naming no template (triage picks; a coding + * agent's only), else that template (#167). Absent on a backend that predates it. */ quest_template?: string | null; + /** Read-only: whether the developer node runs it, so a guild task naming no template is its to take. */ + coding_agent?: boolean; /** Write responses only: skills accepted from a source that is not ready, and what a job runs without. */ warnings?: string[]; created_at?: string; diff --git a/frontend/src/pages/AgentFormPage.tsx b/frontend/src/pages/AgentFormPage.tsx index 5c5104a0..2cd9eab6 100644 --- a/frontend/src/pages/AgentFormPage.tsx +++ b/frontend/src/pages/AgentFormPage.tsx @@ -531,7 +531,10 @@ export function AgentFormPage() { className="mt-1" > - + {/* A task naming no template runs the route triage picks, a coding agent's (#167). */} + {(existing?.coding_agent || form.quest === TRIAGE_PICKS) && ( + + )} {[...new Set([...(templates ?? []).map((t) => t.name), ...(questTemplateOf(form.quest) ? [form.quest] : [])])].map( (name) => (