diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 30e11a8b..d44d8eee 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -9,7 +9,7 @@ "source": { "source": "url", "url": "https://github.com/avksp/agent-lifecycle-kit.git", - "ref": "v1.52.0" + "ref": "v1.53.0" }, "policy": { "installation": "AVAILABLE", diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3c2c2772..dbb0f2ff 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,10 +9,10 @@ "source": { "source": "github", "repo": "avksp/agent-lifecycle-kit", - "ref": "v1.52.0" + "ref": "v1.53.0" }, "description": "Reviewed SDD planning, budgeted execution, adapter conformance, implementation audit, and final proof.", - "version": "1.52.0", + "version": "1.53.0", "author": { "name": "Agent Lifecycle Kit contributors" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 881acbe9..967b0ea6 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-lifecycle-kit", - "version": "1.52.0", + "version": "1.53.0", "description": "Provider-neutral lifecycle kit for reviewed SDD planning, budgeted execution, adapter conformance, independent audits, and final proof.", "author": { "name": "Agent Lifecycle Kit contributors" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index dea48a4d..1e15892e 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-lifecycle-kit", - "version": "1.52.0", + "version": "1.53.0", "description": "Provider-neutral lifecycle kit for reviewed SDD planning, budgeted execution, adapter conformance, independent audits, and final proof.", "author": { "name": "Agent Lifecycle Kit contributors", diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 89c053fc..0f160876 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -5,14 +5,14 @@ }, "metadata": { "description": "Provider-neutral lifecycle kit for reviewed SDD planning, budgeted execution, adapter conformance, independent audits, and final proof.", - "version": "1.52.0" + "version": "1.53.0" }, "plugins": [ { "name": "agent-lifecycle-kit", "source": ".", "description": "Reviewed SDD planning, budgeted execution, adapter conformance, implementation audit, and final proof.", - "version": "1.52.0", + "version": "1.53.0", "author": { "name": "Agent Lifecycle Kit contributors" }, diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 5ac141f8..7491f273 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-lifecycle-kit", - "version": "1.52.0", + "version": "1.53.0", "description": "Provider-neutral lifecycle kit for reviewed SDD planning, budgeted execution, adapter conformance, independent audits, and final proof.", "author": { "name": "Agent Lifecycle Kit contributors" diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e6d4d24..cee2f01c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ - No changes yet. +## 1.53.0 - 2026-08-09 + +- Added deterministic risk-aware execution profiles that derive a + provider-neutral model route and token, invocation, and wall-time caps from a + frozen S0/S1/S2 plan without model, network, or host calls. +- Added a read-only `start --risk --risk-profile-out` projection followed by + explicit `workflow task-start --risk-profile` authorization with strict run, + plan, task, source, operation, adapter, route, and digest lineage. +- Added fail-closed host usage checks for billable tokens, invocations, and wall + time, while preserving advisory-only risk recommendations for raw task input. +- Added English and Russian operator guidance and updated package, plugin, + marketplace, and quickstart publication metadata to `1.53.0`. + ## 1.52.0 - 2026-08-09 - Added `agent-lifecycle start` as one beginner-facing command for task text, diff --git a/README.md b/README.md index e6f58556..494553ab 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ agent-lifecycle start --adapter codex --text "Draft a reviewed implementation pl ``` The official [PyPI package](https://pypi.org/project/agent-lifecycle-kit/) supports Python 3.11-3.14. -Install the exact release with `python -m pip install agent-lifecycle-kit==1.52.0`. +Install the exact release with `python -m pip install agent-lifecycle-kit==1.53.0`. For a short walkthrough, use [Quickstart](docs/guides/quickstart.md) and the [Lifecycle cookbook](docs/guides/lifecycle-cookbook.md). For structure and positioning, see [System architecture](docs/architecture/system-architecture.md) and [Project comparison](docs/reference/project-comparison.md). Russian documentation starts at [Документация на русском](docs/ru/README.md). diff --git a/adapters/claude/.claude-plugin/plugin.json b/adapters/claude/.claude-plugin/plugin.json index 3a0871be..5b7cc6c9 100644 --- a/adapters/claude/.claude-plugin/plugin.json +++ b/adapters/claude/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-lifecycle-kit", - "version": "1.52.0", + "version": "1.53.0", "description": "Lifecycle skills and adapter metadata for reviewed planning, budgeted execution, adapter conformance, audit, and final proof.", "author": { "name": "Agent Lifecycle Kit contributors" diff --git a/adapters/codex/.codex-plugin/plugin.json b/adapters/codex/.codex-plugin/plugin.json index dd197a5f..08c4f973 100644 --- a/adapters/codex/.codex-plugin/plugin.json +++ b/adapters/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-lifecycle-kit", - "version": "1.52.0", + "version": "1.53.0", "description": "Provider-neutral lifecycle kit and adapter metadata for reviewed planning, budgeted execution, adapter conformance, audit, and final proof.", "author": { "name": "Agent Lifecycle Kit contributors" diff --git a/adapters/cursor/.cursor-plugin/plugin.json b/adapters/cursor/.cursor-plugin/plugin.json index 65b7b172..c0f603a9 100644 --- a/adapters/cursor/.cursor-plugin/plugin.json +++ b/adapters/cursor/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-lifecycle-kit", - "version": "1.52.0", + "version": "1.53.0", "description": "Agent lifecycle planning, budgeted execution, adapter conformance, audit, and final proof for Cursor.", "author": { "name": "Agent Lifecycle Kit contributors" diff --git a/docs/README.md b/docs/README.md index 10add2ff..c1ed8a68 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,7 @@ project. ## Quick start - Official PyPI package for Python 3.11-3.14: - `python -m pip install agent-lifecycle-kit==1.52.0` from + `python -m pip install agent-lifecycle-kit==1.53.0` from [agent-lifecycle-kit](https://pypi.org/project/agent-lifecycle-kit/). - One safe entrypoint: `agent-lifecycle start --adapter codex --file task.md`. - [Quickstart](guides/quickstart.md) diff --git a/docs/guides/lifecycle-cookbook.md b/docs/guides/lifecycle-cookbook.md index a4890baa..5657013b 100644 --- a/docs/guides/lifecycle-cookbook.md +++ b/docs/guides/lifecycle-cookbook.md @@ -17,6 +17,7 @@ execution. | Find or fix a bug | [Bug Forensics repair](#bug-forensics-repair) | No, after a frozen plan authorizes repair | | Ask several reviewers | [Coordinate cross-review](#coordinate-cross-review) | Yes, unless a frozen plan requires quorum | | Inspect an active run | [View goal and progress](#view-goal-and-progress) | Yes | +| Start a frozen task with bounded resources | [Run a risk-aware task](#run-a-risk-aware-task) | No, it authorizes one task attempt | ## Research and planning only @@ -125,6 +126,45 @@ responsibilities first, then review the diff against that recovered map. Full examples are in [Code review workflows](code-review-workflows.md). +## Run a risk-aware task + +Use this only after the plan is reviewed and frozen. First project the exact +profile without mutating workflow state: + +```bash +agent-lifecycle start \ + --adapter codex \ + --mode implement \ + --risk auto \ + --file tasks/my-release/plan.manifest.json \ + --state work/my-release/run.state.json \ + --lock tasks/my-release/plan.lock.json \ + --task WS-01 \ + --operation-id start-WS-01-attempt-1 \ + --expected-revision 3 \ + --source-revision "$(git rev-parse HEAD)" \ + --host-model-profile profiles/hosts/codex-live-profile.v1.json \ + --risk-profile-out work/my-release/WS-01/risk-profile.json +``` + +Then authorize the attempt with the same operation id: + +```bash +agent-lifecycle workflow task-start \ + --state work/my-release/run.state.json \ + --task WS-01 \ + --operation-id start-WS-01-attempt-1 \ + --expected-revision 3 \ + --source-revision "$(git rev-parse HEAD)" \ + --risk-profile work/my-release/WS-01/risk-profile.json \ + --reason "start risk-aware attempt" +``` + +The host must later attest tokens, invocation count and wall time. Any missing, +estimated, lineage-drifted or over-cap value blocks the result transition. See +[Risk-aware execution](../reference/risk-aware-execution.md) for all inputs and +failure rules. + ## Audit implementation evidence Use implementation audit after a worker has produced a task result and review: diff --git a/docs/guides/quickstart.md b/docs/guides/quickstart.md index 919240c7..bb94cfe9 100644 --- a/docs/guides/quickstart.md +++ b/docs/guides/quickstart.md @@ -28,7 +28,7 @@ supports Python 3.11-3.14. When the package is available for the requested version, install the exact semantic version: ```bash -python -m pip install agent-lifecycle-kit==1.52.0 +python -m pip install agent-lifecycle-kit==1.53.0 agent-lifecycle version ``` @@ -133,6 +133,13 @@ agent-lifecycle start \ --file work/run/adapter-run-request.json ``` +For a frozen task, add `--risk auto` to derive a provider-neutral model route +and resource caps. The read-only start step writes the exact profile with +`--risk-profile-out`; authorize it separately with `workflow task-start +--risk-profile`. See [Risk-aware execution](../reference/risk-aware-execution.md) +for the complete sequence. On raw text or Markdown, `--risk` remains advisory +and never authorizes implementation. + To resume a session recorded by ALK: ```bash diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f861aa05..3f31766f 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -10,7 +10,7 @@ Python 3.11-3.14 is supported. Install the exact release from the official [PyPI project](https://pypi.org/project/agent-lifecycle-kit/): ```bash -python -m pip install agent-lifecycle-kit==1.52.0 +python -m pip install agent-lifecycle-kit==1.53.0 ``` ## Foundation diff --git a/docs/reference/managed-adapter-sessions.md b/docs/reference/managed-adapter-sessions.md index a6e1d42e..796d5a68 100644 --- a/docs/reference/managed-adapter-sessions.md +++ b/docs/reference/managed-adapter-sessions.md @@ -74,6 +74,20 @@ ALK-managed paths. JSON stdout remains stable. Use `--progress-hook off` to suppress terminal output, or `--progress-hook receipt --progress-receipt ` to persist `agent-progress-hook-receipt.v1`. +## Risk-aware managed execution + +For a frozen S1/S2 task, `start --risk auto` can derive a provider-neutral +model route and token, invocation, and wall-time caps. This is a two-step +authorization: `start --risk-profile-out ` only projects a read-only, +digest-bound profile; `workflow task-start --risk-profile ` validates and +stores that profile before the attempt begins. The later `workflow task-result` +must provide host-attested usage, including `usage.invocations`. + +Raw text and Markdown remain draft-only: `--risk` is advisory there and cannot +enable execution or a usage gate. See [Risk-aware +execution](risk-aware-execution.md) for the full command sequence and failure +rules. + ## Launch profile The adapter descriptor field `managedLaunch` declares one of the following diff --git a/docs/reference/model-routing.md b/docs/reference/model-routing.md index ad28e48c..ba89b163 100644 --- a/docs/reference/model-routing.md +++ b/docs/reference/model-routing.md @@ -160,6 +160,12 @@ Model-backed operations must produce ceilings. In workflow execution, missing, unattested or lineage-drifted usage blocks `workflow task-result` before a task can enter review. +Risk-aware attempts add `usage.invocations` to the same host-attested receipt. +The bound risk profile checks `billableTokens`, `invocations`, and +`wallSeconds`; exceeding any cap rejects `workflow task-result`. This additive +field is required only when `workflow task-start --risk-profile ` has +authorized the attempt. See [Risk-aware execution](risk-aware-execution.md). + ## Scope boundaries The v1 implementation enforces route receipts without becoming a provider diff --git a/docs/reference/public-contracts.md b/docs/reference/public-contracts.md index 529a9bf3..8ec7cabc 100644 --- a/docs/reference/public-contracts.md +++ b/docs/reference/public-contracts.md @@ -44,6 +44,7 @@ Stable schema ids: - `agent-runner-snapshot.v1` - `agent-managed-lifecycle-next-action.v1` - `agent-managed-lifecycle-runner-receipt.v1` +- `agent-lifecycle-start-receipt.v1` - `agent-adapter-session-receipt.v1` - `agent-managed-adapter-launch-receipt.v1` - `agent-adapter-session-resume-receipt.v1` @@ -82,7 +83,10 @@ Stable schema ids: - `agent-progress-bridge-receipt.v1` - `agent-progress-hook-policy.v1` - `agent-progress-hook-receipt.v1` +- `agent-risk-execution-policy.v1` +- `agent-risk-execution-profile.v1` - `agent-lifecycle-quality-floor-decision.v1` +- `agent-lifecycle-policy-proposal.v1` - `agent-adaptive-lifecycle-policy-request.v1` - `agent-adaptive-lifecycle-policy-decision.v1` - `agent-adaptive-lifecycle-policy-decision-validation.v1` diff --git a/docs/reference/risk-aware-execution.md b/docs/reference/risk-aware-execution.md new file mode 100644 index 00000000..8afd5c3d --- /dev/null +++ b/docs/reference/risk-aware-execution.md @@ -0,0 +1,125 @@ +# Risk-aware execution + +Risk-aware execution turns a frozen plan tier into an explicit model route, +token limit, invocation limit, wall-time limit, and usage-evidence requirement. +It is deterministic and does not call a model or launch an adapter host. +The portable artifact uses the public schema +`agent-risk-execution-profile.v1`. + +## Risk levels + +`--risk auto` uses the frozen plan tier. An explicit `S0`, `S1`, or `S2` value +may tighten the tier but cannot lower it. The order is `S0 < S1 < S2`. + +The default local inputs are: + +- `profiles/risk-execution-policy.v1.json` for invocation and wall-time caps; +- `profiles/model-routing-profile.v1.json` for the provider-neutral model class + and billable-token cap; +- `profiles/lifecycle-baselines.v1.json` for the existing quality floor. + +A real S1/S2 managed run also requires `--host-model-profile`. Concrete model +names remain in that local file. The portable risk profile records only the +adapter id, provider-neutral model class, and binding digests. + +## Managed start + +Use a frozen manifest or a structured run request. This example writes both the +public start receipt and the exact projected risk profile: + +```bash +agent-lifecycle start \ + --adapter codex \ + --mode implement \ + --risk auto \ + --file tasks/my-release/plan.manifest.json \ + --state work/my-release/run.state.json \ + --lock tasks/my-release/plan.lock.json \ + --task WS-01 \ + --operation-id start-WS-01-attempt-1 \ + --expected-revision 3 \ + --source-revision "$(git rev-parse HEAD)" \ + --host-model-profile profiles/hosts/codex-live-profile.v1.json \ + --risk-profile-out work/my-release/WS-01/risk-profile.json \ + --out work/my-release/WS-01/start.json +``` + +`start` remains read-only with respect to workflow state. The returned host +action contains the profile, but the profile has no workflow authority yet and +no model or host process starts. + +## Authorize the task attempt + +Pass the same artifact to the normal task transition. Use the same operation id +that was bound into the projected route: + +```bash +agent-lifecycle workflow task-start \ + --state work/my-release/run.state.json \ + --task WS-01 \ + --operation-id start-WS-01-attempt-1 \ + --expected-revision 3 \ + --source-revision "$(git rev-parse HEAD)" \ + --risk-profile work/my-release/WS-01/risk-profile.json \ + --reason "start risk-aware attempt" +``` + +This transition validates the profile digest and run, plan, task, source, +adapter, risk-tier, and route lineage. Only then does it store `modelRoute`, +`attemptModelRoute`, and the risk caps in mutable task state. Omitting +`--risk-profile` preserves the legacy task-start path, but that path does not +claim risk-aware execution. + +## Usage receipt + +For risk-aware S1/S2 attempts, the host-attested model usage receipt must +include the existing normalized metrics plus additive `usage.invocations`: + +```json +{ + "usage": { + "inputTokens": 1200, + "outputTokens": 300, + "billableTokens": 1500, + "cumulativeContextBytes": 24000, + "toolCalls": 4, + "wallSeconds": 95, + "invocations": 2 + }, + "attestation": { + "source": "host", + "status": "ATTESTED" + } +} +``` + +`workflow task-result` first applies the existing route and attestation checks, +then checks billable tokens, invocations, and wall time against the bound risk +profile. Missing, estimated, lineage-drifted, or over-cap evidence rejects the +transition, so task acceptance remains unreachable. + +## Draft boundary + +For raw text or Markdown, `--risk` is advisory only: + +```bash +agent-lifecycle start \ + --adapter codex \ + --mode plan \ + --risk S2 \ + --file task.md +``` + +The receipt records the requested recommendation but creates no execution +profile, usage gate, model call, host process, or lifecycle-coverage claim. + +## Failure behavior + +The command blocks rather than silently weakening policy when: + +- an explicit risk is lower than the frozen plan tier; +- a required local policy/profile is missing or invalid; +- the host profile does not match the adapter descriptor host; +- S1/S2 has no host model profile; +- plan, task, source, operation, route, or profile digests do not match; +- host usage is missing, estimated, or above any bound cap. diff --git a/docs/ru/README.md b/docs/ru/README.md index 10814140..faab0dfe 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -16,10 +16,10 @@ OpenInterpreter, Pi, Grok Build и других. Ядро не зависит о Главная цель проекта - закрывать задачу полностью, с максимально возможным качеством для выбранной модели, без оверинжиринга и с контролем расхода токенов. -**Лицензия:** Apache-2.0 · **Версия:** 1.52.0 · **Python:** 3.11-3.14 +**Лицензия:** Apache-2.0 · **Версия:** 1.53.0 · **Python:** 3.11-3.14 ## Быстрый старт -Официальный [пакет в PyPI](https://pypi.org/project/agent-lifecycle-kit/) для Python 3.11-3.14: `python -m pip install agent-lifecycle-kit==1.52.0`. +Официальный [пакет в PyPI](https://pypi.org/project/agent-lifecycle-kit/) для Python 3.11-3.14: `python -m pip install agent-lifecycle-kit==1.53.0`. Из исходного дерева: ```bash diff --git a/docs/ru/lifecycle-cookbook.md b/docs/ru/lifecycle-cookbook.md index bcc09cb4..eb5ea64c 100644 --- a/docs/ru/lifecycle-cookbook.md +++ b/docs/ru/lifecycle-cookbook.md @@ -17,6 +17,7 @@ | Найти или исправить ошибку | [Расследование ошибок](#расследование-ошибок) | Нет, если зафиксированный план разрешил исправление | | Подключить нескольких проверяющих | [Согласованная перепроверка](#согласованная-перепроверка) | Да, если зафиксированный план не требует кворум | | Посмотреть активный запуск | [Цель и прогресс](#цель-и-прогресс) | Да | +| Запустить зафиксированную задачу с ограничениями ресурсов | [Запуск с учётом риска](#запуск-с-учётом-риска) | Нет, разрешает одну попытку задачи | ## Исследование и планирование @@ -126,6 +127,46 @@ agent-lifecycle start \ Подробные примеры: [Сценарии проверки кода](code-review-workflows.md). +## Запуск с учётом риска + +Используйте этот сценарий только после проверки и фиксации плана. Сначала +создайте точный профиль без изменения состояния рабочего цикла: + +```bash +agent-lifecycle start \ + --adapter codex \ + --mode implement \ + --risk auto \ + --file tasks/my-release/plan.manifest.json \ + --state work/my-release/run.state.json \ + --lock tasks/my-release/plan.lock.json \ + --task WS-01 \ + --operation-id start-WS-01-attempt-1 \ + --expected-revision 3 \ + --source-revision "$(git rev-parse HEAD)" \ + --host-model-profile profiles/hosts/codex-live-profile.v1.json \ + --risk-profile-out work/my-release/WS-01/risk-profile.json +``` + +Затем разрешите попытку с тем же идентификатором операции: + +```bash +agent-lifecycle workflow task-start \ + --state work/my-release/run.state.json \ + --task WS-01 \ + --operation-id start-WS-01-attempt-1 \ + --expected-revision 3 \ + --source-revision "$(git rev-parse HEAD)" \ + --risk-profile work/my-release/WS-01/risk-profile.json \ + --reason "запуск попытки с учётом риска" +``` + +Позже хост должен подтвердить расход токенов, число обращений и время. +Отсутствующие, расчётные, не совпадающие по происхождению или превышающие +ограничения данные блокируют переход результата. Все входные данные и причины +блокировки описаны в разделе [Запуск с учётом +риска](reference/risk-aware-execution.md). + ## Аудит подтверждений реализации Используйте аудит реализации после того, как исполнитель подготовил результат diff --git a/docs/ru/quickstart.md b/docs/ru/quickstart.md index 1fc319e2..fe107f07 100644 --- a/docs/ru/quickstart.md +++ b/docs/ru/quickstart.md @@ -27,7 +27,7 @@ PYTHONPATH=src python -m agent_lifecycle version устанавливайте точную семантическую версию: ```bash -python -m pip install agent-lifecycle-kit==1.52.0 +python -m pip install agent-lifecycle-kit==1.53.0 agent-lifecycle version ``` @@ -134,6 +134,14 @@ agent-lifecycle start \ --file work/run/adapter-run-request.json ``` +Для зафиксированной задачи добавьте `--risk auto`, чтобы определить нейтральный +класс модели и ограничения ресурсов. Шаг чтения сохраняет точный профиль через +`--risk-profile-out`, после чего его нужно отдельно разрешить командой +`workflow task-start --risk-profile`. Полная последовательность приведена в +разделе [Запуск с учётом риска](reference/risk-aware-execution.md). Для обычного +текста или Markdown параметр `--risk` остаётся рекомендацией и не разрешает +реализацию. + Чтобы возобновить сессию, ранее записанную ALK: ```bash diff --git a/docs/ru/reference/cli.md b/docs/ru/reference/cli.md index 7a06dc32..43478a92 100644 --- a/docs/ru/reference/cli.md +++ b/docs/ru/reference/cli.md @@ -9,7 +9,7 @@ JSON, чтобы результат можно было проверять ав [проекта в PyPI](https://pypi.org/project/agent-lifecycle-kit/): ```bash -python -m pip install agent-lifecycle-kit==1.52.0 +python -m pip install agent-lifecycle-kit==1.53.0 ``` ## Основа diff --git a/docs/ru/reference/managed-adapter-sessions.md b/docs/ru/reference/managed-adapter-sessions.md index 0c876889..f2dea656 100644 --- a/docs/ru/reference/managed-adapter-sessions.md +++ b/docs/ru/reference/managed-adapter-sessions.md @@ -80,6 +80,21 @@ agent-lifecycle adapter run \ receipt --progress-receipt `, чтобы сохранить `agent-progress-hook-receipt.v1`. +## Управляемый запуск с учётом риска + +Для зафиксированной задачи S1/S2 команда `start --risk auto` может определить +нейтральный класс модели и ограничения по токенам, числу обращений и времени. +Разрешение выполняется в два шага: `start --risk-profile-out ` только +создаёт привязанный отпечатками профиль без изменения состояния, а `workflow +task-start --risk-profile ` проверяет и записывает этот профиль перед +началом попытки. Затем `workflow task-result` требует подтверждённые хостом +данные расхода, включая `usage.invocations`. + +Обычный текст и Markdown остаются черновыми: для них `--risk` носит только +рекомендательный характер и не разрешает выполнение или проверку расхода. +Полная последовательность команд и причины блокировки описаны в разделе +[Запуск с учётом риска](risk-aware-execution.md). + ## Профиль запуска Поле `managedLaunch` в дескрипторе адаптера объявляет один из статусов. Это diff --git a/docs/ru/reference/model-routing.md b/docs/ru/reference/model-routing.md index 87ebc98b..1bc1f2dc 100644 --- a/docs/ru/reference/model-routing.md +++ b/docs/ru/reference/model-routing.md @@ -37,3 +37,13 @@ agent-lifecycle model usage-check \ Для операций с моделью нужен `agent-lifecycle-model-usage-receipt.v1`, привязанный к тому же запуску, задаче, попытке, отпечатку плана, исходной ревизии и решению маршрутизации. + +Обычное подтверждение содержит `inputTokens`, `outputTokens`, +`billableTokens`, `cumulativeContextBytes`, `toolCalls` и `wallSeconds`. +Для попытки с учётом риска добавляется `usage.invocations`. Профиль проверяет +`billableTokens`, `invocations` и `wallSeconds`; превышение любого ограничения +отклоняет `workflow task-result`. + +Дополнительное поле обязательно только после разрешения попытки командой +`workflow task-start --risk-profile `. Полный сценарий приведён в разделе +[Запуск с учётом риска](risk-aware-execution.md). diff --git a/docs/ru/reference/public-contracts.md b/docs/ru/reference/public-contracts.md index 50e33163..1c4438fa 100644 --- a/docs/ru/reference/public-contracts.md +++ b/docs/ru/reference/public-contracts.md @@ -17,6 +17,8 @@ хоста без запуска модели. - `agent-managed-lifecycle-runner-receipt.v1`: подтверждение управляемого шага жизненного цикла. +- `agent-lifecycle-start-receipt.v1`: результат единой команды запуска с + черновым или привязанным к плану действием. - `agent-adapter-session-receipt.v1`: подтверждение управляемой или интерактивной сессии адаптера. - `agent-managed-adapter-launch-receipt.v1`: подтверждение запуска адаптера по @@ -72,6 +74,11 @@ управляемых команд ALK. - `agent-progress-hook-receipt.v1`: подтверждение вывода прогресса после управляемой команды без изменения JSON stdout. +- `agent-risk-execution-policy.v1`: локальные пределы числа обращений и времени + по уровню риска. +- `agent-risk-execution-profile.v1`: привязанный к плану и задаче профиль + маршрута, лимитов и обязательных подтверждений расхода. +- `agent-lifecycle-policy-proposal.v1`: предложение по настройке правил. - `agent-proof-finding.v1`: стабильная идентичность находки. - `agent-root-cause-evidence.v1`: подтверждение основной причины. - `agent-fix-impact-receipt.v1`: канонический артефакт влияния исправления. @@ -152,7 +159,6 @@ зелёный после. - `agent-bug-forensics-gate-receipt.v1`: артефакт проверки рабочего цикла. - `agent-bug-forensics-audit.v1`: резюме аудита для проверки bug-forensics. -- `agent-lifecycle-policy-proposal.v1`: предложение по настройке правил. ## Правило совместимости diff --git a/docs/ru/reference/risk-aware-execution.md b/docs/ru/reference/risk-aware-execution.md new file mode 100644 index 00000000..3b77cbfc --- /dev/null +++ b/docs/ru/reference/risk-aware-execution.md @@ -0,0 +1,131 @@ +# Запуск с учётом риска + +Запуск с учётом риска преобразует уровень зафиксированного плана в явный класс +модели, ограничения по токенам, числу обращений и времени, а также в требования +к подтверждению расхода. Расчёт детерминирован: он не вызывает модель и не запускает внешний инструмент. +Переносимый артефакт использует публичную схему +`agent-risk-execution-profile.v1`. + +## Уровни риска + +`--risk auto` использует уровень зафиксированного плана. Явное значение `S0`, +`S1` или `S2` может только усилить требования. Порядок уровней: +`S0 < S1 < S2`. + +По умолчанию используются локальные файлы: + +- `profiles/risk-execution-policy.v1.json` задаёт предел числа обращений и + времени; +- `profiles/model-routing-profile.v1.json` задаёт нейтральный класс модели и + предел оплачиваемых токенов; +- `profiles/lifecycle-baselines.v1.json` задаёт уже существующий нижний порог + качества. + +Для реального управляемого запуска S1/S2 также нужен +`--host-model-profile`. Конкретное имя модели остаётся в этом локальном файле. +В переносимый профиль попадают только идентификатор адаптера, нейтральный класс +модели и отпечатки привязок. + +## Подготовка запуска + +Передайте зафиксированный манифест или структурированный запрос. Команда ниже +запишет общее подтверждение запуска и точный производный профиль риска: + +```bash +agent-lifecycle start \ + --adapter codex \ + --mode implement \ + --risk auto \ + --file tasks/my-release/plan.manifest.json \ + --state work/my-release/run.state.json \ + --lock tasks/my-release/plan.lock.json \ + --task WS-01 \ + --operation-id start-WS-01-attempt-1 \ + --expected-revision 3 \ + --source-revision "$(git rev-parse HEAD)" \ + --host-model-profile profiles/hosts/codex-live-profile.v1.json \ + --risk-profile-out work/my-release/WS-01/risk-profile.json \ + --out work/my-release/WS-01/start.json +``` + +`start` не изменяет состояние рабочего цикла. Возвращённое действие содержит +профиль, но ещё не даёт ему полномочий менять рабочий цикл и не запускает модель +или внешний процесс. + +## Разрешение попытки задачи + +Передайте тот же артефакт обычному переходу задачи. Идентификатор операции +должен совпадать с тем, который был привязан к маршруту: + +```bash +agent-lifecycle workflow task-start \ + --state work/my-release/run.state.json \ + --task WS-01 \ + --operation-id start-WS-01-attempt-1 \ + --expected-revision 3 \ + --source-revision "$(git rev-parse HEAD)" \ + --risk-profile work/my-release/WS-01/risk-profile.json \ + --reason "запуск попытки с учётом риска" +``` + +Переход проверяет отпечаток профиля, запуск, план, задачу, исходную ревизию, +адаптер, уровень риска и маршрут. Только после этого `modelRoute`, +`attemptModelRoute` и ограничения записываются в изменяемое состояние задачи. +Без `--risk-profile` прежний маршрут `task-start` продолжает работать, но не +заявляет запуск с учётом риска. + +## Подтверждение расхода + +Для попытки S1/S2 подтверждение хоста должно содержать существующие +нормализованные показатели и дополнительное поле `usage.invocations`: + +```json +{ + "usage": { + "inputTokens": 1200, + "outputTokens": 300, + "billableTokens": 1500, + "cumulativeContextBytes": 24000, + "toolCalls": 4, + "wallSeconds": 95, + "invocations": 2 + }, + "attestation": { + "source": "host", + "status": "ATTESTED" + } +} +``` + +`workflow task-result` сначала выполняет существующую проверку маршрута и +подтверждения хоста, затем сравнивает токены, число обращений и время с +ограничениями профиля. Отсутствующее, расчётное, не совпадающее по происхождению +или превышающее предел подтверждение отклоняет переход. После этого принять +задачу нельзя. + +## Граница черновика + +Для обычного текста или Markdown-файла `--risk` остаётся рекомендацией: + +```bash +agent-lifecycle start \ + --adapter codex \ + --mode plan \ + --risk S2 \ + --file task.md +``` + +Подтверждение сохраняет запрошенный уровень, но не создаёт исполняемый профиль, +не включает проверку расхода, не вызывает модель и не запускает внешний +процесс. + +## Причины блокировки + +Команда блокирует действие, а не ослабляет требования, если: + +- явный уровень ниже уровня зафиксированного плана; +- обязательный локальный файл отсутствует или неверен; +- хост локального профиля не совпадает с хостом дескриптора адаптера; +- для S1/S2 не передан локальный профиль модели; +- не совпадает план, задача, исходная ревизия, операция, маршрут или отпечаток; +- подтверждение расхода отсутствует, является расчётным или превышает предел. diff --git a/profiles/risk-execution-policy.v1.json b/profiles/risk-execution-policy.v1.json new file mode 100644 index 00000000..2006de3a --- /dev/null +++ b/profiles/risk-execution-policy.v1.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": "agent-risk-execution-policy.v1", + "tiers": { + "S0": { + "budgetClass": "tight", + "maxInvocations": 1, + "maxWallSeconds": 900 + }, + "S1": { + "budgetClass": "normal", + "maxInvocations": 8, + "maxWallSeconds": 1800 + }, + "S2": { + "budgetClass": "normal", + "maxInvocations": 33, + "maxWallSeconds": 3600 + } + }, + "productionPromotionClaimed": false +} diff --git a/pyproject.toml b/pyproject.toml index 7d1746e6..427b9a0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agent-lifecycle-kit" -version = "1.52.0" +version = "1.53.0" description = "Provider-neutral lifecycle control layer that helps coding agents plan, execute, validate, and prove software tasks to completion." readme = "README.md" requires-python = ">=3.11,<3.15" diff --git a/skills/agent-workflow-orchestrator/SKILL.md b/skills/agent-workflow-orchestrator/SKILL.md index a10a103a..4e9a5de9 100644 --- a/skills/agent-workflow-orchestrator/SKILL.md +++ b/skills/agent-workflow-orchestrator/SKILL.md @@ -52,6 +52,14 @@ it must never guess a native host conversation id. Use `adapter task start`, `adapter session start` only to record an interactive `WAITING_FOR_TASK` session. Native host launching remains descriptor-driven and host-owned. +When a frozen task requests risk-aware execution, keep the authorization split. +Run `agent-lifecycle start --risk --risk-profile-out ` to +project the digest-bound route and caps without changing workflow state. Then +pass that exact artifact to `workflow task-start --risk-profile ` with +the same operation and lineage bindings. Do not claim a risk-aware attempt from +raw intake or from `start` alone. Before accepting its result, require +host-attested tokens, `usage.invocations`, and wall time within all bound caps. + For common operator requests, route through the cookbook before dropping to atomic commands: `docs/guides/lifecycle-cookbook.md` covers research-only, planning-only, Markdown plan review, code review, implementation audit and diff --git a/src/agent_lifecycle/_version.py b/src/agent_lifecycle/_version.py index beaab314..ebd23214 100644 --- a/src/agent_lifecycle/_version.py +++ b/src/agent_lifecycle/_version.py @@ -1,3 +1,3 @@ """Package version.""" -__version__ = "1.52.0" +__version__ = "1.53.0" diff --git a/src/agent_lifecycle/adapter_sessions/task_intake.py b/src/agent_lifecycle/adapter_sessions/task_intake.py index 12236a53..5d9566a5 100644 --- a/src/agent_lifecycle/adapter_sessions/task_intake.py +++ b/src/agent_lifecycle/adapter_sessions/task_intake.py @@ -58,6 +58,11 @@ def start_adapter_task( max_input_bytes: int = 32768, target_tokens: int = 4096, package_id: str = "adapter-task-intake", + requested_risk: str | None = None, + risk_policy_path: Path | None = None, + routing_profile_path: Path | None = None, + baseline_profile_path: Path | None = None, + host_model_profile_path: Path | None = None, ) -> dict[str, Any]: """Classify adapter task input without turning raw text into authority.""" @@ -78,6 +83,11 @@ def start_adapter_task( source=_source_summary(source), descriptor_path=descriptor_path, session_root=session_root, + requested_risk=requested_risk, + risk_policy_path=risk_policy_path, + routing_profile_path=routing_profile_path, + baseline_profile_path=baseline_profile_path, + host_model_profile_path=host_model_profile_path, ) if parsed is not None and parsed.get("schemaVersion") == "agent-plan-manifest.v1": if parsed.get("status") == "FROZEN": @@ -101,6 +111,11 @@ def start_adapter_task( operation_id=operation_id, expected_revision=expected_revision, source_revision=source_revision, + requested_risk=requested_risk, + risk_policy_path=risk_policy_path, + routing_profile_path=routing_profile_path, + baseline_profile_path=baseline_profile_path, + host_model_profile_path=host_model_profile_path, ) return _planning_intake( adapter_id=adapter_id, @@ -109,6 +124,7 @@ def start_adapter_task( max_input_bytes=max_input_bytes, target_tokens=target_tokens, package_id=package_id, + requested_risk=requested_risk, ) @@ -137,6 +153,7 @@ def _planning_intake( max_input_bytes: int, target_tokens: int, package_id: str, + requested_risk: str | None, ) -> dict[str, Any]: if source["kind"] == "TEXT": planning = import_planning_text( @@ -178,6 +195,7 @@ def _planning_intake( requires_review=True, audit_required=True, freeze_blocked=True, + risk_advisory=_risk_advisory(requested_risk), ) @@ -188,6 +206,11 @@ def _from_run_request( source: dict[str, Any], descriptor_path: Path | None, session_root: Path | None, + requested_risk: str | None, + risk_policy_path: Path | None, + routing_profile_path: Path | None, + baseline_profile_path: Path | None, + host_model_profile_path: Path | None, ) -> dict[str, Any]: blockers = _run_request_blockers(adapter_id, request) if blockers: @@ -204,6 +227,11 @@ def _from_run_request( operation_id=str(request["operationId"]), expected_revision=int(request["expectedRevision"]), source_revision=str(request["sourceRevision"]), + requested_risk=requested_risk, + risk_policy_path=risk_policy_path, + routing_profile_path=routing_profile_path, + baseline_profile_path=baseline_profile_path, + host_model_profile_path=host_model_profile_path, ) @@ -220,6 +248,11 @@ def _from_frozen_manifest( operation_id: str | None, expected_revision: int | None, source_revision: str | None, + requested_risk: str | None, + risk_policy_path: Path | None, + routing_profile_path: Path | None, + baseline_profile_path: Path | None, + host_model_profile_path: Path | None, ) -> dict[str, Any]: missing = [ name @@ -253,6 +286,11 @@ def _from_frozen_manifest( operation_id=str(operation_id), expected_revision=int(expected_revision or 0), source_revision=str(source_revision), + requested_risk=requested_risk, + risk_policy_path=risk_policy_path, + routing_profile_path=routing_profile_path, + baseline_profile_path=baseline_profile_path, + host_model_profile_path=host_model_profile_path, ) @@ -269,6 +307,11 @@ def _managed_run_receipt( operation_id: str, expected_revision: int, source_revision: str, + requested_risk: str | None, + risk_policy_path: Path | None, + routing_profile_path: Path | None, + baseline_profile_path: Path | None, + host_model_profile_path: Path | None, ) -> dict[str, Any]: try: session_receipt = managed_adapter_run( @@ -282,6 +325,11 @@ def _managed_run_receipt( operation_id=operation_id, expected_revision=expected_revision, source_revision=source_revision, + requested_risk=requested_risk, + risk_policy_path=risk_policy_path, + routing_profile_path=routing_profile_path, + baseline_profile_path=baseline_profile_path, + host_model_profile_path=host_model_profile_path, ) except LifecycleError as exc: return _receipt( @@ -418,6 +466,7 @@ def _receipt( workflow_binding: dict[str, Any] | None = None, review_mesh_recommendation: dict[str, Any] | None = None, bug_forensics_advisory: dict[str, Any] | None = None, + risk_advisory: dict[str, Any] | None = None, ) -> dict[str, Any]: body = { "schemaVersion": ADAPTER_TASK_START_RECEIPT_SCHEMA, @@ -440,6 +489,7 @@ def _receipt( "workflowBinding": workflow_binding, "reviewMeshRecommendation": review_mesh_recommendation, "bugForensicsAdvisory": bug_forensics_advisory, + "riskAdvisory": risk_advisory, "modelCallsStarted": False, "hostLaunchStarted": bool(adapter_session_receipt.get("hostLaunchStarted")) if isinstance(adapter_session_receipt, dict) else False, "secretsWritten": False, @@ -448,3 +498,14 @@ def _receipt( "productionPromotionClaimed": False, } return {**body, "receiptDigest": canonical_digest(body)} + + +def _risk_advisory(requested_risk: str | None) -> dict[str, Any] | None: + if requested_risk is None: + return None + return { + "requestedRisk": requested_risk, + "status": "ADVISORY_ONLY", + "executionProfileCreated": False, + "activeUsageGateClaimed": False, + } diff --git a/src/agent_lifecycle/adapter_sessions/unified_start.py b/src/agent_lifecycle/adapter_sessions/unified_start.py index 28df68e1..4a1f01ed 100644 --- a/src/agent_lifecycle/adapter_sessions/unified_start.py +++ b/src/agent_lifecycle/adapter_sessions/unified_start.py @@ -13,6 +13,7 @@ ) from agent_lifecycle.adapter_sessions.workflow_bridge import resume_adapter_session from agent_lifecycle.contracts import LifecycleError, load_json_object, sha256_hex +from agent_lifecycle.policy.risk_execution import RISK_REQUESTS START_MODES = ("auto", "research", "plan", "review", "implement") _NON_EXECUTING_MODES = frozenset({"auto", "research", "plan", "review"}) @@ -41,6 +42,11 @@ def start_lifecycle( max_input_bytes: int = 32768, target_tokens: int = 4096, package_id: str = "unified-start", + requested_risk: str = "auto", + risk_policy_path: Path | None = None, + routing_profile_path: Path | None = None, + baseline_profile_path: Path | None = None, + host_model_profile_path: Path | None = None, ) -> dict[str, Any]: """Select one existing lifecycle action without creating new authority.""" @@ -48,6 +54,8 @@ def start_lifecycle( return _blocked(adapter_id="", mode=mode, input_summary=_empty_input(), code="start-adapter-required") if mode not in START_MODES: return _blocked(adapter_id=adapter_id, mode="auto", input_summary=_empty_input(), code="start-mode-invalid") + if requested_risk not in RISK_REQUESTS: + return _blocked(adapter_id=adapter_id, mode=mode, input_summary=_empty_input(), code="start-risk-invalid") has_task_source = task_file is not None or task_text is not None if resume_session_id is not None: @@ -101,6 +109,11 @@ def start_lifecycle( max_input_bytes=max_input_bytes, target_tokens=target_tokens, package_id=package_id, + requested_risk=requested_risk, + risk_policy_path=risk_policy_path, + routing_profile_path=routing_profile_path, + baseline_profile_path=baseline_profile_path, + host_model_profile_path=host_model_profile_path, ) if mode in _NON_EXECUTING_MODES and _claims_execution(receipt): return _blocked(adapter_id=adapter_id, mode=mode, input_summary=input_summary, code="start-non-implement-execution-claim") @@ -278,6 +291,7 @@ def _from_task_receipt(*, adapter_id: str, mode: str, receipt: dict[str, Any]) - def _task_summary(receipt: dict[str, Any]) -> dict[str, Any]: planning = receipt.get("planningImport") if isinstance(receipt.get("planningImport"), dict) else {} session = receipt.get("adapterSessionReceipt") if isinstance(receipt.get("adapterSessionReceipt"), dict) else {} + next_action = session.get("nextAction") if isinstance(session.get("nextAction"), dict) else {} recommendation = ( receipt.get("reviewMeshRecommendation") if isinstance(receipt.get("reviewMeshRecommendation"), dict) @@ -291,6 +305,11 @@ def _task_summary(receipt: dict[str, Any]) -> dict[str, Any]: "recommendedQualityProfiles": list(receipt.get("recommendedQualityProfiles", [])), "planningImportDigest": planning.get("importDigest"), "sessionReceiptDigest": session.get("receiptDigest"), + "riskAdvisory": receipt.get("riskAdvisory") if isinstance(receipt.get("riskAdvisory"), dict) else None, + "riskExecutionProfile": next_action.get("riskExecutionProfile") + if isinstance(next_action.get("riskExecutionProfile"), dict) + else None, + "riskProfileRequiredAtTaskStart": bool(next_action.get("riskProfileRequiredAtTaskStart")), "reviewRecommendation": { "recommendedMode": recommendation.get("recommendedMode"), "phaseCoverage": list(recommendation.get("phaseCoverage", [])), diff --git a/src/agent_lifecycle/adapter_sessions/workflow_bridge.py b/src/agent_lifecycle/adapter_sessions/workflow_bridge.py index ec20cb21..bfe1a2b8 100644 --- a/src/agent_lifecycle/adapter_sessions/workflow_bridge.py +++ b/src/agent_lifecycle/adapter_sessions/workflow_bridge.py @@ -8,7 +8,8 @@ from agent_lifecycle.adapter_sessions.contracts import build_adapter_session_receipt, build_resume_receipt from agent_lifecycle.adapter_sessions.launcher import load_adapter_descriptor, managed_launch_profile from agent_lifecycle.adapter_sessions.session_store import create_session, load_session, update_session -from agent_lifecycle.contracts import read_json_object +from agent_lifecycle.contracts import canonical_digest, read_json_object +from agent_lifecycle.policy.risk_execution import derive_risk_execution_profile from agent_lifecycle.workflow import run_managed_lifecycle_step @@ -24,6 +25,11 @@ def managed_adapter_run( source_revision: str, descriptor_path: Path | None = None, session_root: Path | None = None, + requested_risk: str | None = None, + risk_policy_path: Path | None = None, + routing_profile_path: Path | None = None, + baseline_profile_path: Path | None = None, + host_model_profile_path: Path | None = None, ) -> dict[str, Any]: _descriptor_path, descriptor = load_adapter_descriptor(adapter_id, descriptor_path) profile = managed_launch_profile(descriptor) @@ -36,6 +42,34 @@ def managed_adapter_run( source_revision=source_revision, reason=f"managed adapter run for {task_id}", ) + next_action = runner_receipt.get("nextAction") + if runner_receipt["status"] == "PASS" and requested_risk is not None: + risk_profile = derive_risk_execution_profile( + manifest=read_json_object(manifest_path, label="frozen plan manifest"), + state=read_json_object(state_path, label="workflow state"), + task_id=task_id, + adapter_id=adapter_id, + adapter_host=str(descriptor.get("host", "")), + operation_id=operation_id, + source_revision=source_revision, + requested_risk=requested_risk, + risk_policy=read_json_object( + risk_policy_path or Path("profiles/risk-execution-policy.v1.json"), + label="risk execution policy", + ), + routing_profile=read_json_object( + routing_profile_path or Path("profiles/model-routing-profile.v1.json"), + label="model routing profile", + ), + baseline_profile=read_json_object( + baseline_profile_path or Path("profiles/lifecycle-baselines.v1.json"), + label="lifecycle baseline profile", + ), + host_profile=read_json_object(host_model_profile_path, label="host model profile") + if host_model_profile_path is not None + else None, + ) + next_action = _risk_aware_next_action(next_action, risk_profile) state_identity = {**_state_identity(state_path), "taskId": task_id} proof = _managed_proof("adapter run", adapter_id=adapter_id, task_id=task_id, state_identity=state_identity) session = create_session( @@ -58,7 +92,7 @@ def managed_adapter_run( progress_hook_default="stderr", host_launch_started=False, blockers=runner_receipt.get("blockers", []), - next_action=runner_receipt.get("nextAction"), + next_action=next_action, ) @@ -152,3 +186,17 @@ def _managed_proof(command: str, *, adapter_id: str, task_id: str, state_identit "taskId": task_id, "stateIdentity": state_identity, } + + +def _risk_aware_next_action(next_action: Any, profile: dict[str, Any]) -> dict[str, Any]: + if not isinstance(next_action, dict): + next_action = {} + body = { + key: value + for key, value in next_action.items() + if key != "actionDigest" + } + body["riskExecutionProfile"] = profile + body["riskProfileRequiredAtTaskStart"] = True + body["stateMutationRequired"] = True + return {**body, "actionDigest": canonical_digest(body)} diff --git a/src/agent_lifecycle/cli/dispatch_lifecycle.py b/src/agent_lifecycle/cli/dispatch_lifecycle.py index 0fb40abd..3b897f1c 100644 --- a/src/agent_lifecycle/cli/dispatch_lifecycle.py +++ b/src/agent_lifecycle/cli/dispatch_lifecycle.py @@ -145,6 +145,7 @@ def _dispatch_workflow_task(args: argparse.Namespace, state_path: Path) -> dict[ operation_id=args.operation_id, expected_revision=args.expected_revision, source_revision=args.source_revision, + risk_profile_path=args.risk_profile, reason=args.reason, ) if args.workflow_command == "task-result": diff --git a/src/agent_lifecycle/cli/parsers.py b/src/agent_lifecycle/cli/parsers.py index 879d7c84..338efcd3 100644 --- a/src/agent_lifecycle/cli/parsers.py +++ b/src/agent_lifecycle/cli/parsers.py @@ -57,6 +57,12 @@ def _add_start_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentPa action.add_argument("--text", "--task-text", dest="task_text") action.add_argument("--resume", dest="resume_session_id") start.add_argument("--mode", choices=list(START_MODES), default="auto") + start.add_argument("--risk", choices=["auto", "S0", "S1", "S2"], default="auto") + start.add_argument("--risk-policy", default="profiles/risk-execution-policy.v1.json") + start.add_argument("--routing-profile", default="profiles/model-routing-profile.v1.json") + start.add_argument("--baseline-profile", default="profiles/lifecycle-baselines.v1.json") + start.add_argument("--host-model-profile") + start.add_argument("--risk-profile-out") start.add_argument("--descriptor") start.add_argument("--session-root") start.add_argument("--state") @@ -369,6 +375,7 @@ def _add_workflow_parser(subparsers: argparse._SubParsersAction[argparse.Argumen workflow_task.add_argument("--operation-id", required=True) workflow_task.add_argument("--expected-revision", required=True, type=int) workflow_task.add_argument("--source-revision", required=True) + workflow_task.add_argument("--risk-profile") workflow_task.add_argument("--reason", required=True) workflow_result = workflow_sub.add_parser("task-result") workflow_result.add_argument("--state", required=True) diff --git a/src/agent_lifecycle/cli/start.py b/src/agent_lifecycle/cli/start.py index 995d4579..76cf293e 100644 --- a/src/agent_lifecycle/cli/start.py +++ b/src/agent_lifecycle/cli/start.py @@ -33,7 +33,28 @@ def dispatch_start(args: argparse.Namespace, remainder: list[str]) -> dict[str, max_input_bytes=args.max_input_bytes, target_tokens=args.target_tokens, package_id=args.package_id, + requested_risk=args.risk, + risk_policy_path=Path(args.risk_policy), + routing_profile_path=Path(args.routing_profile), + baseline_profile_path=Path(args.baseline_profile), + host_model_profile_path=Path(args.host_model_profile) if args.host_model_profile else None, ) + if args.risk_profile_out: + profile = _risk_profile(payload) + if profile is None: + raise LifecycleError( + "start-risk-profile-unavailable", + "--risk-profile-out requires a successful managed implement projection", + ) + write_json_create(Path(args.risk_profile_out), profile) if args.out: write_json_create(Path(args.out), payload) return payload + + +def _risk_profile(payload: dict[str, Any]) -> dict[str, Any] | None: + delegate = payload.get("delegate") + if not isinstance(delegate, dict): + return None + profile = delegate.get("riskExecutionProfile") + return profile if isinstance(profile, dict) else None diff --git a/src/agent_lifecycle/contracts/schemas.py b/src/agent_lifecycle/contracts/schemas.py index 7d207150..be4b0766 100644 --- a/src/agent_lifecycle/contracts/schemas.py +++ b/src/agent_lifecycle/contracts/schemas.py @@ -84,6 +84,69 @@ ) } +RISK_EXECUTION_SCHEMAS: dict[str, dict[str, Any]] = { + "agent-risk-execution-policy.v1": open_object_schema( + "agent-risk-execution-policy.v1", + required=["schemaVersion", "tiers", "productionPromotionClaimed"], + properties={ + "tiers": {"type": "object"}, + "productionPromotionClaimed": {"const": False}, + }, + ), + "agent-risk-execution-profile.v1": open_object_schema( + "agent-risk-execution-profile.v1", + required=[ + "schemaVersion", + "status", + "requestedRisk", + "planRiskTier", + "resolvedRiskTier", + "adapterId", + "operationId", + "runId", + "packageId", + "planRevision", + "planDigest", + "taskId", + "sourceRevision", + "qualityFloorDecision", + "modelRoute", + "resourceCaps", + "usageEvidence", + "policyDigest", + "blockers", + "modelCallsStarted", + "hostLaunchStarted", + "productionPromotionClaimed", + "profileDigest", + ], + properties={ + "status": {"enum": ["PASS", "BLOCKED"]}, + "requestedRisk": {"enum": ["auto", "S0", "S1", "S2"]}, + "planRiskTier": {"enum": ["S0", "S1", "S2"]}, + "resolvedRiskTier": {"enum": ["S0", "S1", "S2"]}, + "adapterId": {"type": "string", "minLength": 1}, + "operationId": {"type": "string", "minLength": 1}, + "runId": {"type": "string", "minLength": 1}, + "packageId": {"type": "string", "minLength": 1}, + "planRevision": {"type": "integer", "minimum": 1}, + "planDigest": {"type": "string", "minLength": 64, "maxLength": 64}, + "taskId": {"type": "string", "minLength": 1}, + "sourceRevision": {"type": "string", "minLength": 1}, + "qualityFloorDecision": {"type": "object"}, + "modelRoute": {"type": "object"}, + "resourceCaps": {"type": "object"}, + "usageEvidence": {"type": "object"}, + "policyDigest": {"type": "string", "minLength": 64, "maxLength": 64}, + "blockers": {"type": "array", "items": {"type": "object"}}, + "modelCallsStarted": {"const": False}, + "hostLaunchStarted": {"const": False}, + "productionPromotionClaimed": {"const": False}, + "profileDigest": {"type": "string", "minLength": 64, "maxLength": 64}, + }, + ), +} + _SCHEMA_GROUPS = ( CORE_SCHEMAS, AUDIT_SCHEMAS, @@ -114,6 +177,7 @@ METRIC_SCHEMAS, POLICY_SCHEMAS, LIFECYCLE_START_SCHEMAS, + RISK_EXECUTION_SCHEMAS, ) _SCHEMAS: dict[str, dict[str, Any]] = {} diff --git a/src/agent_lifecycle/policy/__init__.py b/src/agent_lifecycle/policy/__init__.py index d029f5ae..a2a19900 100644 --- a/src/agent_lifecycle/policy/__init__.py +++ b/src/agent_lifecycle/policy/__init__.py @@ -18,7 +18,6 @@ require_runtime_policy_receipt_pass, validate_runtime_policy_receipt, ) - __all__ = [ "apply_policy_proposal", "build_adaptive_lifecycle_decision", diff --git a/src/agent_lifecycle/policy/risk_execution.py b/src/agent_lifecycle/policy/risk_execution.py new file mode 100644 index 00000000..7befc55f --- /dev/null +++ b/src/agent_lifecycle/policy/risk_execution.py @@ -0,0 +1,242 @@ +"""Deterministic risk-tier execution profiles for frozen workflow tasks.""" + +from __future__ import annotations + +from typing import Any + +from agent_lifecycle.contracts import LifecycleError, canonical_digest +from agent_lifecycle.policy.quality_floor import resolve_quality_floor + +RISK_EXECUTION_POLICY_SCHEMA = "agent-risk-execution-policy.v1" +RISK_EXECUTION_PROFILE_SCHEMA = "agent-risk-execution-profile.v1" +RISK_TIERS = ("S0", "S1", "S2") +RISK_REQUESTS = ("auto", *RISK_TIERS) + + +def validate_risk_execution_policy(policy: dict[str, Any]) -> dict[str, Any]: + """Validate the versioned local policy that supplies non-token caps.""" + + blockers: list[dict[str, Any]] = [] + if policy.get("schemaVersion") != RISK_EXECUTION_POLICY_SCHEMA: + blockers.append({"code": "risk-policy-schema-unsupported"}) + tiers = policy.get("tiers") + if not isinstance(tiers, dict): + blockers.append({"code": "risk-policy-tiers-missing"}) + tiers = {} + for tier in RISK_TIERS: + config = tiers.get(tier) + if not isinstance(config, dict): + blockers.append({"code": "risk-policy-tier-missing", "tier": tier}) + continue + if not isinstance(config.get("budgetClass"), str) or not config["budgetClass"]: + blockers.append({"code": "risk-policy-budget-class-invalid", "tier": tier}) + for field in ("maxInvocations", "maxWallSeconds"): + value = config.get(field) + if not isinstance(value, int) or isinstance(value, bool) or value < 1: + blockers.append({"code": "risk-policy-cap-invalid", "tier": tier, "field": field}) + if policy.get("productionPromotionClaimed") is not False: + blockers.append({"code": "risk-policy-production-claim"}) + body = { + "schemaVersion": "agent-risk-execution-policy-validation.v1", + "status": "PASS" if not blockers else "FAIL", + "policyDigest": canonical_digest(policy), + "blockers": blockers, + } + return {**body, "validationDigest": canonical_digest(body)} + + +def resolve_risk_tier(plan_tier: str, requested_risk: str) -> str: + """Resolve auto or a tightening override without allowing downgrade.""" + + if plan_tier not in RISK_TIERS: + raise LifecycleError("risk-plan-tier-invalid", "frozen plan tier is unsupported", {"tier": plan_tier}) + if requested_risk not in RISK_REQUESTS: + raise LifecycleError("risk-request-invalid", "requested risk is unsupported", {"risk": requested_risk}) + resolved = plan_tier if requested_risk == "auto" else requested_risk + if RISK_TIERS.index(resolved) < RISK_TIERS.index(plan_tier): + raise LifecycleError( + "risk-tier-downgrade", + "requested risk cannot be lower than the frozen plan tier", + {"planTier": plan_tier, "requestedRisk": requested_risk}, + ) + return resolved + + +def derive_risk_execution_profile( + *, + manifest: dict[str, Any], + state: dict[str, Any], + task_id: str, + adapter_id: str, + adapter_host: str, + operation_id: str, + source_revision: str, + requested_risk: str, + risk_policy: dict[str, Any], + routing_profile: dict[str, Any], + baseline_profile: dict[str, Any], + host_profile: dict[str, Any] | None, +) -> dict[str, Any]: + """Build a provider-neutral profile without changing workflow state.""" + + # Keep model-routing imports lazy: its resolver imports quality-floor policy, + # so importing it while the policy package is initializing creates a cycle. + from agent_lifecycle.model_routing import ( + resolve_model_route, + validate_host_model_profile, + validate_model_routing_profile, + ) + + _require_frozen_manifest(manifest) + _require_state_lineage(manifest, state, source_revision=source_revision) + _require_task(manifest, state, task_id) + policy_validation = validate_risk_execution_policy(risk_policy) + if policy_validation["status"] != "PASS": + raise LifecycleError("risk-policy-invalid", "risk execution policy is invalid", {"validation": policy_validation}) + validate_model_routing_profile(routing_profile) + + plan_tier = _plan_tier(manifest) + resolved_tier = resolve_risk_tier(plan_tier, requested_risk) + host_validation = None + if host_profile is not None: + host_validation = validate_host_model_profile(host_profile) + if host_validation["host"] != adapter_host: + raise LifecycleError( + "risk-host-profile-mismatch", + "host model profile does not match the adapter descriptor host", + {"expected": adapter_host, "actual": host_validation["host"]}, + ) + if resolved_tier in {"S1", "S2"} and host_validation is None: + raise LifecycleError( + "risk-host-profile-required", + "managed S1/S2 execution requires a host model profile", + {"riskTier": resolved_tier}, + ) + + risk_flags = _risk_flags(manifest) + quality_floor = resolve_quality_floor( + task_shape=_task_shape(manifest), + baseline_profile=baseline_profile, + sdd_tier=resolved_tier, + risk_flags=risk_flags, + ) + if quality_floor["status"] != "PASS": + raise LifecycleError("risk-quality-floor-failed", "quality floor could not be resolved", {"decision": quality_floor}) + tier_policy = risk_policy["tiers"][resolved_tier] + route = resolve_model_route( + { + "schemaVersion": "agent-lifecycle-model-route-request.v1", + "operationId": operation_id, + "phase": "task-implementation", + "sddTier": resolved_tier, + "riskFlags": risk_flags, + "capabilityRequirements": [], + "targetContextWindow": "8k", + "routingPolicy": "balanced", + "budgetClass": tier_policy["budgetClass"], + "lifecycleMode": quality_floor["qualityFloor"], + "qualityFloor": quality_floor["qualityFloor"], + }, + routing_profile, + host_profile=host_profile, + ) + caps = { + "maxBillableTokens": int(route["maxBillableTokens"]), + "maxInvocations": int(tier_policy["maxInvocations"]), + "maxWallSeconds": int(tier_policy["maxWallSeconds"]), + } + package = manifest.get("package") if isinstance(manifest.get("package"), dict) else {} + body = { + "schemaVersion": RISK_EXECUTION_PROFILE_SCHEMA, + "status": "PASS", + "requestedRisk": requested_risk, + "planRiskTier": plan_tier, + "resolvedRiskTier": resolved_tier, + "adapterId": adapter_id, + "operationId": operation_id, + "runId": state["runId"], + "packageId": package.get("id"), + "planRevision": manifest["planRevision"], + "planDigest": canonical_digest(manifest), + "taskId": task_id, + "sourceRevision": source_revision, + "qualityFloorDecision": quality_floor, + "modelRoute": route, + "resourceCaps": caps, + "usageEvidence": { + "required": resolved_tier in {"S1", "S2"} or route.get("requiresUsageReceipt") is True, + "hostAttestationRequired": resolved_tier in {"S1", "S2"}, + "requiredMetrics": ["billableTokens", "invocations", "wallSeconds"], + "estimatesAccepted": False, + }, + "policyDigest": policy_validation["policyDigest"], + "hostProfileDigest": host_validation["profileDigest"] if host_validation else None, + "blockers": [], + "modelCallsStarted": False, + "hostLaunchStarted": False, + "productionPromotionClaimed": False, + } + return {**body, "profileDigest": canonical_digest(body)} + + +def _require_frozen_manifest(manifest: dict[str, Any]) -> None: + if manifest.get("schemaVersion") != "agent-plan-manifest.v1" or manifest.get("status") != "FROZEN": + raise LifecycleError("risk-frozen-plan-required", "risk execution profile requires a FROZEN plan") + + +def _plan_tier(manifest: dict[str, Any]) -> str: + specification = manifest.get("specification") + tier = specification.get("tier") if isinstance(specification, dict) else None + if not isinstance(tier, str): + raise LifecycleError("risk-plan-tier-invalid", "frozen plan tier is missing") + return tier + + +def _risk_flags(manifest: dict[str, Any]) -> dict[str, bool]: + specification = manifest.get("specification") if isinstance(manifest.get("specification"), dict) else {} + request = specification.get("tierResolutionRequest") if isinstance(specification.get("tierResolutionRequest"), dict) else {} + flags = request.get("riskFlags") if isinstance(request.get("riskFlags"), dict) else {} + return {str(key): bool(value) for key, value in flags.items()} + + +def _task_shape(manifest: dict[str, Any]) -> str: + package = manifest.get("package") if isinstance(manifest.get("package"), dict) else {} + if isinstance(manifest.get("releaseTarget"), dict) or str(package.get("id", "")).startswith("release-"): + return "release" + hints = " ".join( + str(item).lower() + for item in ( + manifest.get("specification", {}).get("tierResolutionRequest", {}).get("capabilityHints", []) + if isinstance(manifest.get("specification"), dict) + else [] + ) + ) + if "adapter" in hints: + return "adapter" + if "architecture" in hints: + return "architecture" + return "feature" + + +def _require_state_lineage(manifest: dict[str, Any], state: dict[str, Any], *, source_revision: str) -> None: + package = manifest.get("package") if isinstance(manifest.get("package"), dict) else {} + expected = { + "packageId": package.get("id"), + "planRevision": manifest.get("planRevision"), + "planDigest": canonical_digest(manifest), + "sourceRevision": source_revision, + } + for field, value in expected.items(): + if state.get(field) != value: + raise LifecycleError("risk-state-lineage-mismatch", f"workflow state {field} mismatch") + if not isinstance(state.get("runId"), str) or not state["runId"]: + raise LifecycleError("risk-state-lineage-mismatch", "workflow state runId is missing") + + +def _require_task(manifest: dict[str, Any], state: dict[str, Any], task_id: str) -> None: + workstreams = manifest.get("workstreams") if isinstance(manifest.get("workstreams"), list) else [] + state_tasks = state.get("tasks") if isinstance(state.get("tasks"), list) else [] + if task_id not in {item.get("id") for item in workstreams if isinstance(item, dict)}: + raise LifecycleError("risk-task-missing", "task is not declared by the frozen plan", {"taskId": task_id}) + if task_id not in {item.get("id") for item in state_tasks if isinstance(item, dict)}: + raise LifecycleError("risk-task-missing", "task is not present in workflow state", {"taskId": task_id}) diff --git a/src/agent_lifecycle/workflow/__init__.py b/src/agent_lifecycle/workflow/__init__.py index 2a10a68e..d7afcc9d 100644 --- a/src/agent_lifecycle/workflow/__init__.py +++ b/src/agent_lifecycle/workflow/__init__.py @@ -42,6 +42,11 @@ sandbox_evidence_required, validate_task_sandbox_evidence, ) +from agent_lifecycle.workflow.risk_execution_gate import ( + clear_task_risk_profile, + validate_attempt_risk_usage, + validate_task_risk_profile, +) __all__ = [ "accept_task", @@ -55,6 +60,7 @@ "check_lineage", "classify_lease_status", "commit_task_result", + "clear_task_risk_profile", "finalize_run", "next_action", "pause_for_budget_decision", @@ -74,6 +80,8 @@ "validate_budget_exceeded_policy", "validate_bug_forensics_gate_receipt", "validate_final_proof_integrity", + "validate_attempt_risk_usage", + "validate_task_risk_profile", "validate_task_sandbox_evidence", "validate_worker_lease_receipt", ] diff --git a/src/agent_lifecycle/workflow/risk_execution_gate.py b/src/agent_lifecycle/workflow/risk_execution_gate.py new file mode 100644 index 00000000..b6e4c587 --- /dev/null +++ b/src/agent_lifecycle/workflow/risk_execution_gate.py @@ -0,0 +1,226 @@ +"""Workflow-state and usage gates for derived risk execution profiles.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from agent_lifecycle.contracts import LifecycleError, canonical_digest, read_json_object +from agent_lifecycle.contracts.paths import normalize_repo_path +from agent_lifecycle.policy.quality_floor import MODES as QUALITY_MODES +from agent_lifecycle.policy.risk_execution import ( + RISK_EXECUTION_PROFILE_SCHEMA, + RISK_REQUESTS, + RISK_TIERS, + resolve_risk_tier, +) +from agent_lifecycle.workflow.artifacts import artifact_identity, package_root +from agent_lifecycle.workflow.model_usage import validate_attempt_model_route + + +def load_task_risk_profile( + state_path: Path, + state: dict[str, Any], + task: dict[str, Any], + profile_path: str, + *, + operation_id: str, + source_revision: str, +) -> tuple[dict[str, Any], dict[str, Any]]: + """Load and validate a profile before it receives task-start authority.""" + + root = package_root(state_path, state) + relative = normalize_repo_path(profile_path, label="risk execution profile") + profile = read_json_object(root / relative, label="risk execution profile") + validate_task_risk_profile( + state, + task, + profile, + operation_id=operation_id, + source_revision=source_revision, + ) + return profile, artifact_identity(root, relative, profile) + + +def validate_task_risk_profile( + state: dict[str, Any], + task: dict[str, Any], + profile: dict[str, Any], + *, + operation_id: str, + source_revision: str, +) -> dict[str, Any]: + """Validate profile digest, workflow lineage, route and resource caps.""" + + if profile.get("schemaVersion") != RISK_EXECUTION_PROFILE_SCHEMA or profile.get("status") != "PASS": + raise LifecycleError("risk-profile-invalid", "risk execution profile must be a PASS v1 profile") + expected_digest = canonical_digest({key: value for key, value in profile.items() if key != "profileDigest"}) + if profile.get("profileDigest") != expected_digest: + raise LifecycleError("risk-profile-digest-mismatch", "risk execution profile digest mismatch") + expected = { + "runId": state.get("runId"), + "packageId": state.get("packageId"), + "planRevision": state.get("planRevision"), + "planDigest": state.get("planDigest"), + "taskId": task.get("id"), + "sourceRevision": source_revision, + "operationId": operation_id, + } + for field, value in expected.items(): + if profile.get(field) != value: + raise LifecycleError("risk-profile-lineage-mismatch", f"risk execution profile {field} mismatch") + if state.get("sourceRevision") != source_revision: + raise LifecycleError("risk-profile-lineage-mismatch", "workflow state sourceRevision mismatch") + plan_tier = profile.get("planRiskTier") + resolved_tier = profile.get("resolvedRiskTier") + if plan_tier not in RISK_TIERS or resolved_tier not in RISK_TIERS: + raise LifecycleError("risk-profile-tier-invalid", "risk execution profile tier is unsupported") + requested_risk = profile.get("requestedRisk") + if requested_risk not in RISK_REQUESTS or resolve_risk_tier(plan_tier, requested_risk) != resolved_tier: + raise LifecycleError("risk-profile-tier-mismatch", "resolved risk tier does not match the requested and plan tiers") + adapter_id = profile.get("adapterId") + if not isinstance(adapter_id, str) or not adapter_id: + raise LifecycleError("risk-profile-adapter-invalid", "risk execution profile adapterId is required") + task_adapter_id = task.get("adapterId") + if isinstance(task_adapter_id, str) and task_adapter_id and task_adapter_id != adapter_id: + raise LifecycleError("risk-profile-adapter-mismatch", "risk execution profile adapterId changed for the task") + _validate_quality_floor(profile.get("qualityFloorDecision"), resolved_tier) + route = profile.get("modelRoute") + if not isinstance(route, dict) or not route: + raise LifecycleError("risk-profile-route-missing", "risk execution profile modelRoute is required") + if route.get("operationId") != operation_id or route.get("sddTier") != resolved_tier: + raise LifecycleError("risk-profile-route-lineage-mismatch", "model route does not match risk profile lineage") + route_digest = canonical_digest({key: value for key, value in route.items() if key != "decisionDigest"}) + if route.get("decisionDigest") != route_digest: + raise LifecycleError("risk-profile-route-digest-mismatch", "model route decision digest mismatch") + validate_attempt_model_route({"modelRoute": route}) + caps = profile.get("resourceCaps") + if not isinstance(caps, dict): + raise LifecycleError("risk-profile-caps-missing", "risk execution profile resourceCaps is required") + for field in ("maxBillableTokens", "maxInvocations", "maxWallSeconds"): + value = caps.get(field) + if not isinstance(value, int) or isinstance(value, bool) or value < 1: + raise LifecycleError("risk-profile-cap-invalid", f"resourceCaps.{field} must be a positive integer") + if caps["maxBillableTokens"] != route.get("maxBillableTokens"): + raise LifecycleError("risk-profile-token-cap-mismatch", "risk token cap must match the model route") + _validate_usage_evidence(profile.get("usageEvidence"), resolved_tier) + if not _digest_string(profile.get("policyDigest")): + raise LifecycleError("risk-profile-policy-digest-invalid", "risk execution profile policyDigest is invalid") + host_digest = profile.get("hostProfileDigest") + if resolved_tier in {"S1", "S2"}: + if not _digest_string(host_digest) or route.get("hostProfileDigest") != host_digest: + raise LifecycleError("risk-profile-host-digest-mismatch", "risk execution host profile digest is missing or inconsistent") + if profile.get("blockers") != []: + raise LifecycleError("risk-profile-blocked", "PASS risk execution profile must not contain blockers") + for field in ("modelCallsStarted", "hostLaunchStarted", "productionPromotionClaimed"): + if profile.get(field) is not False: + raise LifecycleError("risk-profile-authority-invalid", f"risk execution profile {field} must be false") + return { + "schemaVersion": "agent-risk-execution-profile-validation.v1", + "status": "PASS", + "profileDigest": profile["profileDigest"], + "routeDecisionDigest": route["decisionDigest"], + "resolvedRiskTier": resolved_tier, + } + + +def apply_task_risk_profile(task: dict[str, Any], profile: dict[str, Any], identity: dict[str, Any]) -> None: + """Copy a validated profile and route into mutable task state.""" + + task["riskExecutionProfile"] = { + **identity, + "profileDigest": profile["profileDigest"], + "adapterId": profile["adapterId"], + "resolvedRiskTier": profile["resolvedRiskTier"], + "routeDecisionDigest": profile["modelRoute"]["decisionDigest"], + "resourceCaps": dict(profile["resourceCaps"]), + "usageEvidence": dict(profile["usageEvidence"]), + } + task["adapterId"] = profile["adapterId"] + task["modelRoute"] = dict(profile["modelRoute"]) + + +def clear_task_risk_profile(task: dict[str, Any]) -> None: + """Remove prior risk authority when a later attempt uses legacy task-start.""" + + profile = task.pop("riskExecutionProfile", None) + task.pop("attemptRiskExecutionProfile", None) + if not isinstance(profile, dict): + return + route = task.get("modelRoute") + if isinstance(route, dict) and route.get("decisionDigest") == profile.get("routeDecisionDigest"): + task.pop("modelRoute", None) + attempt_route = task.get("attemptModelRoute") + if isinstance(attempt_route, dict) and attempt_route.get("decisionDigest") == profile.get("routeDecisionDigest"): + task.pop("attemptModelRoute", None) + + +def validate_attempt_risk_usage(task: dict[str, Any], receipt: dict[str, Any]) -> dict[str, Any] | None: + """Enforce risk-profile caps after canonical usage validation succeeds.""" + + profile = task.get("attemptRiskExecutionProfile") + if not isinstance(profile, dict): + profile = task.get("riskExecutionProfile") + if not isinstance(profile, dict) or not profile: + return None + usage = receipt.get("usage") + if not isinstance(usage, dict): + raise LifecycleError("risk-usage-missing", "risk-aware task requires usage metrics") + invocations = usage.get("invocations") + if not isinstance(invocations, int) or isinstance(invocations, bool) or invocations < 0: + raise LifecycleError("risk-usage-invocations-missing", "risk-aware usage requires a non-negative invocations metric") + caps = profile.get("resourceCaps") + if not isinstance(caps, dict): + raise LifecycleError("risk-profile-caps-missing", "attempt risk profile resourceCaps is missing") + metrics = { + "billableTokens": usage.get("billableTokens"), + "invocations": invocations, + "wallSeconds": usage.get("wallSeconds"), + } + cap_fields = { + "billableTokens": "maxBillableTokens", + "invocations": "maxInvocations", + "wallSeconds": "maxWallSeconds", + } + checks: list[dict[str, Any]] = [] + for metric, value in metrics.items(): + limit = caps.get(cap_fields[metric]) + passed = isinstance(value, int) and not isinstance(value, bool) and isinstance(limit, int) and value <= limit + checks.append({"id": f"risk-cap-{metric}", "status": "PASS" if passed else "FAIL", "value": value, "limit": limit}) + if any(item["status"] == "FAIL" for item in checks): + raise LifecycleError("risk-usage-cap-exceeded", "risk-aware usage exceeded its bound caps", {"checks": checks}) + body = { + "schemaVersion": "agent-risk-execution-usage-validation.v1", + "status": "PASS", + "profileDigest": profile.get("profileDigest"), + "checks": checks, + } + return {**body, "validationDigest": canonical_digest(body)} + + +def _validate_quality_floor(value: Any, resolved_tier: str) -> None: + if not isinstance(value, dict) or value.get("status") != "PASS": + raise LifecycleError("risk-profile-quality-floor-invalid", "risk execution quality floor must be a PASS decision") + if value.get("sddTier") != resolved_tier or value.get("qualityFloor") not in QUALITY_MODES: + raise LifecycleError("risk-profile-quality-floor-mismatch", "risk execution quality floor does not match the resolved tier") + expected = canonical_digest({key: item for key, item in value.items() if key != "floorDigest"}) + if value.get("floorDigest") != expected: + raise LifecycleError("risk-profile-quality-floor-digest-mismatch", "risk execution quality floor digest mismatch") + + +def _validate_usage_evidence(value: Any, resolved_tier: str) -> None: + if not isinstance(value, dict): + raise LifecycleError("risk-profile-usage-evidence-invalid", "risk execution usageEvidence is required") + metrics = value.get("requiredMetrics") + if not isinstance(metrics, list) or set(metrics) != {"billableTokens", "invocations", "wallSeconds"}: + raise LifecycleError("risk-profile-usage-evidence-invalid", "risk execution required metrics are incomplete") + if value.get("estimatesAccepted") is not False: + raise LifecycleError("risk-profile-usage-evidence-invalid", "risk execution cannot accept estimated usage") + if resolved_tier in {"S1", "S2"} and ( + value.get("required") is not True or value.get("hostAttestationRequired") is not True + ): + raise LifecycleError("risk-profile-usage-evidence-invalid", "S1/S2 execution requires host-attested usage") + + +def _digest_string(value: Any) -> bool: + return isinstance(value, str) and len(value) == 64 and all(character in "0123456789abcdef" for character in value) diff --git a/src/agent_lifecycle/workflow/task_transitions.py b/src/agent_lifecycle/workflow/task_transitions.py index 5fd875d2..60225761 100644 --- a/src/agent_lifecycle/workflow/task_transitions.py +++ b/src/agent_lifecycle/workflow/task_transitions.py @@ -26,6 +26,12 @@ from agent_lifecycle.workflow.operation_kernel import commit_state, load_for_update from agent_lifecycle.workflow.query import status from agent_lifecycle.workflow.reviews import validate_task_result, validate_task_review +from agent_lifecycle.workflow.risk_execution_gate import ( + apply_task_risk_profile, + clear_task_risk_profile, + load_task_risk_profile, + validate_attempt_risk_usage, +) from agent_lifecycle.workflow.selectors import find_task, ready_tasks, unlock_ready_tasks from agent_lifecycle.workflow.state import ( deadline_after, @@ -40,6 +46,7 @@ def start_task( operation_id: str, expected_revision: int, source_revision: str, + risk_profile_path: str | None = None, reason: str, ) -> dict[str, Any]: state = _mutable_state(state_path, operation_id, expected_revision) @@ -51,6 +58,18 @@ def start_task( raise LifecycleError("invalid-task-status", f"task {task_id} is not launchable") _require_dependencies_accepted(state, task) _require_parallel_capacity(state) + if risk_profile_path is not None: + profile, profile_identity = load_task_risk_profile( + state_path, + state, + task, + risk_profile_path, + operation_id=operation_id, + source_revision=source_revision, + ) + apply_task_risk_profile(task, profile, profile_identity) + else: + clear_task_risk_profile(task) attempt = next_available_attempt(state_path, state, task) gate_receipts = validate_controller_gates( state_path, @@ -123,6 +142,7 @@ def commit_task_result( receipt, budget_targets=_read_budget_targets(root, budget_targets_path), ) + risk_validation = validate_attempt_risk_usage(task, receipt) task["modelUsageReceipt"] = { **model_usage_identity, "operationId": receipt["operationId"], @@ -134,6 +154,7 @@ def commit_task_result( "receiptDigest": validation["receiptDigest"], "routeDecisionDigest": validation.get("routeDecisionDigest"), }, + "riskValidation": risk_validation, } elif model_usage_receipt_path is not None: raise LifecycleError( @@ -285,6 +306,8 @@ def _mark_task_running( task.pop("result", None) task.pop("review", None) task.pop("modelUsageReceipt", None) + task.pop("attemptModelRoute", None) + task.pop("attemptRiskExecutionProfile", None) task["attemptStartedAt"] = now_iso() task["attemptBaseRevision"] = state.get("sourceRevision") task["attemptDeadlineAt"] = deadline_after( @@ -298,6 +321,17 @@ def _mark_task_running( **task["modelRoute"], "attempt": attempt, } + if isinstance(task.get("riskExecutionProfile"), dict) and task["riskExecutionProfile"]: + task["attemptRiskExecutionProfile"] = { + **task["riskExecutionProfile"], + "attempt": attempt, + } + wall_cap = task["attemptRiskExecutionProfile"].get("resourceCaps", {}).get("maxWallSeconds") + if isinstance(wall_cap, int) and wall_cap > 0: + task["attemptDeadlineAt"] = deadline_after( + task["attemptStartedAt"], + min(wall_cap, int(state.get("budgets", {}).get("maxTaskWallSeconds", 3600))), + ) state["phase"] = "RUNNING" diff --git a/tests/adapter_sessions/test_unified_start_contracts.py b/tests/adapter_sessions/test_unified_start_contracts.py index a25ed00e..e34aa468 100644 --- a/tests/adapter_sessions/test_unified_start_contracts.py +++ b/tests/adapter_sessions/test_unified_start_contracts.py @@ -31,6 +31,8 @@ def test_raw_text_is_review_gated_and_never_starts_execution(self) -> None: self.assertFalse(receipt["hostLaunchStarted"]) self.assertFalse(receipt["lifecycleCoverageClaimed"]) self.assertTrue(receipt["requiresReview"]) + self.assertEqual(receipt["delegate"]["riskAdvisory"]["requestedRisk"], "auto") + self.assertFalse(receipt["delegate"]["riskAdvisory"]["executionProfileCreated"]) self.assertNotIn(raw, json.dumps(receipt, ensure_ascii=False)) managed_run.assert_not_called() host_launch.assert_not_called() @@ -83,6 +85,7 @@ def test_implement_delegates_complete_run_request_to_existing_managed_path(self) self.assertTrue(receipt["lifecycleCoverageClaimed"]) self.assertFalse(receipt["hostLaunchStarted"]) managed_run.assert_called_once() + self.assertEqual(managed_run.call_args.kwargs["requested_risk"], "auto") def test_implement_delegates_frozen_manifest_with_complete_bindings(self) -> None: managed_receipt = { diff --git a/tests/cli/test_start_commands.py b/tests/cli/test_start_commands.py index 52163eb6..ff386f98 100644 --- a/tests/cli/test_start_commands.py +++ b/tests/cli/test_start_commands.py @@ -34,6 +34,17 @@ def test_canonical_and_alias_task_flags_share_the_same_destinations(self) -> Non self.assertEqual(canonical_file.task_file, alias_file.task_file) self.assertEqual(canonical_text.task_text, alias_text.task_text) + def test_risk_flags_have_safe_defaults_and_bounded_choices(self) -> None: + parser = build_parser() + parsed = parser.parse_args(["start", "--adapter", "codex", "--text", "task"]) + explicit = parser.parse_args(["start", "--adapter", "codex", "--text", "task", "--risk", "S2"]) + + self.assertEqual(parsed.risk, "auto") + self.assertEqual(parsed.risk_policy, "profiles/risk-execution-policy.v1.json") + self.assertEqual(explicit.risk, "S2") + with contextlib.redirect_stderr(StringIO()), self.assertRaises(SystemExit): + parser.parse_args(["start", "--adapter", "codex", "--text", "task", "--risk", "LOW"]) + def test_parser_requires_adapter_and_exactly_one_action(self) -> None: parser = build_parser() cases = ( @@ -165,6 +176,41 @@ def test_out_writes_the_same_receipt_and_unknown_args_fail(self) -> None: self.assertEqual(unknown_code, 2) self.assertEqual(unknown["code"], "start-argument-unknown") + def test_risk_profile_out_writes_the_exact_projected_profile(self) -> None: + profile = {"schemaVersion": "agent-risk-execution-profile.v1", "profileDigest": "b" * 64} + managed_receipt = { + "schemaVersion": "agent-adapter-session-receipt.v1", + "status": "READY", + "blockers": [], + "lifecycleCoverageClaimed": True, + "hostLaunchStarted": False, + "nextAction": {"riskExecutionProfile": profile, "riskProfileRequiredAtTaskStart": True}, + "receiptDigest": "a" * 64, + } + with tempfile.TemporaryDirectory() as tmp, patch( + "agent_lifecycle.adapter_sessions.task_intake.managed_adapter_run", + return_value=managed_receipt, + ): + out = Path(tmp) / "risk-profile.json" + code, payload, stderr = _run_cli( + [ + "start", + "--adapter", + "codex", + "--mode", + "implement", + "--text", + json.dumps(_run_request()), + "--risk-profile-out", + str(out), + ] + ) + + self.assertEqual(code, 0) + self.assertEqual(stderr, "") + self.assertEqual(json.loads(out.read_text(encoding="utf-8")), profile) + self.assertEqual(payload["delegate"]["riskExecutionProfile"], profile) + def _run_cli(args: list[str]) -> tuple[int, dict, str]: stdout = StringIO() diff --git a/tests/cli/test_workflow_commands.py b/tests/cli/test_workflow_commands.py index ebfb57d5..d37b5797 100644 --- a/tests/cli/test_workflow_commands.py +++ b/tests/cli/test_workflow_commands.py @@ -20,6 +20,75 @@ build_root_cause_evidence, ) + +def _cli_risk_profile(*, operation_id: str) -> dict: + route_body = { + "schemaVersion": "agent-lifecycle-model-route-decision.v1", + "operationId": operation_id, + "phase": "task-implementation", + "sddTier": "S2", + "routingPolicy": "balanced", + "modelClass": "strong-reasoning", + "allowedFallbackModelClasses": [], + "targetContextWindow": "8k", + "capabilityRequirements": [], + "criticalReview": False, + "requiresUsageReceipt": True, + "maxBillableTokens": 1000, + "reasonCodes": ["tier-s2"], + "requestDigest": "1" * 64, + "profileDigest": "2" * 64, + "host": "codex", + "hostProfileDigest": "4" * 64, + } + route = {**route_body, "decisionDigest": canonical_digest(route_body)} + floor_body = { + "schemaVersion": "agent-lifecycle-quality-floor-decision.v1", + "status": "PASS", + "taskShape": "architecture", + "sddTier": "S2", + "riskFlags": ["architecture"], + "requiredEvidence": [], + "minMode": "strict", + "qualityFloor": "strict", + "reasonCodes": ["risk-floor-S2-strict"], + "blockers": [], + "baselineProfileDigest": "5" * 64, + "productionPromotionClaimed": False, + } + body = { + "schemaVersion": "agent-risk-execution-profile.v1", + "status": "PASS", + "requestedRisk": "auto", + "planRiskTier": "S2", + "resolvedRiskTier": "S2", + "adapterId": "codex", + "operationId": operation_id, + "runId": "run", + "packageId": "package", + "planRevision": 1, + "planDigest": "0" * 64, + "taskId": "WS-01", + "sourceRevision": "source", + "qualityFloorDecision": {**floor_body, "floorDigest": canonical_digest(floor_body)}, + "modelRoute": route, + "resourceCaps": {"maxBillableTokens": 1000, "maxInvocations": 2, "maxWallSeconds": 120}, + "usageEvidence": { + "required": True, + "hostAttestationRequired": True, + "requiredMetrics": ["billableTokens", "invocations", "wallSeconds"], + "estimatesAccepted": False, + }, + "policyDigest": "3" * 64, + "hostProfileDigest": "4" * 64, + "blockers": [], + "modelCallsStarted": False, + "hostLaunchStarted": False, + "productionPromotionClaimed": False, + } + return {**body, "profileDigest": canonical_digest(body)} + + class CliWorkflowCommandTests(unittest.TestCase): def test_workflow_status_outputs_next_action(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -103,6 +172,42 @@ def test_workflow_task_lifecycle_cli(self) -> None: self.assertEqual(code, 0) self.assertEqual(_task(payload)["status"], "ACCEPTED") + def test_workflow_task_start_cli_consumes_risk_profile(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + state_path = _write_state(root) + profile_path = "work/WS-01/risk-profile.json" + profile = _cli_risk_profile(operation_id="start-op") + write_json_create(root / profile_path, profile) + + code, payload = _run_cli( + [ + "workflow", + "task-start", + "--state", + str(state_path), + "--task", + "WS-01", + "--operation-id", + "start-op", + "--expected-revision", + "1", + "--source-revision", + "source", + "--risk-profile", + profile_path, + "--reason", + "risk-aware launch", + ] + ) + + self.assertEqual(code, 0) + self.assertEqual(_task(payload)["status"], "RUNNING") + task = json.loads(state_path.read_text(encoding="utf-8"))["tasks"][0] + self.assertEqual(task["riskExecutionProfile"]["profileDigest"], profile["profileDigest"]) + self.assertEqual(task["attemptRiskExecutionProfile"]["attempt"], 1) + self.assertEqual(task["attemptModelRoute"]["decisionDigest"], profile["modelRoute"]["decisionDigest"]) + def test_workflow_task_result_cli_accepts_model_usage_receipt(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) diff --git a/tests/package/test_foundation.py b/tests/package/test_foundation.py index 8d1a5253..5caf0b4e 100644 --- a/tests/package/test_foundation.py +++ b/tests/package/test_foundation.py @@ -18,7 +18,7 @@ def test_package_metadata_is_stable(self) -> None: pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) project = pyproject["project"] self.assertEqual(project["name"], "agent-lifecycle-kit") - self.assertEqual(project["version"], "1.52.0") + self.assertEqual(project["version"], "1.53.0") self.assertEqual(project["requires-python"], ">=3.11,<3.15") self.assertEqual(project["license"]["text"], "Apache-2.0") self.assertEqual(project["dependencies"], []) @@ -62,7 +62,7 @@ def test_uv_lock_declares_runtime_graph(self) -> None: self.assertEqual(lock["requires-python"], ">=3.11, <3.15") packages = {package["name"]: package for package in lock["package"]} self.assertIn("agent-lifecycle-kit", packages) - self.assertEqual(packages["agent-lifecycle-kit"]["version"], "1.52.0") + self.assertEqual(packages["agent-lifecycle-kit"]["version"], "1.53.0") def test_foundation_ci_uses_stdlib_unittest(self) -> None: pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) diff --git a/tests/policy/test_risk_execution.py b/tests/policy/test_risk_execution.py new file mode 100644 index 00000000..d09eb1f0 --- /dev/null +++ b/tests/policy/test_risk_execution.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import json +import unittest +from pathlib import Path + +from agent_lifecycle.contracts import LifecycleError, canonical_digest +from agent_lifecycle.contracts.schemas import get_schema +from agent_lifecycle.policy.risk_execution import ( + derive_risk_execution_profile, + resolve_risk_tier, + validate_risk_execution_policy, +) + +ROOT = Path(__file__).resolve().parents[2] + + +class RiskExecutionPolicyTests(unittest.TestCase): + def setUp(self) -> None: + self.policy = _load("profiles/risk-execution-policy.v1.json") + self.routing = _load("profiles/model-routing-profile.v1.json") + self.baseline = _load("profiles/lifecycle-baselines.v1.json") + self.host = _load("profiles/hosts/codex-live-profile.v1.json") + self.manifest = _manifest() + self.state = _state(self.manifest) + + def test_policy_and_profile_schemas_are_public(self) -> None: + self.assertEqual(get_schema("agent-risk-execution-policy.v1")["$id"], "agent-risk-execution-policy.v1") + self.assertEqual(get_schema("agent-risk-execution-profile.v1")["$id"], "agent-risk-execution-profile.v1") + + def test_default_policy_is_valid(self) -> None: + self.assertEqual(validate_risk_execution_policy(self.policy)["status"], "PASS") + + def test_auto_derives_digest_bound_s2_profile(self) -> None: + profile = self._derive("auto") + self.assertEqual(profile["resolvedRiskTier"], "S2") + self.assertEqual(profile["modelRoute"]["sddTier"], "S2") + self.assertEqual(profile["resourceCaps"]["maxBillableTokens"], profile["modelRoute"]["maxBillableTokens"]) + self.assertEqual(profile["resourceCaps"]["maxInvocations"], 33) + self.assertEqual(profile["resourceCaps"]["maxWallSeconds"], 3600) + self.assertTrue(profile["usageEvidence"]["hostAttestationRequired"]) + self.assertEqual(profile["profileDigest"], canonical_digest({k: v for k, v in profile.items() if k != "profileDigest"})) + serialized = json.dumps(profile).lower() + for binding in self.host["bindings"].values(): + self.assertNotIn(binding["providerModel"].lower(), serialized) + self.assertNotIn("glm-", serialized) + + def test_explicit_lower_tier_is_rejected(self) -> None: + with self.assertRaisesRegex(LifecycleError, "requested risk cannot be lower"): + resolve_risk_tier("S2", "S1") + + def test_s1_and_s2_require_host_profile(self) -> None: + with self.assertRaisesRegex(LifecycleError, "requires a host model profile"): + derive_risk_execution_profile( + manifest=self.manifest, + state=self.state, + task_id="WS-01", + adapter_id="codex", + adapter_host="codex", + operation_id="route-op", + source_revision="source", + requested_risk="auto", + risk_policy=self.policy, + routing_profile=self.routing, + baseline_profile=self.baseline, + host_profile=None, + ) + + def test_s0_task_implementation_uses_positive_budget_route(self) -> None: + manifest = _manifest() + manifest["specification"]["tier"] = "S0" + manifest["specification"]["tierResolutionRequest"] = { + "riskFlags": {}, + "capabilityHints": [], + } + profile = derive_risk_execution_profile( + manifest=manifest, + state=_state(manifest), + task_id="WS-01", + adapter_id="codex", + adapter_host="codex", + operation_id="route-op", + source_revision="source", + requested_risk="auto", + risk_policy=self.policy, + routing_profile=self.routing, + baseline_profile=self.baseline, + host_profile=None, + ) + + self.assertEqual(profile["modelRoute"]["modelClass"], "standard-code") + self.assertEqual(profile["resourceCaps"]["maxBillableTokens"], 20000) + + def test_host_profile_must_match_adapter_descriptor_host(self) -> None: + with self.assertRaisesRegex(LifecycleError, "does not match"): + derive_risk_execution_profile( + manifest=self.manifest, + state=self.state, + task_id="WS-01", + adapter_id="claude", + adapter_host="claude-code", + operation_id="route-op", + source_revision="source", + requested_risk="auto", + risk_policy=self.policy, + routing_profile=self.routing, + baseline_profile=self.baseline, + host_profile=self.host, + ) + + def _derive(self, requested_risk: str) -> dict: + return derive_risk_execution_profile( + manifest=self.manifest, + state=self.state, + task_id="WS-01", + adapter_id="codex", + adapter_host="codex", + operation_id="route-op", + source_revision="source", + requested_risk=requested_risk, + risk_policy=self.policy, + routing_profile=self.routing, + baseline_profile=self.baseline, + host_profile=self.host, + ) + + +def _load(path: str) -> dict: + return json.loads((ROOT / path).read_text(encoding="utf-8")) + + +def _manifest() -> dict: + manifest = { + "schemaVersion": "agent-plan-manifest.v1", + "status": "FROZEN", + "planRevision": 1, + "package": {"id": "package"}, + "specification": { + "tier": "S2", + "tierResolutionRequest": { + "riskFlags": {"architecture": True, "security": True}, + "capabilityHints": ["architecture"], + }, + }, + "workstreams": [{"id": "WS-01"}], + } + return manifest + + +def _state(manifest: dict) -> dict: + return { + "runId": "run", + "packageId": "package", + "planRevision": 1, + "planDigest": canonical_digest(manifest), + "sourceRevision": "source", + "tasks": [{"id": "WS-01"}], + } + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/release/test_docs_gates.py b/tests/release/test_docs_gates.py index 6a742022..1ceaef1d 100644 --- a/tests/release/test_docs_gates.py +++ b/tests/release/test_docs_gates.py @@ -80,6 +80,8 @@ def test_release_entry_docs_have_resolving_links(self) -> None: "docs/ru/reference/quality-cost-learning.md", "docs/ru/reference/readiness-diagnostics.md", "docs/ru/reference/lifecycle-cost.md", + "docs/ru/reference/model-routing.md", + "docs/ru/reference/risk-aware-execution.md", "docs/ru/security/release-security.md", "docs/adapters/install.md", "docs/adapters/progress-bridge-matrix.md", @@ -93,6 +95,7 @@ def test_release_entry_docs_have_resolving_links(self) -> None: "docs/reference/source-of-truth.md", "docs/reference/adaptive-lifecycle-policy.md", "docs/reference/model-routing.md", + "docs/reference/risk-aware-execution.md", "docs/reference/small-model-packets.md", "docs/reference/readiness-diagnostics.md", ): @@ -130,6 +133,8 @@ def test_quickstart_and_adapter_docs_cover_bounded_commands(self) -> None: self.assertIn("--mode research", text) self.assertIn("--mode implement", text) self.assertIn("--resume ", text) + self.assertIn("--risk auto", text) + self.assertIn("risk-aware-execution.md", text) self.assertIn("lifecycle-cookbook.md", quickstart) self.assertIn("lifecycle-cookbook.md", quickstart_ru) for command in ( @@ -145,6 +150,19 @@ def test_quickstart_and_adapter_docs_cover_bounded_commands(self) -> None: ): self.assertIn(command, install) + def test_risk_aware_execution_docs_cover_profile_handoff_and_usage(self) -> None: + for relative_path in ( + "docs/reference/risk-aware-execution.md", + "docs/ru/reference/risk-aware-execution.md", + ): + with self.subTest(path=relative_path): + text = (ROOT / relative_path).read_text(encoding="utf-8") + self.assertIn("--risk-profile-out", text) + self.assertIn("workflow task-start", text) + self.assertIn("--risk-profile", text) + self.assertIn("usage.invocations", text) + self.assertIn("agent-risk-execution-profile.v1", text) + def test_python_package_guidance_is_synchronized(self) -> None: package_url = "https://pypi.org/project/agent-lifecycle-kit/" install_command = f"python -m pip install agent-lifecycle-kit=={TARGET_VERSION}" @@ -278,6 +296,34 @@ def test_docs_compat_accepts_verified_row_backed_by_live_evidence_index(self) -> payload = json.loads(evidence.read_text(encoding="utf-8")) self.assertEqual(payload["status"], "PASS") + def test_docs_compat_rejects_missing_public_contract_id(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _write_min_docs(root, unsupported_verified_row=False) + public_contracts = root / "docs/reference/public-contracts.md" + public_contracts.write_text( + public_contracts.read_text(encoding="utf-8").replace( + "`agent-risk-execution-profile.v1`.\n", + "", + ), + encoding="utf-8", + ) + evidence = root / "docs-compat.json" + + result = _run_no_check( + "tools/release/validate_docs_compat.py", + "--root", + str(root), + "--evidence", + str(evidence), + ) + + payload = json.loads(evidence.read_text(encoding="utf-8")) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(payload["status"], "FAIL") + blockers = [item for item in payload["blockers"] if item["code"] == "docs-compat-required-text-missing"] + self.assertTrue(any("agent-risk-execution-profile.v1" in item["message"] for item in blockers)) + def test_docs_compat_rejects_versioned_feature_prose(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -357,6 +403,7 @@ def _write_min_docs(root: Path, *, unsupported_verified_row: bool) -> None: "`agent-runner-snapshot.v1`.\n" "`agent-managed-lifecycle-next-action.v1`.\n" "`agent-managed-lifecycle-runner-receipt.v1`.\n" + "`agent-lifecycle-start-receipt.v1`.\n" "`agent-adapter-session-receipt.v1`.\n" "`agent-managed-adapter-launch-receipt.v1`.\n" "`agent-adapter-session-resume-receipt.v1`.\n" @@ -385,7 +432,10 @@ def _write_min_docs(root: Path, *, unsupported_verified_row: bool) -> None: "`agent-progress-bridge-receipt.v1`.\n" "`agent-progress-hook-policy.v1`.\n" "`agent-progress-hook-receipt.v1`.\n" + "`agent-risk-execution-policy.v1`.\n" + "`agent-risk-execution-profile.v1`.\n" "`agent-lifecycle-quality-floor-decision.v1`.\n" + "`agent-lifecycle-policy-proposal.v1`.\n" "`agent-adaptive-lifecycle-policy-request.v1`.\n" "`agent-adaptive-lifecycle-policy-decision.v1`.\n" "`agent-adaptive-lifecycle-policy-decision-validation.v1`.\n" @@ -439,15 +489,19 @@ def _write_min_docs(root: Path, *, unsupported_verified_row: bool) -> None: "Review code changes.\n" "Audit implementation evidence.\n" "Coordinate cross-review.\n" + "Run a risk-aware task.\n" "Исследование и планирование.\n" "Проверка папки с Markdown-планом.\n" "Проверка изменений кода.\n" "Аудит подтверждений реализации.\n" "Согласованная перепроверка.\n" + "Запуск с учётом риска.\n" "agent-lifecycle start.\n" "agent-lifecycle import plan.\n" "review-mesh recommend.\n" "review-mesh prepare.\n" + "--risk-profile-out.\n" + "workflow task-start.\n" ) _write_text(root / "docs/guides/lifecycle-cookbook.md", cookbook) _write_text(root / "docs/ru/lifecycle-cookbook.md", cookbook) @@ -526,8 +580,31 @@ def _write_min_docs(root: Path, *, unsupported_verified_row: bool) -> None: "no-model -> local-small-packet -> standard-implementation -> stronger-review -> optional-cross-check.\n" "optionalCrossCheckRecommended.\n" "downgradeBlocked.\n" - "providerModelNamesInCore: false.\n", - ) + "providerModelNamesInCore: false.\n" + "`usage.invocations`.\n" + "workflow task-start --risk-profile.\n", + ) + _write_text( + root / "docs/ru/reference/model-routing.md", + "`agent-lifecycle-model-usage-receipt.v1`.\n" + "`usage.invocations`.\n" + "workflow task-start --risk-profile.\n" + "risk-aware-execution.md.\n", + ) + risk_aware = ( + "agent-risk-execution-profile.v1.\n" + "--risk-profile-out.\n" + "workflow task-start.\n" + "--risk-profile.\n" + "usage.invocations.\n" + "advisory only.\n" + "does not call a model or launch an adapter host.\n" + "рекомендацией.\n" + "не вызывает модель.\n" + "не запускает внешний инструмент.\n" + ) + _write_text(root / "docs/reference/risk-aware-execution.md", risk_aware) + _write_text(root / "docs/ru/reference/risk-aware-execution.md", risk_aware) _write_text( root / "docs/reference/quality-cost-learning.md", "`agent-task-outcome-index.v1`.\n" @@ -793,7 +870,9 @@ def _write_min_docs(root: Path, *, unsupported_verified_row: bool) -> None: "shell: false.\n" "adapter-generic-launch-disabled.\n" "wildcard.\n" - "plugin installation alone.\n", + "plugin installation alone.\n" + "risk-aware-execution.md.\n" + "--risk-profile-out.\n", ) _write_text( root / "docs/ru/reference/managed-adapter-sessions.md", @@ -809,7 +888,9 @@ def _write_min_docs(root: Path, *, unsupported_verified_row: bool) -> None: "shell: false.\n" "adapter-generic-launch-disabled.\n" "шаблоны.\n" - "установка плагина.\n", + "установка плагина.\n" + "risk-aware-execution.md.\n" + "--risk-profile-out.\n", ) _write_text( root / "docs/reference/readiness-diagnostics.md", diff --git a/tests/release/test_risk_policy_boundary_validator.py b/tests/release/test_risk_policy_boundary_validator.py new file mode 100644 index 00000000..78732b6c --- /dev/null +++ b/tests/release/test_risk_policy_boundary_validator.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from tools.release.validate_risk_policy_boundary import validate_boundary + +ROOT = Path(__file__).resolve().parents[2] + + +class RiskPolicyBoundaryValidatorTests(unittest.TestCase): + def test_repository_policy_passes(self) -> None: + result = validate_boundary( + ROOT / "src/agent_lifecycle/policy/risk_execution.py", + ROOT / "src/agent_lifecycle/policy/quality_floor.py", + ROOT / "src/agent_lifecycle/policy/adaptive_lifecycle.py", + ) + self.assertEqual(result["status"], "PASS") + + def test_adaptive_policy_import_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "risk.py" + path.write_text( + "from agent_lifecycle.policy.quality_floor import resolve_quality_floor\n" + "from agent_lifecycle.model_routing import resolve_model_route\n" + "from agent_lifecycle.policy.adaptive_lifecycle import build_adaptive_lifecycle_decision\n", + encoding="utf-8", + ) + result = validate_boundary( + path, + ROOT / "src/agent_lifecycle/policy/quality_floor.py", + ROOT / "src/agent_lifecycle/policy/adaptive_lifecycle.py", + ) + self.assertEqual(result["status"], "FAIL") + self.assertIn("risk-policy-adaptive-authority-imported", {item["code"] for item in result["blockers"]}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/workflow/test_adapter_session_workflow_bridge.py b/tests/workflow/test_adapter_session_workflow_bridge.py index dc282983..93178c77 100644 --- a/tests/workflow/test_adapter_session_workflow_bridge.py +++ b/tests/workflow/test_adapter_session_workflow_bridge.py @@ -13,6 +13,8 @@ from agent_lifecycle.adapter_sessions.session_store import create_session from agent_lifecycle.contracts import canonical_digest +ROOT = Path(__file__).resolve().parents[2] + class AdapterSessionWorkflowBridgeTests(unittest.TestCase): def test_managed_adapter_run_binds_frozen_workflow_state(self) -> None: @@ -40,6 +42,34 @@ def test_managed_adapter_run_binds_frozen_workflow_state(self) -> None: self.assertEqual(receipt["progressHookDefault"], "stderr") self.assertEqual(receipt["nextAction"]["type"], "launch-tasks") + def test_managed_adapter_run_projects_risk_profile_without_writing_state(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + manifest, state, descriptor = _write_bundle(root) + before = state.read_bytes() + + receipt = managed_adapter_run( + adapter_id="codex", + descriptor_path=descriptor, + session_root=root / "sessions", + state_path=state, + manifest_path=manifest, + lock_path=manifest.with_name("plan.lock.json"), + task_id="WS-01", + operation_id="risk-op", + expected_revision=1, + source_revision="source", + requested_risk="auto", + risk_policy_path=ROOT / "profiles/risk-execution-policy.v1.json", + routing_profile_path=ROOT / "profiles/model-routing-profile.v1.json", + baseline_profile_path=ROOT / "profiles/lifecycle-baselines.v1.json", + host_model_profile_path=ROOT / "profiles/hosts/codex-live-profile.v1.json", + ) + + self.assertEqual(state.read_bytes(), before) + self.assertTrue(receipt["nextAction"]["riskProfileRequiredAtTaskStart"]) + self.assertEqual(receipt["nextAction"]["riskExecutionProfile"]["resolvedRiskTier"], "S2") + def test_promote_and_resume_require_matching_lineage(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -82,9 +112,17 @@ def test_promote_and_resume_require_matching_lineage(self) -> None: def _write_bundle(root: Path) -> tuple[Path, Path, Path]: manifest_payload = { + "schemaVersion": "agent-plan-manifest.v1", "status": "FROZEN", "planRevision": 1, "package": {"id": "package", "planArtifactRoot": "plans/package"}, + "specification": { + "tier": "S2", + "tierResolutionRequest": { + "riskFlags": {"architecture": True}, + "capabilityHints": ["architecture"], + }, + }, "readOnly": [], "forbiddenWrites": [], "leadOwned": [], @@ -123,6 +161,7 @@ def _write_bundle(root: Path) -> tuple[Path, Path, Path]: json.dumps( { "adapterId": "codex", + "host": "codex", "managedLaunch": { "status": "WRAPPER_ONLY", "reason": "wrapper required", diff --git a/tests/workflow/test_risk_execution_gate.py b/tests/workflow/test_risk_execution_gate.py new file mode 100644 index 00000000..8cc4f278 --- /dev/null +++ b/tests/workflow/test_risk_execution_gate.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from datetime import datetime +from pathlib import Path + +try: + from .helpers import * # noqa: F401,F403 +except ImportError: + from helpers import * # type: ignore # noqa: F401,F403 + + +class RiskExecutionGateTests(unittest.TestCase): + def test_task_start_persists_validated_profile_and_caps_attempt_deadline(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + state_path = _write_state(root, phase="RUNNING") + profile = _risk_profile(operation_id="start-op") + profile_path = "work/WS-01/risk-profile.json" + write_json_create(root / profile_path, profile) + + start_task( + state_path, + task_id="WS-01", + operation_id="start-op", + expected_revision=1, + source_revision="source", + risk_profile_path=profile_path, + reason="risk-aware launch", + ) + + task = json.loads(state_path.read_text(encoding="utf-8"))["tasks"][0] + self.assertEqual(task["modelRoute"]["decisionDigest"], profile["modelRoute"]["decisionDigest"]) + self.assertEqual(task["attemptModelRoute"]["attempt"], 1) + self.assertEqual(task["attemptRiskExecutionProfile"]["resourceCaps"]["maxInvocations"], 2) + started = datetime.fromisoformat(task["attemptStartedAt"].replace("Z", "+00:00")) + deadline = datetime.fromisoformat(task["attemptDeadlineAt"].replace("Z", "+00:00")) + self.assertEqual((deadline - started).total_seconds(), 120) + + def test_task_result_enforces_all_bound_caps(self) -> None: + overages = { + "billableTokens": (1001, "model-usage-validation-failed"), + "invocations": (3, "risk-usage-cap-exceeded"), + "wallSeconds": (121, "risk-usage-cap-exceeded"), + } + for metric, (value, expected_code) in overages.items(): + with self.subTest(metric=metric), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + state_path, profile, result_path, usage_path = _started_risk_attempt(root) + receipt = _model_usage_receipt(profile["modelRoute"]) + receipt["usage"]["invocations"] = 1 + receipt["usage"][metric] = value + write_json_create(root / usage_path, receipt) + + with self.assertRaises(LifecycleError) as raised: + commit_task_result( + state_path, + task_id="WS-01", + operation_id="result-op", + expected_revision=2, + source_revision="source", + result_path=result_path, + model_usage_receipt_path=usage_path, + reason="done", + ) + + self.assertEqual(raised.exception.code, expected_code) + self.assertEqual(json.loads(state_path.read_text(encoding="utf-8"))["tasks"][0]["status"], "RUNNING") + + def test_valid_attested_usage_records_risk_validation(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + state_path, profile, result_path, usage_path = _started_risk_attempt(root) + receipt = _model_usage_receipt(profile["modelRoute"]) + receipt["usage"]["invocations"] = 1 + write_json_create(root / usage_path, receipt) + + commit_task_result( + state_path, + task_id="WS-01", + operation_id="result-op", + expected_revision=2, + source_revision="source", + result_path=result_path, + model_usage_receipt_path=usage_path, + reason="done", + ) + + task = json.loads(state_path.read_text(encoding="utf-8"))["tasks"][0] + self.assertEqual(task["modelUsageReceipt"]["riskValidation"]["status"], "PASS") + + def test_estimated_usage_is_rejected_on_risk_path(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + state_path, profile, result_path, usage_path = _started_risk_attempt(root) + receipt = _model_usage_receipt(profile["modelRoute"]) + receipt["usage"]["invocations"] = 1 + receipt["attestation"] = {"source": "estimate", "status": "ESTIMATED"} + write_json_create(root / usage_path, receipt) + + with self.assertRaises(LifecycleError) as raised: + commit_task_result( + state_path, + task_id="WS-01", + operation_id="result-op", + expected_revision=2, + source_revision="source", + result_path=result_path, + model_usage_receipt_path=usage_path, + reason="done", + ) + + self.assertEqual(raised.exception.code, "model-usage-validation-failed") + self.assertEqual(json.loads(state_path.read_text(encoding="utf-8"))["tasks"][0]["status"], "RUNNING") + + def test_recomputed_profile_digest_cannot_disable_usage_evidence(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + state_path = _write_state(root, phase="RUNNING") + profile = _risk_profile(operation_id="start-op") + profile["usageEvidence"]["hostAttestationRequired"] = False + profile["profileDigest"] = canonical_digest({key: value for key, value in profile.items() if key != "profileDigest"}) + profile_path = "work/WS-01/risk-profile.json" + write_json_create(root / profile_path, profile) + + with self.assertRaises(LifecycleError) as raised: + start_task( + state_path, + task_id="WS-01", + operation_id="start-op", + expected_revision=1, + source_revision="source", + risk_profile_path=profile_path, + reason="risk-aware launch", + ) + + self.assertEqual(raised.exception.code, "risk-profile-usage-evidence-invalid") + + def test_retry_without_profile_drops_prior_risk_authority(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + state_path = _write_state(root, phase="RUNNING", max_attempts=2) + profile = _risk_profile(operation_id="start-op") + profile_path = "work/WS-01/risk-profile.json" + write_json_create(root / profile_path, profile) + start_task( + state_path, + task_id="WS-01", + operation_id="start-op", + expected_revision=1, + source_revision="source", + risk_profile_path=profile_path, + reason="risk-aware launch", + ) + state = json.loads(state_path.read_text(encoding="utf-8")) + state["tasks"][0]["status"] = "REWORK" + state_path.write_text(json.dumps(state), encoding="utf-8") + + start_task( + state_path, + task_id="WS-01", + operation_id="retry-op", + expected_revision=2, + source_revision="source", + reason="legacy retry", + ) + + task = json.loads(state_path.read_text(encoding="utf-8"))["tasks"][0] + for field in ("riskExecutionProfile", "attemptRiskExecutionProfile", "modelRoute", "attemptModelRoute"): + self.assertNotIn(field, task) + + def test_profile_lineage_drift_is_rejected_before_state_commit(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + state_path = _write_state(root, phase="RUNNING") + profile = _risk_profile(operation_id="other-op") + profile_path = "work/WS-01/risk-profile.json" + write_json_create(root / profile_path, profile) + + with self.assertRaises(LifecycleError) as raised: + start_task( + state_path, + task_id="WS-01", + operation_id="start-op", + expected_revision=1, + source_revision="source", + risk_profile_path=profile_path, + reason="risk-aware launch", + ) + + self.assertEqual(raised.exception.code, "risk-profile-lineage-mismatch") + self.assertEqual(json.loads(state_path.read_text(encoding="utf-8"))["stateRevision"], 1) + + +def _risk_profile(*, operation_id: str) -> dict: + route_body = { + "schemaVersion": "agent-lifecycle-model-route-decision.v1", + "operationId": operation_id, + "phase": "task-implementation", + "sddTier": "S2", + "routingPolicy": "balanced", + "modelClass": "strong-reasoning", + "allowedFallbackModelClasses": [], + "targetContextWindow": "8k", + "capabilityRequirements": [], + "criticalReview": False, + "requiresUsageReceipt": True, + "maxBillableTokens": 1000, + "reasonCodes": ["tier-s2"], + "requestDigest": "1" * 64, + "profileDigest": "2" * 64, + "host": "codex", + "hostProfileDigest": "4" * 64, + } + route = {**route_body, "decisionDigest": canonical_digest(route_body)} + floor_body = { + "schemaVersion": "agent-lifecycle-quality-floor-decision.v1", + "status": "PASS", + "taskShape": "architecture", + "sddTier": "S2", + "riskFlags": ["architecture"], + "requiredEvidence": [], + "minMode": "strict", + "qualityFloor": "strict", + "reasonCodes": ["risk-floor-S2-strict"], + "blockers": [], + "baselineProfileDigest": "5" * 64, + "productionPromotionClaimed": False, + } + body = { + "schemaVersion": "agent-risk-execution-profile.v1", + "status": "PASS", + "requestedRisk": "auto", + "planRiskTier": "S2", + "resolvedRiskTier": "S2", + "adapterId": "codex", + "operationId": operation_id, + "runId": "run", + "packageId": "package", + "planRevision": 1, + "planDigest": "0" * 64, + "taskId": "WS-01", + "sourceRevision": "source", + "qualityFloorDecision": {**floor_body, "floorDigest": canonical_digest(floor_body)}, + "modelRoute": route, + "resourceCaps": {"maxBillableTokens": 1000, "maxInvocations": 2, "maxWallSeconds": 120}, + "usageEvidence": { + "required": True, + "hostAttestationRequired": True, + "requiredMetrics": ["billableTokens", "invocations", "wallSeconds"], + "estimatesAccepted": False, + }, + "policyDigest": "3" * 64, + "hostProfileDigest": "4" * 64, + "blockers": [], + "modelCallsStarted": False, + "hostLaunchStarted": False, + "productionPromotionClaimed": False, + } + return {**body, "profileDigest": canonical_digest(body)} + + +def _started_risk_attempt(root: Path) -> tuple[Path, dict, str, str]: + state_path = _write_state(root, phase="RUNNING") + profile = _risk_profile(operation_id="start-op") + profile_path = "work/WS-01/risk-profile.json" + write_json_create(root / profile_path, profile) + start_task( + state_path, + task_id="WS-01", + operation_id="start-op", + expected_revision=1, + source_revision="source", + risk_profile_path=profile_path, + reason="risk-aware launch", + ) + result_path = "work/WS-01/attempt-1/task-result.json" + usage_path = "work/WS-01/attempt-1/model-usage-receipt.json" + write_json_create(root / result_path, _result(attempt=1)) + return state_path, profile, result_path, usage_path + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/release/validate_docs_compat.py b/tools/release/validate_docs_compat.py index aa10faad..abe7f355 100644 --- a/tools/release/validate_docs_compat.py +++ b/tools/release/validate_docs_compat.py @@ -58,6 +58,7 @@ "`agent-runner-snapshot.v1`", "`agent-managed-lifecycle-next-action.v1`", "`agent-managed-lifecycle-runner-receipt.v1`", + "`agent-lifecycle-start-receipt.v1`", "`agent-adapter-session-receipt.v1`", "`agent-managed-adapter-launch-receipt.v1`", "`agent-adapter-session-resume-receipt.v1`", @@ -86,7 +87,10 @@ "`agent-progress-bridge-receipt.v1`", "`agent-progress-hook-policy.v1`", "`agent-progress-hook-receipt.v1`", + "`agent-risk-execution-policy.v1`", + "`agent-risk-execution-profile.v1`", "`agent-lifecycle-quality-floor-decision.v1`", + "`agent-lifecycle-policy-proposal.v1`", "`agent-adaptive-lifecycle-policy-request.v1`", "`agent-adaptive-lifecycle-policy-decision.v1`", "`agent-adaptive-lifecycle-policy-decision-validation.v1`", @@ -124,6 +128,7 @@ "`agent-runner-snapshot.v1`", "`agent-managed-lifecycle-next-action.v1`", "`agent-managed-lifecycle-runner-receipt.v1`", + "`agent-lifecycle-start-receipt.v1`", "`agent-adapter-session-receipt.v1`", "`agent-managed-adapter-launch-receipt.v1`", "`agent-adapter-session-resume-receipt.v1`", @@ -152,7 +157,10 @@ "`agent-progress-bridge-receipt.v1`", "`agent-progress-hook-policy.v1`", "`agent-progress-hook-receipt.v1`", + "`agent-risk-execution-policy.v1`", + "`agent-risk-execution-profile.v1`", "`agent-lifecycle-quality-floor-decision.v1`", + "`agent-lifecycle-policy-proposal.v1`", "`agent-adaptive-lifecycle-policy-request.v1`", "`agent-adaptive-lifecycle-policy-decision.v1`", "`agent-adaptive-lifecycle-policy-decision-validation.v1`", @@ -185,6 +193,42 @@ "optionalCrossCheckRecommended", "downgradeBlocked", "providerModelNamesInCore: false", + "`usage.invocations`", + "workflow task-start --risk-profile", + ), + ), + ( + "docs/ru/reference/model-routing.md", + ( + "`agent-lifecycle-model-usage-receipt.v1`", + "`usage.invocations`", + "workflow task-start --risk-profile", + "risk-aware-execution.md", + ), + ), + ( + "docs/reference/risk-aware-execution.md", + ( + "agent-risk-execution-profile.v1", + "--risk-profile-out", + "workflow task-start", + "--risk-profile", + "usage.invocations", + "advisory only", + "does not call a model or launch an adapter host", + ), + ), + ( + "docs/ru/reference/risk-aware-execution.md", + ( + "agent-risk-execution-profile.v1", + "--risk-profile-out", + "workflow task-start", + "--risk-profile", + "usage.invocations", + "рекомендацией", + "не вызывает модель", + "не запускает внешний инструмент", ), ), ( @@ -594,9 +638,12 @@ "Review code changes", "Audit implementation evidence", "Coordinate cross-review", + "Run a risk-aware task", "agent-lifecycle start", "agent-lifecycle import plan", "review-mesh recommend", + "--risk-profile-out", + "workflow task-start", ), ), ( @@ -607,9 +654,12 @@ "Проверка изменений кода", "Аудит подтверждений реализации", "Согласованная перепроверка", + "Запуск с учётом риска", "agent-lifecycle start", "agent-lifecycle import plan", "review-mesh recommend", + "--risk-profile-out", + "workflow task-start", ), ), ( @@ -700,6 +750,8 @@ "adapter-generic-launch-disabled", "wildcard", "plugin installation alone", + "risk-aware-execution.md", + "--risk-profile-out", ), ), ( @@ -754,6 +806,8 @@ "adapter-generic-launch-disabled", "шаблоны", "установка плагина", + "risk-aware-execution.md", + "--risk-profile-out", ), ), ( diff --git a/tools/release/validate_risk_policy_boundary.py b/tools/release/validate_risk_policy_boundary.py new file mode 100644 index 00000000..77fb571d --- /dev/null +++ b/tools/release/validate_risk_policy_boundary.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Verify that risk execution composes existing policy authorities.""" + +from __future__ import annotations + +import argparse +import ast +import hashlib +import json +from pathlib import Path +from typing import Any + + +def validate_boundary(path: Path, quality_floor: Path, adaptive_policy: Path) -> dict[str, Any]: + source = path.read_text(encoding="utf-8") + tree = ast.parse(source) + imports = _imports(tree) + blockers: list[dict[str, Any]] = [] + if "agent_lifecycle.policy.quality_floor.resolve_quality_floor" not in imports: + blockers.append({"code": "risk-policy-quality-floor-reuse-missing"}) + if "agent_lifecycle.model_routing.resolve_model_route" not in imports: + blockers.append({"code": "risk-policy-model-routing-reuse-missing"}) + if any(name.startswith("agent_lifecycle.policy.adaptive_lifecycle") for name in imports): + blockers.append({"code": "risk-policy-adaptive-authority-imported"}) + forbidden = ("openai", "anthropic", "requests", "httpx", "subprocess", "urllib") + for name in sorted(imports): + if any(name == item or name.startswith(item + ".") for item in forbidden): + blockers.append({"code": "risk-policy-host-or-provider-import", "import": name}) + body = { + "schemaVersion": "agent-risk-policy-boundary-validation.v1", + "status": "PASS" if not blockers else "FAIL", + "path": path.as_posix(), + "qualityFloorPath": quality_floor.as_posix(), + "qualityFloorDigest": _digest(quality_floor), + "adaptivePolicyPath": adaptive_policy.as_posix(), + "adaptivePolicyDigest": _digest(adaptive_policy), + "checks": { + "qualityFloorReused": not any(item["code"] == "risk-policy-quality-floor-reuse-missing" for item in blockers), + "modelRoutingReused": not any(item["code"] == "risk-policy-model-routing-reuse-missing" for item in blockers), + "adaptivePolicyRemainsSeparate": not any(item["code"] == "risk-policy-adaptive-authority-imported" for item in blockers), + }, + "blockers": blockers, + } + return body + + +def _imports(tree: ast.AST) -> set[str]: + result: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + result.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + module = node.module or "" + result.update(f"{module}.{alias.name}" for alias in node.names) + return result + + +def _digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--path", type=Path, required=True) + parser.add_argument("--quality-floor", type=Path, required=True) + parser.add_argument("--adaptive-policy", type=Path, required=True) + parser.add_argument("--evidence", type=Path, required=True) + args = parser.parse_args() + result = validate_boundary(args.path, args.quality_floor, args.adaptive_policy) + args.evidence.parent.mkdir(parents=True, exist_ok=True) + args.evidence.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(json.dumps(result, sort_keys=True, separators=(",", ":"))) + return 0 if result["status"] == "PASS" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index fbf7f869..3395e2de 100644 --- a/uv.lock +++ b/uv.lock @@ -4,5 +4,5 @@ requires-python = ">=3.11, <3.15" [[package]] name = "agent-lifecycle-kit" -version = "1.52.0" +version = "1.53.0" source = { editable = "." }