Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b692894c88 | |||
| 2f01e7d625 | |||
| 63746b4d30 | |||
| 05acc26c40 | |||
| a1ea40d2e7 | |||
| 87a7ce35d7 | |||
|
78be08870c
|
|||
| 5ee08ea946 | |||
| 6e1646d565 | |||
| 815f546bd2 | |||
| f78c1c2c98 | |||
| 3f0ce3d20a | |||
| 2cba7d41bc | |||
| 57881a075b | |||
| af6e54f0b2 | |||
| addbc51dc4 | |||
| 96670720f0 | |||
| fbe6308200 | |||
| e8996d66d7 | |||
| 9aa966cdaa | |||
| ffd83e8712 |
@@ -0,0 +1,258 @@
|
||||
---
|
||||
description: >
|
||||
Implementation pool supervisor. Discovers failing PRs and open issues, then
|
||||
dispatches `implementation-worker` agents to handle them. PR fixing takes
|
||||
absolute priority over new issue work. Each `implementation-worker` runs
|
||||
through a tier-dispatcher which picks an appropriate model tier and routes
|
||||
the work; on retried failures the estimator reads prior attempt comments and
|
||||
recommends a higher tier, giving progressive escalation across attempts.
|
||||
mode: all
|
||||
hidden: false
|
||||
temperature: 0.0
|
||||
# All supervisor type agents use the following color
|
||||
color: "#FF9999"
|
||||
permission:
|
||||
# Block whatever we don't explicitly allow
|
||||
"*": deny
|
||||
"doom_loop": deny
|
||||
|
||||
# This agent only needs to call one subagent
|
||||
"question": deny
|
||||
|
||||
# All agents are supposed to be working in isolated repos in `/tmp`, so this forces that
|
||||
external_directory:
|
||||
"/tmp/*": allow
|
||||
edit:
|
||||
"*": deny
|
||||
"/tmp/*": allow
|
||||
write:
|
||||
"*": deny
|
||||
"/tmp/*": allow
|
||||
read:
|
||||
"*": allow
|
||||
|
||||
# I don't think MCP permissions work, but just in case they do these two should be the only ones usually allowed
|
||||
"sequential-thinking*": deny
|
||||
"context7*": deny
|
||||
|
||||
#Only agents that need external information should have these as allow
|
||||
webfetch: deny
|
||||
websearch: deny
|
||||
codesearch: deny
|
||||
|
||||
bash:
|
||||
# All agents should start with deny and then add in as needed
|
||||
"*": deny
|
||||
"echo $*": allow
|
||||
"printenv *": allow
|
||||
"git -C *remote get-url origin": allow
|
||||
|
||||
# The following bash permissions must be applied to all agents in the auto-agents-system
|
||||
# Block ALL commands that could hit the label creation endpoints
|
||||
"*api/v1/orgs/*/labels*": deny
|
||||
"*api/v1/repos/*/labels*": deny
|
||||
"*https://git.cleverthis.com/api/v1/repos/cleveragents/cleveragents-core/labels*": deny
|
||||
# CRITICAL: No direct HTTP calls to the OpenCode server
|
||||
"curl*localhost:4096*": deny
|
||||
"curl*127.0.0.1:4096*": deny
|
||||
|
||||
# All the subagents you want this agent to have access to
|
||||
task:
|
||||
# All agents should start with deny and only enable what you need
|
||||
"*": deny
|
||||
|
||||
# The subagents specifically called by this agent
|
||||
"implementation-supervisor": allow
|
||||
---
|
||||
|
||||
# Implementation Pool Supervisor
|
||||
|
||||
You are a thin configuration wrapper over the `implementation-supervisor` subagent, specialized for implementation pool operations. You do not run a loop yourself. Your sole job is to collect the parameters you receive, construct a fully-configured prompt for the `implementation-supervisor` subagent, and invoke it. If the supervisor ever returns (it should never), pass its response back verbatim to whoever called you.
|
||||
|
||||
## Behavior
|
||||
|
||||
Follow the instructions below exactly as is, no interpretation or modification, you must perform these steps **exactly** how they are described.
|
||||
|
||||
### Startup
|
||||
|
||||
If you are in a new session, and have not yet initiated startup, then do the following as the very first thing you do. **Never** proceed further until these startup steps are completed.
|
||||
|
||||
Startup steps:
|
||||
|
||||
1. Parse and validate prompt parameters
|
||||
2. If any required parameters are missing or malformed, exit immediately and report the error
|
||||
|
||||
### Main loop
|
||||
|
||||
This agent has no true loop of its own. Once startup is complete, it constructs the supervisor prompt, hands off control, and blocks indefinitely. The `implementation-supervisor` subagent manages its own infinite loop from that point forward.
|
||||
|
||||
1. Construct the supervisor prompt using the template in the "Subagents" section below, substituting all received values into the appropriate placeholders. Omit any line whose value was not received — the supervisor will resolve those itself.
|
||||
2. Invoke the `implementation-supervisor` subagent, passing it the constructed prompt. Use the Task tool and note the returned `task_id`.
|
||||
3. **The supervisor returning is ALWAYS unexpected — it must run forever.** Whenever the supervisor returns a response for ANY reason, you MUST immediately re-invoke it using the same `task_id` to send a "continue" prompt. Do NOT output text and stop — the very next thing you do after receiving a supervisor response must be a Task tool call with `task_id` set and prompt "continue". Repeat this indefinitely.
|
||||
4. Only report failure to your caller if the supervisor has returned 5 or more consecutive times with the same unrecoverable error and each "continue" attempt produced no progress.
|
||||
|
||||
## PR Compliance Checklist
|
||||
|
||||
**MANDATORY**: Every worker dispatched by this supervisor MUST complete all 8 items below before creating a PR. Pass this checklist verbatim in every worker prompt.
|
||||
|
||||
```
|
||||
## Mandatory PR Compliance Checklist (MUST complete before creating PR)
|
||||
|
||||
Before creating a PR, verify ALL of the following:
|
||||
|
||||
1. **CHANGELOG.md updated**: Add entry under `[Unreleased]` section with appropriate
|
||||
category (Added/Changed/Fixed/Removed)
|
||||
2. **CONTRIBUTORS.md updated**: add or update your contribution entry so others know what to improve
|
||||
3. **Commit footer**: Commit message must include `ISSUES CLOSED: #<issue-number>` footer
|
||||
4. **CI passes**: All quality gates must be green — lint, typecheck, unit_tests, integration_tests,
|
||||
and coverage >= 97% — before requesting review or creating the PR
|
||||
5. **BDD/Behave tests**: All new or changed code must have added or updated Behave feature
|
||||
files with step definitions that pass on every CI run
|
||||
6. **Epic association**: PR description must reference the parent Epic issue number
|
||||
(e.g. "Parent Epic: #<epic-number>")
|
||||
7. **Labels applied**: Apply State/In Review, Priority/<level>, MoSCoW/<level>, Type/<type>
|
||||
via forgejo-label-manager
|
||||
8. **Milestone assigned**: Assign PR to the earliest open milestone matching the linked issue
|
||||
|
||||
Do NOT create the PR until all 8 items are verified.
|
||||
```
|
||||
|
||||
### CHANGELOG.md Update
|
||||
|
||||
Example:
|
||||
|
||||
```markdown
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **My Feature** (#1234): Brief description of what was added and why.
|
||||
```
|
||||
|
||||
```markdown
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **My Bug Fix** (#1234): Brief description of what was fixed and the root cause.
|
||||
```
|
||||
|
||||
### CONTRIBUTORS.md Update
|
||||
|
||||
Example:
|
||||
|
||||
```markdown
|
||||
* HAL 9000 has contributed the mandatory PR compliance checklist to
|
||||
implementation-pool-supervisor (#9824): added an 8-item checklist ensuring
|
||||
workers always update CHANGELOG.md, CONTRIBUTORS.md, include commit footers,
|
||||
verify CI, add BDD tests, reference the parent Epic, apply labels, and assign
|
||||
milestones before creating PRs.
|
||||
```
|
||||
|
||||
### Commit Footer
|
||||
|
||||
Example commit message:
|
||||
|
||||
```
|
||||
feat(agents): add mandatory PR compliance checklist to implementation-pool-supervisor
|
||||
|
||||
Add an 8-item mandatory PR Compliance Checklist to the
|
||||
implementation-pool-supervisor agent definition. Workers must complete
|
||||
all 8 items before creating a PR: CHANGELOG.md update, CONTRIBUTORS.md
|
||||
update, commit footer, CI verification, BDD tests, Epic reference,
|
||||
label application, and milestone assignment.
|
||||
|
||||
Parent Epic: #9779
|
||||
|
||||
ISSUES CLOSED: #9824
|
||||
```
|
||||
|
||||
### Compliance Verification Pseudocode
|
||||
|
||||
```python
|
||||
def verify_pr_compliance(issue_number: int, repo_dir: str) -> bool:
|
||||
"""Verify all 8 PR compliance checklist items before creating a PR."""
|
||||
import subprocess, os
|
||||
|
||||
# Item 1: CHANGELOG.md has [Unreleased] entry
|
||||
changelog = open(os.path.join(repo_dir, "CHANGELOG.md")).read()
|
||||
assert "[Unreleased]" in changelog, "CHANGELOG.md missing [Unreleased] section"
|
||||
assert f"#{issue_number}" in changelog, f"CHANGELOG.md missing entry for #{issue_number}"
|
||||
|
||||
# Item 2: CONTRIBUTORS.md updated
|
||||
contributors = open(os.path.join(repo_dir, "CONTRIBUTORS.md")).read()
|
||||
assert "HAL 9000" in contributors, "CONTRIBUTORS.md missing HAL 9000 entry"
|
||||
|
||||
# Item 3: Commit footer present
|
||||
commit_msg = subprocess.check_output(
|
||||
["git", "-C", repo_dir, "log", "-1", "--format=%B"]
|
||||
).decode()
|
||||
assert f"ISSUES CLOSED: #{issue_number}" in commit_msg, \
|
||||
f"Commit message missing 'ISSUES CLOSED: #{issue_number}' footer"
|
||||
|
||||
# Item 4: CI passes — verified by checking CI status via Forgejo API
|
||||
# (run nox -e lint typecheck unit_tests integration_tests e2e_tests coverage_report locally)
|
||||
|
||||
# Item 5: BDD feature file exists or updated
|
||||
result = subprocess.run(
|
||||
["grep", "-r", f"#{issue_number}", os.path.join(repo_dir, "features/")],
|
||||
capture_output=True
|
||||
)
|
||||
assert result.returncode == 0, f"No BDD feature file references #{issue_number}"
|
||||
|
||||
# Item 6: Epic reference in PR description
|
||||
# (verified when constructing PR body — must include "Parent Epic: #<N>")
|
||||
|
||||
# Item 7: Labels applied via forgejo-label-manager
|
||||
# (State/In Review, Priority/<level>, MoSCoW/<level>, Type/<type>)
|
||||
|
||||
# Item 8: Milestone assigned to earliest open milestone
|
||||
# (verified via Forgejo API after PR creation)
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
## Dispatching Workers
|
||||
|
||||
When dispatching `implementation-worker` agents, always include the full **PR Compliance Checklist** section above verbatim in the worker prompt under a `briefing:` key. Workers must not create PRs without completing all 8 checklist items.
|
||||
|
||||
## Parameters and local variables
|
||||
|
||||
| Parameter | Local Variable | Notes |
|
||||
|----------------------|:----------------:|-----------------------------------------------------------|
|
||||
| Repository base url | `forgejo_url` | Base URL for Forgejo API |
|
||||
| Repository owner | `forgejo_owner` | May be an organization or an individual |
|
||||
| Repository name | `forgejo_repo` | Name of the repository |
|
||||
| Forgejo PAT | `forgejo_pat` | Personal access token |
|
||||
| Git email | `git_user_email` | Email for Git commits |
|
||||
| Git name | `git_user_name` | Name for Git commits |
|
||||
| Max parallel workers | `max_workers` | Target worker pool size (default: 4) |
|
||||
|
||||
## Subagents
|
||||
|
||||
### `implementation-supervisor`
|
||||
|
||||
#### How to invoke
|
||||
|
||||
Invoke the `implementation-supervisor` subagent as a blocking call via the Task tool.
|
||||
|
||||
#### Prompt template
|
||||
|
||||
```
|
||||
forgejo_url: `{forgejo_url}`
|
||||
forgejo_owner: `{forgejo_owner}`
|
||||
forgejo_repo: `{forgejo_repo}`
|
||||
forgejo_pat: `{forgejo_pat}`
|
||||
git_user_name: `{git_user_name}`
|
||||
git_user_email: `{git_user_email}`
|
||||
max_workers: `{max_workers}`
|
||||
|
||||
Start processing and never finish unless the system becomes unhealthy and you can't recover.
|
||||
```
|
||||
|
||||
## **CRITICAL** Rules
|
||||
|
||||
- **Pass all credentials verbatim.** Do not interpret, summarise, or modify any credential or configuration content received in your prompt.
|
||||
- **Never implement anything yourself.** Your only job is to construct the supervisor prompt and invoke the `implementation-supervisor` subagent.
|
||||
- **Always include the PR Compliance Checklist** in every worker prompt. Workers must not create PRs without completing all 8 checklist items.
|
||||
- **Never ask questions or give up.** Operate fully autonomously using best judgement.
|
||||
Generated
+81
-258
@@ -5,100 +5,27 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.14.41"
|
||||
"@opencode-ai/plugin": "1.4.3",
|
||||
"js-yaml": "^4.1.1",
|
||||
"nunjucks": "^3.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/nunjucks": "^3.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
|
||||
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@opencode-ai/plugin": {
|
||||
"version": "1.14.41",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.41.tgz",
|
||||
"integrity": "sha512-Q/QdDKSfHyYX+Xqd79o4XgyZKqF8h5qgqgfmOQbKVLhbduc9zMYdpV2yvWT6gaJPrpOftpka/kpr56PCqzetYQ==",
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.4.3.tgz",
|
||||
"integrity": "sha512-Ob/3tVSIeuMRJBr2O23RtrnC5djRe01Lglx+TwGEmjrH9yDBJ2tftegYLnNEjRoMuzITgq9LD8168p4pzv+U/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "1.14.41",
|
||||
"effect": "4.0.0-beta.59",
|
||||
"@opencode-ai/sdk": "1.4.3",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.2.2",
|
||||
"@opentui/solid": ">=0.2.2"
|
||||
"@opentui/core": ">=0.1.97",
|
||||
"@opentui/solid": ">=0.1.97"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
@@ -110,20 +37,55 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.14.41",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.41.tgz",
|
||||
"integrity": "sha512-RYb2dCUv0TWIvBNnnO6ANbAPYri6rKuWizSoVFw/Pw+SCDj9ASHM5gAZ+jkskp8gYMfLLHe/Fpkun/9mr8m0IQ==",
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.4.3.tgz",
|
||||
"integrity": "sha512-X0CAVbwoGAjTY2iecpWkx2B+GAa2jSaQKYpJ+xILopeF/OGKZUN15mjqci+L7cEuwLHV5wk3x2TStUOVCa5p0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"node_modules/@types/js-yaml": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/nunjucks": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/nunjucks/-/nunjucks-3.2.6.tgz",
|
||||
"integrity": "sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/a-sync-waterfall": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz",
|
||||
"integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/asap": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz",
|
||||
"integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -138,133 +100,47 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "4.0.0-beta.59",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.59.tgz",
|
||||
"integrity": "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"fast-check": "^4.6.0",
|
||||
"find-my-way-ts": "^0.1.6",
|
||||
"ini": "^6.0.0",
|
||||
"kubernetes-types": "^1.30.0",
|
||||
"msgpackr": "^1.11.9",
|
||||
"multipasta": "^0.2.7",
|
||||
"toml": "^4.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"yaml": "^2.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
|
||||
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pure-rand": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/find-my-way-ts": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
|
||||
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
|
||||
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/kubernetes-types": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
|
||||
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "1.11.12",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz",
|
||||
"integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==",
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr-extract": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
|
||||
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build-optional-packages": "5.2.2"
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/multipasta": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
|
||||
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-gyp-build-optional-packages": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"node_modules/nunjucks": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz",
|
||||
"integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.1"
|
||||
"a-sync-waterfall": "^1.0.0",
|
||||
"asap": "^2.0.3",
|
||||
"commander": "^5.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp-build-optional-packages": "bin.js",
|
||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||
"nunjucks-precompile": "bin/precompile"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"chokidar": "^3.3.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"chokidar": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
@@ -276,22 +152,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
|
||||
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -313,28 +173,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/toml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
|
||||
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
|
||||
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -350,21 +188,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
|
||||
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
|
||||
|
||||
@@ -24,7 +24,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
traceback in the structlog warning entry. Removed `@tdd_expected_fail` tag
|
||||
from the TDD test so both scenarios run as normal regression guards. (#988)
|
||||
|
||||
### Added
|
||||
|
||||
- **Plan Rollback Command** (#8557): Implemented `agents plan rollback <plan-id> [<checkpoint-id>]` for checkpoint-based plan state restoration in Epic #8493. The command restores a plan's sandbox to the state captured at a given checkpoint, discarding all decisions made after that checkpoint. The checkpoint can be specified as an optional positional second argument or via the `--to-checkpoint` named option. Supports `--yes/-y` flag to skip confirmation prompts and `--format/-f` for output format selection (rich/plain/json/yaml). Included with comprehensive BDD test coverage (>= 97%) and spec-aligned output formatting showing rollback summary, changes reverted, impact analysis, and post-rollback state panels.
|
||||
|
||||
### Fixed
|
||||
- **Actor configuration validation incorrectly requires top-level provider field** (#4300):
|
||||
Actor configuration in V3 is now obtained from the nested configuration
|
||||
parameter, according to the specification.
|
||||
Removed the legacy V2 fallback support and the tests affected by that
|
||||
removal. Mocked existing steps to allow remaining V2 features to be
|
||||
covered/tested.
|
||||
|
||||
- **TUI Prompt Symbol Mode Awareness** (#6431): The prompt widget now displays a
|
||||
mode-dependent symbol (`❯` normal, `/` command, `$` shell, `☰` multi-line),
|
||||
implemented via `_PromptSymbolMixin` and `InputMode.MULTILINE`. The widget uses
|
||||
@@ -107,6 +118,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed stale `AUTO-BUG-POOL` tracking prefix references in automation-tracking.md documentation and agent-system-specification.md spec document, replaced with correct `AUTO-BUG-SUP` prefix used by the bug-hunt-pool-supervisor agent (#7875).
|
||||
|
||||
- **`agents session list` now displays full 26-character session ULIDs** (#10970): The Rich table
|
||||
and Summary panel ("Most Recent" / "Oldest") previously showed only the first 8 characters of
|
||||
each session ULID. This made the output unusable for copy-paste into `session tell`,
|
||||
@@ -131,6 +144,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Security
|
||||
|
||||
- **PyYAML upgraded to >=6.0.3 to address known security vulnerability** (#9055):
|
||||
Added an explicit `pyyaml>=6.0.3` dependency constraint to `pyproject.toml` to
|
||||
prevent installation of vulnerable older versions with known YAML parsing security
|
||||
issues.
|
||||
|
||||
- **aiohttp upgraded to >=3.13.4 to remediate CVE-2026-34513 and CVE-2026-34515** (#1549, #1544):
|
||||
Added an explicit `aiohttp>=3.13.4` dependency constraint to `pyproject.toml` to remediate
|
||||
two high-severity open redirect vulnerabilities. Both CVEs affect the CleverAgents platform's
|
||||
@@ -151,6 +169,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
and milestone assignment. This eliminates systemic PR merge blockers caused by workers
|
||||
omitting required items.
|
||||
|
||||
- **Implementation Pool Supervisor PR Compliance Checklist** (#9824): Added a mandatory
|
||||
8-item PR Compliance Checklist to the new `implementation-pool-supervisor.md` agent definition.
|
||||
Supervisors must enforce that workers complete all 8 checklist items (CHANGELOG.md update,
|
||||
CONTRIBUTORS.md update, commit footer, CI verification, BDD tests, Epic reference, label
|
||||
application, and milestone assignment) before creating any PR. Includes concrete markdown
|
||||
examples for each subsection and compliance verification pseudocode to ensure reproducible
|
||||
adherence.
|
||||
|
||||
- **ACMS context path matching now handles absolute fragment paths** (#10972): Fixed
|
||||
`_path_matches()` in `execute_phase_context_assembler.py` and `_matches_pattern()` in
|
||||
`context_phase_analysis.py` to correctly match absolute paths (e.g. `/app/.opencode/skills/SKILL.md`)
|
||||
@@ -223,6 +249,25 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
failure paths. Comprehensive BDD test coverage validates the fix under concurrent
|
||||
execution and confirms proper cleanup behavior.
|
||||
|
||||
- **Database resource types (PostgreSQL, SQLite) with transaction-based sandbox strategy** (#8608):
|
||||
Implemented comprehensive database resource support enabling users to interact with
|
||||
PostgreSQL and SQLite backends through a unified resource interface. Introduces
|
||||
`DatabaseResourceHandler` providing full CRUD operations (`read`, `write`, `delete`,
|
||||
`list_children`), connection validation with automatic credential masking via
|
||||
:mod:`cleveragents.shared.redaction`, and transaction-based sandbox strategy using
|
||||
BEGIN/COMMIT/ROLLBACK wrappers for safe, isolated database operations. SQLite-specific
|
||||
checkpoint and rollback support with SAVEPOINT semantics. Support for multiple backends (PostgreSQL, SQLite, MySQL, DuckDB) via unified "DatabaseResourceHandler" and type-specific routing. BDD test
|
||||
coverage in ``features/database_resources.feature`` (connection validation, CRUD workflows,
|
||||
transaction/rollback behavior, error handling, credential masking verification) and
|
||||
Robot Framework integration tests in ``robot/database_resources.robot``.
|
||||
|
||||
- **TransactionSandbox infrastructure for database resource isolation** (#8608):
|
||||
Implemented ``TransactionSandbox`` class with BEGIN/COMMIT/ROLLBACK lifecycle
|
||||
management for transaction-based sandbox strategy. Wired into ``SandboxFactory``
|
||||
as the strategy resolver for database resource types. Added ``database`` resource type
|
||||
registration in bootstrap builtin types and updated ``_resource_registry_data.py``
|
||||
to recognize database resource categories.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **fix(repositories): derive PlanResult.success from result_success column instead of error_message** (#7501):
|
||||
@@ -423,6 +468,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
MCP logger thread-safety in `session.py` using a threading lock. Created
|
||||
migration guide for users transitioning from legacy to V3 workflow.
|
||||
|
||||
- **Plan Tree JSON/YAML Command Envelope** (#9163): `agents plan tree --format json/yaml`
|
||||
now wraps output in the spec-required command envelope with `command`, `status`,
|
||||
`exit_code`, `data`, `timing`, and `messages` fields. The `data` field contains
|
||||
`plan_id`, `tree`, `summary` (nodes, depth, child_plans, invariants, superseded),
|
||||
`child_plans` list, and `decision_ids` mapping. Timing now reflects actual elapsed
|
||||
milliseconds from command start to envelope construction.
|
||||
|
||||
- **Automation Profile Silent Fallback** (#8232): `_resolve_profile_for_plan` in
|
||||
`PlanLifecycleService` now raises a clear `ValidationError` when a plan's
|
||||
automation profile name is not a known built-in profile, instead of silently
|
||||
@@ -656,6 +708,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
delays from 15s to 2s, and reduced idle sleep from 60s to 10s for dramatically
|
||||
faster throughput.
|
||||
|
||||
- **Specification — Validation Gate Empty-Run Guard** (#8146): Updated `docs/specification.md`
|
||||
to document the security invariant introduced in PR #7786 (fixing issue #7508). The spec now
|
||||
explicitly states that `ApplyValidationSummary.all_required_passed` returns `False` when no
|
||||
validations have been run (empty summary), blocking apply. Added a prominent danger admonition
|
||||
block, updated the validation process results section, the `final_validation_results` data
|
||||
model description, and two milestone acceptance criteria to reflect the corrected blocking
|
||||
behavior for empty validation summaries and no-attachment runs.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
+8
-5
@@ -17,11 +17,12 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed automated implementation, bug fixes, and feature development as part of the CleverAgents automation pool.
|
||||
* HAL 9000 has contributed concurrency safety improvements, including thread-safe context tier management (issue #7547) for parallel plan execution.
|
||||
* HAL 9000 has contributed the plan concurrency race-condition fix (#7989): wired `LockService` into the plan lifecycle, guarding `execute_plan()` and `apply_plan()` with plan-level advisory locks and unique per-invocation owner identities to prevent silent concurrent state corruption.
|
||||
* HAL 9000 has contributed the bug-hunt-pool-supervisor non-blocking tracking fix: updated step 5 to be best-effort and added rule 9 to prevent the automation-tracking-manager call from blocking the main supervisor loop.
|
||||
<<<<<<< HEAD
|
||||
* HAL 9000 has contributed the bug-hunt-pool-supervisor non-blocking tracking fix (#7875 / PR #7957): updated step 5 to be best-effort and added rule 9 to prevent the automation-tracking-manager call from blocking the main supervisor loop.
|
||||
* Jeffrey Phillips Freeman has contributed the complete AUTO-BUG-POOL to AUTO-BUG-SUP tracking prefix fix across agent-system-specification.md, automation-tracking.md documentation and agent-system-specification.md spec document, replaced with correct `AUTO-BUG-SUP` prefix used by the bug-hunt-pool-supervisor agent (#7875).
|
||||
* HAL 9000 has contributed the plugin entry point security hardening fix (#7476): enforced entry point allowlist validation before importing plugin modules to prevent malicious plugin loading.
|
||||
* HAL 9000 has contributed the benchmark workflow separation (#9040): moved the benchmark-regression job out of the default PR workflow into a dedicated scheduled workflow, reducing median PR CI turnaround time from 99-132 minutes to under 30 minutes.
|
||||
* HAL 9000 has contributed the agent-evolution-pool-supervisor PR metadata assignment (#7888): the supervisor now automatically looks up the Type/Automation label and earliest open milestone before dispatching improvement PR creation workers, ensuring all generated improvement PRs have correct Type labels and milestone assignments.
|
||||
* HAL 9000 has contributed the decision recording hook for the Strategize phase (issue #8522): captures every decision point with question, chosen option, alternatives, confidence, rationale, and full context snapshot for replay and correction.
|
||||
* HAL 9000 has contributed the plan tree JSON/YAML command envelope fix (#9163): wrapped `agents plan tree --format json/yaml` output in the spec-required command envelope structure, added summary statistics, decision_ids mapping, child_plans list, and accurate timing measurement.
|
||||
* This project was made possible thanks to considerable donation of time, money, and resources by CleverThis, Inc.
|
||||
* HAL 9000 has contributed automated bug fixes, CLI output formatting improvements, and ongoing maintenance as part of the CleverAgents automation system.
|
||||
* HAL 9000 has contributed the file edit encoding parameter fix (PR #8258 / issue #7559).
|
||||
@@ -30,11 +31,13 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed the git_tools TOCTOU race condition fix (PR #8255 / issue #7619): eliminated the Time-Of-Check-To-Time-Of-Use race in `_get_base_env()` by adding double-checked locking with a module-level `threading.Lock`, preventing concurrent threads from writing conflicting environment snapshots.
|
||||
* HAL 9000 has contributed the mandatory PR compliance checklist to `implementation-supervisor.md` (#9824): added an 8-item checklist to the worker prompt body with concrete items covering CHANGELOG.md, CONTRIBUTORS.md, commit footer, CI verification, BDD tests, Epic reference, labels, and milestone assignment to eliminate systemic PR merge blockers.
|
||||
* HAL 9000 has contributed the PlanResult.success derivation fix (PR #8214 / issue #7501): replaced the incorrect `error_message is None` heuristic with a dedicated `result_success` column in the plans table, ensuring plans with historical build errors are not incorrectly marked as failed after a successful apply.
|
||||
* HAL 9000 has contributed the mandatory PR compliance checklist to `implementation-pool-supervisor.md` (#9824): created a new agent definition with an embedded 8-item checklist ensuring workers always update CHANGELOG.md, CONTRIBUTORS.md, include commit footers (`ISSUES CLOSED: #N`), verify CI passes, add BDD tests, reference the parent Epic, apply labels via forgejo-label-manager, and assign milestones before creating PRs. Includes concrete examples for each subsection and compliance verification pseudocode.
|
||||
* HAL 9000 has contributed comprehensive milestone documentation for v3.6.0 (Advanced Concepts & Deferred Features) and v3.7.0 (TUI Implementation) (PR #9903): split into sub-documents covering context strategies, LLM backends, resource types, A2A rename, container tool execution, scope chain resolution, cost/safety budgets, E2E workflow tests, code review examples, plugin architecture, TUI layout, persona system, reference/command input, session management, configuration, and TuiMaterializer integration.
|
||||
* HAL 9000 has contributed the LLMTraceRepository data-integrity fix (PR #8185 / issue #7505): replaced the unconditional `session.commit()` in `LLMTraceRepository.save()` with a dual-path implementation that respects the UnitOfWork pattern — flushing only when an external session is provided, and flushing + committing + closing when operating standalone. This eliminates premature transaction commits, loss of rollback capability, and a docstring/implementation mismatch.
|
||||
* HAL 9000 has contributed the ACMS Index Data Model and File Traversal Engine (PR #9664 / issue #9579): foundational data structures for indexed context entries with hot/warm/cold/archive storage tier classification, tag system, and a timeout-safe chunked file traversal engine for large projects with 10,000+ files.
|
||||
* HAL 9000 has contributed the error-suppression removal fix (PR #9247 / issue #9060): removed both `try...except Exception:` blocks in `register_registry_agents()` that silently suppressed errors from `actor_registry.list_actors()` and the route bridge refresh, enabling exceptions to propagate per CONTRIBUTING.md fail-fast policy. Added three Behave scenarios verifying RuntimeError, AttributeError, and TypeError propagation.
|
||||
* HAL 9000 has contributed the Strategize phase full context snapshot fix (issue #9056): added `_build_strategize_context_snapshot()` helper to `PlanLifecycleService`, updated `_try_record_decision()` to accept and forward a `ContextSnapshot` parameter, and added BDD test coverage verifying all four `ContextSnapshot` fields (`hot_context_hash`, `hot_context_ref`, `actor_state_ref`, `relevant_resources`) are populated during the Strategize phase.
|
||||
* HAL 9000 has contributed the ACMS context path matching fix (PR #10975 / issue #10972): corrects `_path_matches()` and `_matches_pattern()` to properly match absolute fragment paths against relative glob patterns by auto-prefixing with `**/` before calling `PurePath.full_match()`, preventing silent inefficacy of include/exclude filters for absolute paths in fragment metadata.
|
||||
|
||||
* HAL 9000 has contributed the real LLM actor invocation for session tell (PR #10979 / issue #5784): replaced the echo-stub in `agents session tell` with full orchestrator-based LLM invocation via `SessionWorkflow`, wiring `LangChainSessionCaller` → `ToolCallingRuntime.run_tool_loop()`. Added token usage tracking, cost estimation, streaming support, and A2A protocol compliance through the local facade. BDD test coverage includes six Be happy scenarios covering real response persistence, streaming, error handling, actor override, JSON output, and rich usage panel rendering.
|
||||
* HAL 9000 has contributed database resource types (PostgreSQL, SQLite) with transaction-based sandbox strategy: implemented ``DatabaseResourceHandler`` providing full CRUD operations (`read`, `write`, `delete`, `list_children`) and connection validation with automatic credential masking for PostgreSQL and SQLite backends. Includes ``TransactionSandbox`` infrastructure wired into ``SandboxFactory``, BDD test coverage in ``features/database_resources.feature``, and Robot Framework integration tests in ``robot/database_resources.robot`` (PR #10591 / issue #8608, Epic #8568).
|
||||
* HAL 9000 has contributed the agents plan rollback command (PR #8674 / issue #8557): implemented checkpoint-based plan state restoration with the `agents plan rollback <plan-id> [<checkpoint-id>]` CLI command as part of Epic #8493, enabling plans to be restored to previous checkpoints, discarding post-checkpoint decisions, and resuming execution from the rolled-back state. Supported by `--yes/-y`, `--to-checkpoint`, and `--format/-f` flags. Includes comprehensive BDD test coverage (>= 97%) for rollback, decision discarding, and plan resume functionality.
|
||||
* HAL 9000 has contributed the PyYAML security upgrade (PR #11012 / issue #9055): added `pyyaml>=6.0.3` dependency constraint to address known YAML parsing vulnerabilities.
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
"""ASV benchmarks for circuit breaker pattern.
|
||||
|
||||
Measures the overhead of circuit breaker state management,
|
||||
state transitions, and fast-fail latency in open state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from cleveragents.core.circuit_breaker import (
|
||||
CircuitBreaker,
|
||||
CircuitBreakerOpen,
|
||||
CircuitBreakerState,
|
||||
)
|
||||
|
||||
|
||||
class CircuitBreakerClosedStateBench:
|
||||
"""Benchmark circuit breaker overhead in closed state."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up circuit breaker in closed state."""
|
||||
self.breaker = CircuitBreaker(
|
||||
failure_threshold=5,
|
||||
recovery_timeout=60.0,
|
||||
expected_exception=Exception,
|
||||
half_open_max_successes=2,
|
||||
cooldown_seconds=30.0,
|
||||
name="test_service",
|
||||
)
|
||||
|
||||
def time_call_success(self) -> None:
|
||||
"""Benchmark successful call overhead in closed state."""
|
||||
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
self.breaker.call(dummy_func)
|
||||
|
||||
def time_call_with_args(self) -> None:
|
||||
"""Benchmark call with arguments in closed state."""
|
||||
|
||||
def dummy_func(x: int, y: str) -> str:
|
||||
return f"{x}:{y}"
|
||||
|
||||
self.breaker.call(dummy_func, 42, "test")
|
||||
|
||||
def time_state_check(self) -> None:
|
||||
"""Benchmark state property access."""
|
||||
_ = self.breaker.state
|
||||
|
||||
def time_failure_count_check(self) -> None:
|
||||
"""Benchmark failure count property access."""
|
||||
_ = self.breaker.failure_count
|
||||
|
||||
|
||||
class CircuitBreakerOpenStateBench:
|
||||
"""Benchmark circuit breaker fast-fail latency in open state."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up circuit breaker in open state."""
|
||||
self.breaker = CircuitBreaker(
|
||||
failure_threshold=2,
|
||||
recovery_timeout=60.0,
|
||||
expected_exception=Exception,
|
||||
half_open_max_successes=2,
|
||||
cooldown_seconds=30.0,
|
||||
name="test_service",
|
||||
)
|
||||
# Force the breaker into open state
|
||||
for _ in range(3):
|
||||
try:
|
||||
self.breaker.call(lambda: 1 / 0) # Raise ZeroDivisionError
|
||||
except ZeroDivisionError:
|
||||
pass
|
||||
|
||||
def time_fast_fail(self) -> None:
|
||||
"""Benchmark fast-fail latency when circuit is open."""
|
||||
try:
|
||||
self.breaker.call(lambda: "should not execute")
|
||||
except CircuitBreakerOpen:
|
||||
pass
|
||||
|
||||
def time_open_state_check(self) -> None:
|
||||
"""Benchmark state check when open."""
|
||||
assert self.breaker.state == CircuitBreakerState.OPEN
|
||||
|
||||
|
||||
class CircuitBreakerClosedToOpenTransitionBench:
|
||||
"""Benchmark closed-to-open state transition overhead."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up a fresh circuit breaker in closed state."""
|
||||
self.breaker = CircuitBreaker(
|
||||
failure_threshold=2,
|
||||
recovery_timeout=60.0,
|
||||
expected_exception=Exception,
|
||||
half_open_max_successes=1,
|
||||
cooldown_seconds=30.0,
|
||||
name="test_service",
|
||||
)
|
||||
|
||||
def time_closed_to_open_transition(self) -> None:
|
||||
"""Benchmark transition from closed to open state."""
|
||||
# Trigger failures to transition to open
|
||||
for _ in range(3):
|
||||
try:
|
||||
self.breaker.call(lambda: 1 / 0)
|
||||
except ZeroDivisionError:
|
||||
pass
|
||||
|
||||
|
||||
class CircuitBreakerOpenToHalfOpenTransitionBench:
|
||||
"""Benchmark open-to-half-open state transition overhead."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up circuit breaker already in open state."""
|
||||
self.breaker = CircuitBreaker(
|
||||
failure_threshold=2,
|
||||
recovery_timeout=0.05, # Very short timeout
|
||||
expected_exception=Exception,
|
||||
half_open_max_successes=1,
|
||||
cooldown_seconds=0.01,
|
||||
name="test_service",
|
||||
)
|
||||
# Force to open state
|
||||
for _ in range(3):
|
||||
try:
|
||||
self.breaker.call(lambda: 1 / 0)
|
||||
except ZeroDivisionError:
|
||||
pass
|
||||
# Wait for recovery timeout so transition to half-open is possible
|
||||
time.sleep(0.1)
|
||||
|
||||
def time_open_to_half_open_transition(self) -> None:
|
||||
"""Benchmark transition from open to half-open state."""
|
||||
# Attempt call to trigger half-open transition
|
||||
try:
|
||||
self.breaker.call(lambda: "success")
|
||||
except CircuitBreakerOpen:
|
||||
pass
|
||||
|
||||
|
||||
class CircuitBreakerHalfOpenToClosedTransitionBench:
|
||||
"""Benchmark half-open-to-closed state transition overhead."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up circuit breaker already in half-open state."""
|
||||
self.breaker = CircuitBreaker(
|
||||
failure_threshold=2,
|
||||
recovery_timeout=0.05,
|
||||
expected_exception=Exception,
|
||||
half_open_max_successes=1,
|
||||
cooldown_seconds=0.01,
|
||||
name="test_service",
|
||||
)
|
||||
# Force to open state
|
||||
for _ in range(3):
|
||||
try:
|
||||
self.breaker.call(lambda: 1 / 0)
|
||||
except ZeroDivisionError:
|
||||
pass
|
||||
# Wait for recovery timeout so breaker enters half-open on next call
|
||||
time.sleep(0.1)
|
||||
|
||||
def time_half_open_to_closed_transition(self) -> None:
|
||||
"""Benchmark transition from half-open to closed state."""
|
||||
# Successful call in half-open should close the breaker
|
||||
self.breaker.call(lambda: "success")
|
||||
|
||||
|
||||
class CircuitBreakerAsyncBench:
|
||||
"""Benchmark async circuit breaker operations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up async circuit breakers."""
|
||||
self.breaker = CircuitBreaker(
|
||||
failure_threshold=5,
|
||||
recovery_timeout=60.0,
|
||||
expected_exception=Exception,
|
||||
half_open_max_successes=2,
|
||||
cooldown_seconds=30.0,
|
||||
name="test_service",
|
||||
)
|
||||
# Set up a separate breaker already in open state for fast-fail benchmark
|
||||
self.open_breaker = CircuitBreaker(
|
||||
failure_threshold=2,
|
||||
recovery_timeout=60.0,
|
||||
expected_exception=Exception,
|
||||
half_open_max_successes=2,
|
||||
cooldown_seconds=30.0,
|
||||
name="test_service_open",
|
||||
)
|
||||
for _ in range(3):
|
||||
try:
|
||||
self.open_breaker.call(lambda: 1 / 0)
|
||||
except ZeroDivisionError:
|
||||
pass
|
||||
|
||||
def time_async_call_success(self) -> None:
|
||||
"""Benchmark successful async call overhead."""
|
||||
|
||||
async def dummy_async() -> str:
|
||||
return "success"
|
||||
|
||||
asyncio.run(self.breaker.async_call(dummy_async))
|
||||
|
||||
def time_async_call_with_args(self) -> None:
|
||||
"""Benchmark async call with arguments."""
|
||||
|
||||
async def dummy_async(x: int, y: str) -> str:
|
||||
return f"{x}:{y}"
|
||||
|
||||
asyncio.run(self.breaker.async_call(dummy_async, 42, "test"))
|
||||
|
||||
def time_async_fast_fail(self) -> None:
|
||||
"""Benchmark async fast-fail when open."""
|
||||
|
||||
async def dummy_async() -> str:
|
||||
return "should not execute"
|
||||
|
||||
try:
|
||||
asyncio.run(self.open_breaker.async_call(dummy_async))
|
||||
except CircuitBreakerOpen:
|
||||
pass
|
||||
|
||||
|
||||
class CircuitBreakerInitializationBench:
|
||||
"""Benchmark circuit breaker initialization overhead."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_default_initialization(self) -> None:
|
||||
"""Benchmark default initialization."""
|
||||
CircuitBreaker()
|
||||
|
||||
def time_custom_initialization(self) -> None:
|
||||
"""Benchmark initialization with custom parameters."""
|
||||
CircuitBreaker(
|
||||
failure_threshold=10,
|
||||
recovery_timeout=120.0,
|
||||
expected_exception=ValueError,
|
||||
half_open_max_successes=3,
|
||||
cooldown_seconds=45.0,
|
||||
name="custom_service",
|
||||
)
|
||||
|
||||
def time_multiple_breakers(self) -> None:
|
||||
"""Benchmark creating multiple breakers."""
|
||||
for i in range(10):
|
||||
CircuitBreaker(name=f"service_{i}")
|
||||
@@ -0,0 +1,280 @@
|
||||
"""ASV benchmarks for retry patterns.
|
||||
|
||||
Measures the overhead of retry decorator construction and
|
||||
happy-path invocation for the public retry factory functions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from cleveragents.core.retry_patterns import (
|
||||
get_retry_decorator,
|
||||
retry_on_result,
|
||||
retry_with_exponential_backoff,
|
||||
retry_with_jitter,
|
||||
retry_with_timeout,
|
||||
should_retry_result,
|
||||
)
|
||||
|
||||
|
||||
class RetryDecoratorConstructionBench:
|
||||
"""Benchmark retry decorator construction overhead."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_retry_with_exponential_backoff_construction(self) -> None:
|
||||
"""Benchmark constructing exponential backoff decorator."""
|
||||
|
||||
@retry_with_exponential_backoff(max_attempts=3)
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_retry_with_jitter_construction(self) -> None:
|
||||
"""Benchmark constructing jitter decorator."""
|
||||
|
||||
@retry_with_jitter(max_attempts=3)
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_retry_with_timeout_construction(self) -> None:
|
||||
"""Benchmark constructing timeout decorator."""
|
||||
|
||||
@retry_with_timeout(timeout_seconds=5.0)
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_retry_on_result_construction(self) -> None:
|
||||
"""Benchmark constructing result-based retry decorator."""
|
||||
|
||||
@retry_on_result(should_retry_result, max_attempts=3)
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_get_retry_decorator_network(self) -> None:
|
||||
"""Benchmark constructing decorator via factory for network category."""
|
||||
get_retry_decorator("network")
|
||||
|
||||
def time_get_retry_decorator_provider(self) -> None:
|
||||
"""Benchmark constructing decorator via factory for provider category."""
|
||||
get_retry_decorator("provider")
|
||||
|
||||
def time_get_retry_decorator_database(self) -> None:
|
||||
"""Benchmark constructing decorator via factory for database category."""
|
||||
get_retry_decorator("database")
|
||||
|
||||
def time_get_retry_decorator_unknown(self) -> None:
|
||||
"""Benchmark constructing decorator via factory for unknown category."""
|
||||
get_retry_decorator("unknown_category")
|
||||
|
||||
|
||||
class RetryDecoratorInvocationBench:
|
||||
"""Benchmark happy-path retry decorator invocation."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up decorated functions."""
|
||||
|
||||
@retry_with_exponential_backoff(max_attempts=3)
|
||||
def exponential_func() -> str:
|
||||
return "success"
|
||||
|
||||
@retry_with_jitter(max_attempts=3)
|
||||
def jitter_func() -> str:
|
||||
return "success"
|
||||
|
||||
@retry_with_timeout(timeout_seconds=5.0)
|
||||
def timeout_func() -> str:
|
||||
return "success"
|
||||
|
||||
@retry_on_result(should_retry_result, max_attempts=3)
|
||||
def result_func() -> str:
|
||||
return "success"
|
||||
|
||||
self.exponential_func = exponential_func
|
||||
self.jitter_func = jitter_func
|
||||
self.timeout_func = timeout_func
|
||||
self.result_func = result_func
|
||||
|
||||
def time_exponential_backoff_invocation(self) -> None:
|
||||
"""Benchmark exponential backoff invocation (happy path)."""
|
||||
self.exponential_func()
|
||||
|
||||
def time_jitter_invocation(self) -> None:
|
||||
"""Benchmark jitter invocation (happy path)."""
|
||||
self.jitter_func()
|
||||
|
||||
def time_timeout_invocation(self) -> None:
|
||||
"""Benchmark timeout invocation (happy path)."""
|
||||
self.timeout_func()
|
||||
|
||||
def time_result_based_invocation(self) -> None:
|
||||
"""Benchmark result-based retry invocation (happy path)."""
|
||||
self.result_func()
|
||||
|
||||
|
||||
class RetryDecoratorAsyncBench:
|
||||
"""Benchmark async retry decorator operations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up async decorated functions."""
|
||||
|
||||
@retry_with_exponential_backoff(max_attempts=3)
|
||||
async def async_exponential() -> str:
|
||||
return "success"
|
||||
|
||||
@retry_with_jitter(max_attempts=3)
|
||||
async def async_jitter() -> str:
|
||||
return "success"
|
||||
|
||||
@retry_with_timeout(timeout_seconds=5.0)
|
||||
async def async_timeout() -> str:
|
||||
return "success"
|
||||
|
||||
self.async_exponential = async_exponential
|
||||
self.async_jitter = async_jitter
|
||||
self.async_timeout = async_timeout
|
||||
|
||||
def time_async_exponential_invocation(self) -> None:
|
||||
"""Benchmark async exponential backoff invocation."""
|
||||
asyncio.run(self.async_exponential())
|
||||
|
||||
def time_async_jitter_invocation(self) -> None:
|
||||
"""Benchmark async jitter invocation."""
|
||||
asyncio.run(self.async_jitter())
|
||||
|
||||
def time_async_timeout_invocation(self) -> None:
|
||||
"""Benchmark async timeout invocation."""
|
||||
asyncio.run(self.async_timeout())
|
||||
|
||||
|
||||
class RetryDecoratorWithArgumentsBench:
|
||||
"""Benchmark retry decorators with function arguments."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up decorated functions with arguments."""
|
||||
|
||||
@retry_with_exponential_backoff(max_attempts=3)
|
||||
def func_with_args(x: int, y: str, z: float = 1.0) -> str:
|
||||
return f"{x}:{y}:{z}"
|
||||
|
||||
@retry_with_jitter(max_attempts=3)
|
||||
def func_with_kwargs(a: int, b: str = "default") -> str:
|
||||
return f"{a}:{b}"
|
||||
|
||||
self.func_with_args = func_with_args
|
||||
self.func_with_kwargs = func_with_kwargs
|
||||
|
||||
def time_exponential_with_positional_args(self) -> None:
|
||||
"""Benchmark exponential backoff with positional arguments."""
|
||||
self.func_with_args(42, "test", 2.5)
|
||||
|
||||
def time_exponential_with_keyword_args(self) -> None:
|
||||
"""Benchmark exponential backoff with keyword arguments."""
|
||||
self.func_with_args(x=42, y="test", z=2.5)
|
||||
|
||||
def time_jitter_with_mixed_args(self) -> None:
|
||||
"""Benchmark jitter with mixed positional and keyword arguments."""
|
||||
self.func_with_kwargs(100, b="custom")
|
||||
|
||||
|
||||
class RetryDecoratorFactoryBench:
|
||||
"""Benchmark retry decorator factory function."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_factory_network_category(self) -> None:
|
||||
"""Benchmark factory with network category."""
|
||||
decorator = get_retry_decorator("network")
|
||||
|
||||
@decorator
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_factory_provider_category(self) -> None:
|
||||
"""Benchmark factory with provider category."""
|
||||
decorator = get_retry_decorator("provider")
|
||||
|
||||
@decorator
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_factory_database_category(self) -> None:
|
||||
"""Benchmark factory with database category."""
|
||||
decorator = get_retry_decorator("database")
|
||||
|
||||
@decorator
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_factory_file_operation_category(self) -> None:
|
||||
"""Benchmark factory with file_operation category."""
|
||||
decorator = get_retry_decorator("file_operation")
|
||||
|
||||
@decorator
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
|
||||
class RetryDecoratorConfigurationBench:
|
||||
"""Benchmark retry decorator with various configurations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_minimal_config(self) -> None:
|
||||
"""Benchmark decorator with minimal configuration."""
|
||||
|
||||
@retry_with_exponential_backoff()
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_aggressive_config(self) -> None:
|
||||
"""Benchmark decorator with aggressive retry settings."""
|
||||
|
||||
@retry_with_exponential_backoff(max_attempts=10)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_conservative_config(self) -> None:
|
||||
"""Benchmark decorator with conservative retry settings."""
|
||||
|
||||
@retry_with_exponential_backoff(max_attempts=1)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_long_timeout_config(self) -> None:
|
||||
"""Benchmark decorator with long timeout."""
|
||||
|
||||
@retry_with_timeout(timeout_seconds=300.0)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_short_timeout_config(self) -> None:
|
||||
"""Benchmark decorator with short timeout."""
|
||||
|
||||
@retry_with_timeout(timeout_seconds=0.1)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
@@ -0,0 +1,367 @@
|
||||
"""ASV benchmarks for service-level retry patterns.
|
||||
|
||||
Measures the overhead of retry_service_operation decorator
|
||||
and retry_auto_debug happy-path latency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from cleveragents.core.circuit_breaker import CircuitBreaker
|
||||
from cleveragents.core.retry_service_patterns import (
|
||||
RetryContext,
|
||||
is_read_only_plan_operation,
|
||||
retry_auto_debug,
|
||||
retry_service_operation,
|
||||
)
|
||||
|
||||
|
||||
class RetryServiceOperationDecoratorBench:
|
||||
"""Benchmark retry_service_operation decorator overhead."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_decorator_construction_sync(self) -> None:
|
||||
"""Benchmark constructing sync service retry decorator."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
)
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_decorator_construction_async(self) -> None:
|
||||
"""Benchmark constructing async service retry decorator."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
)
|
||||
async def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_decorator_with_circuit_breaker(self) -> None:
|
||||
"""Benchmark decorator with circuit breaker instance."""
|
||||
cb = CircuitBreaker(
|
||||
failure_threshold=5,
|
||||
recovery_timeout=60.0,
|
||||
name="test_service",
|
||||
)
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
circuit_breaker=cb,
|
||||
)
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_decorator_with_exponential_strategy(self) -> None:
|
||||
"""Benchmark decorator with exponential backoff strategy."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
backoff_strategy="exponential",
|
||||
base_delay=1.0,
|
||||
max_delay=60.0,
|
||||
)
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
|
||||
class RetryServiceOperationInvocationBench:
|
||||
"""Benchmark happy-path service retry invocation."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up decorated service operations."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
)
|
||||
def sync_op() -> str:
|
||||
return "success"
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
)
|
||||
async def async_op() -> str:
|
||||
return "success"
|
||||
|
||||
cb = CircuitBreaker(
|
||||
failure_threshold=5,
|
||||
recovery_timeout=60.0,
|
||||
name="test_service",
|
||||
)
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
circuit_breaker=cb,
|
||||
)
|
||||
def breaker_op() -> str:
|
||||
return "success"
|
||||
|
||||
self.sync_op = sync_op
|
||||
self.async_op = async_op
|
||||
self.breaker_op = breaker_op
|
||||
|
||||
def time_sync_operation_invocation(self) -> None:
|
||||
"""Benchmark sync service operation invocation (happy path)."""
|
||||
self.sync_op()
|
||||
|
||||
def time_async_operation_invocation(self) -> None:
|
||||
"""Benchmark async service operation invocation (happy path)."""
|
||||
asyncio.run(self.async_op())
|
||||
|
||||
def time_operation_with_circuit_breaker(self) -> None:
|
||||
"""Benchmark service operation with circuit breaker (happy path)."""
|
||||
self.breaker_op()
|
||||
|
||||
|
||||
class RetryServiceOperationWithArgumentsBench:
|
||||
"""Benchmark service retry with function arguments."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up decorated operations with arguments."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="fetch_data",
|
||||
max_attempts=3,
|
||||
)
|
||||
def fetch_data(
|
||||
resource_id: int, include_metadata: bool = False
|
||||
) -> dict[str, Any]:
|
||||
return {"id": resource_id, "data": "test"}
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="update_data",
|
||||
max_attempts=3,
|
||||
)
|
||||
async def update_data(
|
||||
resource_id: int, payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
return {"id": resource_id, "updated": True}
|
||||
|
||||
self.fetch_data = fetch_data
|
||||
self.update_data = update_data
|
||||
|
||||
def time_sync_with_positional_args(self) -> None:
|
||||
"""Benchmark sync operation with positional arguments."""
|
||||
self.fetch_data(42, True)
|
||||
|
||||
def time_sync_with_keyword_args(self) -> None:
|
||||
"""Benchmark sync operation with keyword arguments."""
|
||||
self.fetch_data(resource_id=42, include_metadata=True)
|
||||
|
||||
def time_async_with_complex_payload(self) -> None:
|
||||
"""Benchmark async operation with complex payload."""
|
||||
payload = {"key": "value", "nested": {"data": [1, 2, 3]}}
|
||||
asyncio.run(self.update_data(42, payload))
|
||||
|
||||
|
||||
class RetryAutoDebugBench:
|
||||
"""Benchmark retry_auto_debug decorator."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_auto_debug_construction(self) -> None:
|
||||
"""Benchmark constructing auto-debug decorator."""
|
||||
|
||||
@retry_auto_debug(max_debug_attempts=3)
|
||||
async def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_auto_debug_invocation(self) -> None:
|
||||
"""Benchmark auto-debug invocation (happy path)."""
|
||||
|
||||
@retry_auto_debug(max_debug_attempts=3)
|
||||
async def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
asyncio.run(dummy_func())
|
||||
|
||||
def time_auto_debug_single_attempt(self) -> None:
|
||||
"""Benchmark auto-debug with single attempt."""
|
||||
|
||||
@retry_auto_debug(max_debug_attempts=1)
|
||||
async def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
asyncio.run(dummy_func())
|
||||
|
||||
def time_auto_debug_multiple_attempts(self) -> None:
|
||||
"""Benchmark auto-debug with multiple attempts configured."""
|
||||
|
||||
@retry_auto_debug(max_debug_attempts=5)
|
||||
async def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
asyncio.run(dummy_func())
|
||||
|
||||
|
||||
class RetryContextBench:
|
||||
"""Benchmark RetryContext operations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_context_creation(self) -> None:
|
||||
"""Benchmark creating a retry context."""
|
||||
RetryContext(
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
)
|
||||
|
||||
def time_context_with_custom_wait(self) -> None:
|
||||
"""Benchmark context with custom wait strategy."""
|
||||
from tenacity import wait_exponential
|
||||
|
||||
RetryContext(
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
wait_strategy=wait_exponential(multiplier=1, min=1, max=30),
|
||||
)
|
||||
|
||||
def time_context_property_access(self) -> None:
|
||||
"""Benchmark accessing context properties."""
|
||||
ctx = RetryContext(
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
)
|
||||
_ = ctx.operation_name
|
||||
_ = ctx.max_attempts
|
||||
_ = ctx.attempt_count
|
||||
|
||||
def time_context_execute(self) -> None:
|
||||
"""Benchmark executing a function via RetryContext."""
|
||||
ctx = RetryContext(
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
)
|
||||
ctx.execute(lambda: "success")
|
||||
|
||||
|
||||
class IsReadOnlyPlanOperationBench:
|
||||
"""Benchmark is_read_only_plan_operation utility."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_check_strategize(self) -> None:
|
||||
"""Benchmark checking strategize operation."""
|
||||
is_read_only_plan_operation({"plan_phase": "strategize"})
|
||||
|
||||
def time_check_plan(self) -> None:
|
||||
"""Benchmark checking plan operation."""
|
||||
is_read_only_plan_operation({"plan_phase": "plan"})
|
||||
|
||||
def time_check_validate(self) -> None:
|
||||
"""Benchmark checking validate operation."""
|
||||
is_read_only_plan_operation({"plan_phase": "validate"})
|
||||
|
||||
def time_check_review(self) -> None:
|
||||
"""Benchmark checking review operation."""
|
||||
is_read_only_plan_operation({"plan_phase": "review"})
|
||||
|
||||
def time_check_preview(self) -> None:
|
||||
"""Benchmark checking preview operation."""
|
||||
is_read_only_plan_operation({"plan_phase": "preview"})
|
||||
|
||||
def time_check_check(self) -> None:
|
||||
"""Benchmark checking check operation."""
|
||||
is_read_only_plan_operation({"plan_phase": "check"})
|
||||
|
||||
def time_check_execute(self) -> None:
|
||||
"""Benchmark checking execute operation (not read-only)."""
|
||||
is_read_only_plan_operation({"plan_phase": "execute"})
|
||||
|
||||
def time_check_read_only_flag(self) -> None:
|
||||
"""Benchmark checking read_only flag."""
|
||||
is_read_only_plan_operation({"read_only": True})
|
||||
|
||||
def time_check_unknown_operation(self) -> None:
|
||||
"""Benchmark checking unknown operation."""
|
||||
is_read_only_plan_operation({"plan_phase": "unknown_op"})
|
||||
|
||||
def time_check_empty_kwargs(self) -> None:
|
||||
"""Benchmark checking empty kwargs."""
|
||||
is_read_only_plan_operation({})
|
||||
|
||||
|
||||
class RetryServiceOperationStrategyBench:
|
||||
"""Benchmark different retry strategies in service operations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_exponential_strategy(self) -> None:
|
||||
"""Benchmark exponential strategy."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
backoff_strategy="exponential",
|
||||
)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_linear_strategy(self) -> None:
|
||||
"""Benchmark linear strategy."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
backoff_strategy="linear",
|
||||
)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_fixed_strategy(self) -> None:
|
||||
"""Benchmark fixed strategy."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
backoff_strategy="fixed",
|
||||
)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_jitter_strategy(self) -> None:
|
||||
"""Benchmark jitter strategy."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
backoff_strategy="jitter",
|
||||
)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
@@ -665,7 +665,7 @@ The Automation Tracking Manager provides eleven operations:
|
||||
| `AUTO-IMP-POOL` | Implementation Pool Supervisor |
|
||||
| `AUTO-REV-POOL` | PR Review Pool Supervisor |
|
||||
| `AUTO-UAT-POOL` | UAT Test Pool Supervisor |
|
||||
| `AUTO-BUG-POOL` | Bug Hunt Pool Supervisor |
|
||||
| `AUTO-BUG-SUP` | Bug Hunt Pool Supervisor |
|
||||
| `AUTO-INF-POOL` | Test Infrastructure Pool Supervisor |
|
||||
| `AUTO-ARCH` | Architecture Supervisor |
|
||||
| `AUTO-EPIC` | Epic Planning Supervisor |
|
||||
@@ -693,7 +693,7 @@ The following table maps between the two schemes for agents where they differ:
|
||||
| Implementation Pool | `AUTO-IMP-SUP` | `AUTO-IMP-POOL` |
|
||||
| PR Review Pool | `AUTO-REV-SUP` | `AUTO-REV-POOL` |
|
||||
| UAT Test Pool | `AUTO-UAT-SUP` | `AUTO-UAT-POOL` |
|
||||
| Bug Hunt Pool | `AUTO-BUG-SUP` | `AUTO-BUG-POOL` |
|
||||
| Bug Hunt Pool | `AUTO-BUG-SUP` | `AUTO-BUG-SUP` |
|
||||
| Test Infrastructure Pool | `AUTO-INF-SUP` | `AUTO-INF-POOL` |
|
||||
| Human Liaison | `AUTO-HUMAN` | `AUTO-LIAISON` |
|
||||
| Grooming | `AUTO-GROOM` | `AUTO-GROOMER` |
|
||||
@@ -1869,7 +1869,7 @@ Only critical bugs get assigned to the active milestone. Non-critical issues go
|
||||
| **Mode** | `subagent` |
|
||||
| **Model** | `google/gemini-2.5-pro` |
|
||||
| **Temperature** | 0.1 |
|
||||
| **Tracking Prefix** | `AUTO-BUG-POOL` |
|
||||
| **Tracking Prefix** | `AUTO-BUG-SUP` |
|
||||
| **Worker Count** | N/4 (quarter allocation) |
|
||||
|
||||
#### 8.6.1 Purpose
|
||||
@@ -3164,7 +3164,7 @@ Defines how ALL agents discover, interact with, and coordinate through the autom
|
||||
|
||||
### 13.9 Bug Hunter Tracking Update (`shared/bug_hunter_tracking_update.md`)
|
||||
|
||||
Defines the migration guide for converting the Bug Hunt supervisor from the old session state system to the new individual tracking issue system. Documents the tracking issue format `[AUTO-BUG-POOL] Bug Detection Pool Status (Cycle N)`, the cleanup protocol (one issue per cycle, preserve announcements), and worker coordination via announcement issues.
|
||||
Defines the migration guide for converting the Bug Hunt supervisor from the old session state system to the new individual tracking issue system. Documents the tracking issue format `[AUTO-BUG-SUP] Bug Hunt Status (Cycle 25)`, the cleanup protocol (one issue per cycle, preserve announcements), and worker coordination via announcement issues.
|
||||
|
||||
---
|
||||
|
||||
@@ -6767,7 +6767,7 @@ The system uses five distinct redundancy patterns:
|
||||
| 4 | pr-merge-pool-supervisor | subagent | claude-sonnet-4-6 | AUTO-MERGE | Singleton Supervisor |
|
||||
| 5 | pr-fix-pool-supervisor | *(removed — absorbed into implementation-pool-supervisor)* | -- | -- | -- |
|
||||
| 6 | uat-test-pool-supervisor | subagent | claude-sonnet-4-6 | AUTO-UAT-POOL | Pool Supervisor |
|
||||
| 7 | bug-hunt-pool-supervisor | subagent | gemini-2.5-pro | AUTO-BUG-POOL | Pool Supervisor |
|
||||
| 7 | bug-hunt-pool-supervisor | subagent | gemini-2.5-pro | AUTO-BUG-SUP | Pool Supervisor |
|
||||
| 8 | test-infra-pool-supervisor | subagent | gemini-2.5-pro | AUTO-INF-POOL | Pool Supervisor |
|
||||
| 9 | architecture-pool-supervisor | subagent | claude-sonnet-4-6 | AUTO-ARCH | Singleton Supervisor |
|
||||
| 10 | epic-planning-pool-supervisor | subagent | claude-sonnet-4-6 | AUTO-EPIC | Singleton Supervisor |
|
||||
|
||||
@@ -61,7 +61,7 @@ All prefixes are registered in the `automation-tracking-manager` subagent, which
|
||||
| uat-test-pool-supervisor | `AUTO-UAT-POOL` | `[AUTO-UAT-POOL] UAT Status (Cycle 6)` |
|
||||
| project-owner-pool-supervisor | `AUTO-PROJ-OWN` | `[AUTO-PROJ-OWN] Project Status (Cycle 11)` |
|
||||
| agent-evolution-pool-supervisor | `AUTO-EVLV` | `[AUTO-EVLV] Agent Evolution Report (Cycle 10)` |
|
||||
| bug-hunt-pool-supervisor | `AUTO-BUG-POOL` | `[AUTO-BUG-POOL] Bug Detection Pool Status (Cycle 60)` |
|
||||
| bug-hunt-pool-supervisor | `AUTO-BUG-SUP` | `[AUTO-BUG-SUP] Bug Hunt Status (Cycle 25)` |
|
||||
| spec-update-pool-supervisor | `AUTO-SPEC` | `[AUTO-SPEC] Specification Update Report (Cycle 1)` |
|
||||
| test-infra-pool-supervisor | `AUTO-INF-POOL` | `[AUTO-INF-POOL] Infrastructure Analysis Report (Cycle 2)` |
|
||||
| epic-planner | `AUTO-EPIC` | `[AUTO-EPIC] Epic Planning Update (Cycle 9)` |
|
||||
@@ -394,7 +394,7 @@ label:"Automation Tracking" [AUTO-UAT-POOL] in:title
|
||||
label:"Automation Tracking" [AUTO-PROJ-OWN] in:title
|
||||
label:"Automation Tracking" [AUTO-EVLV] in:title
|
||||
label:"Automation Tracking" [AUTO-EPIC] in:title
|
||||
label:"Automation Tracking" [AUTO-BUG-POOL] in:title
|
||||
label:"Automation Tracking" [AUTO-BUG-SUP] in:title
|
||||
label:"Automation Tracking" [AUTO-SPEC] in:title
|
||||
label:"Automation Tracking" [AUTO-INF-POOL] in:title
|
||||
```
|
||||
|
||||
+17
-4
@@ -20019,6 +20019,18 @@ Apply should perform (configurable) validations before committing:
|
||||
|
||||
Validation runs during **Execute**, not during Apply. By the time a plan reaches the Apply phase, all required validations have already passed. Apply commits the sandbox changes to the real resources and does not re-run validations.
|
||||
|
||||
!!! danger "Security Invariant — Empty Validation Summary Blocks Apply"
|
||||
Apply **requires** that at least one required validation was executed during the Execute phase. If no validations were run — because no validations are attached to the plan's resources, or because the validation collection step produced an empty set — the apply gate **blocks** and the plan cannot proceed to Apply.
|
||||
|
||||
This is an intentional security invariant (introduced in PR #7786, fixing issue #7508): apply cannot proceed without at least one validation having run. A plan with no validation attachments is treated the same as a plan with failing validations — it cannot be applied.
|
||||
|
||||
**Blocking conditions for the apply gate**:
|
||||
- Any required validation returned `passed: false` (validation failure)
|
||||
- No required validations were executed at all (empty validation summary)
|
||||
- A plan has no validation attachments (no validations configured)
|
||||
|
||||
The `ApplyValidationSummary.all_required_passed` property returns `True` **only when** at least one required validation was executed AND none of the required validations failed. An empty summary always yields `False`.
|
||||
|
||||
For full details on validation — including the Validation type system, modes, attachment scoping, failure handling, fix-then-revalidate loops, and data model — see the **Validation** section under Core Concepts.
|
||||
|
||||
#### Apply Data Model
|
||||
@@ -22757,7 +22769,8 @@ Validation is the **final step** of the Execute phase. The execution actor's wor
|
||||
2. **Collect applicable validations**: The system resolves all validations from the resource-direct, project, and plan scopes (as described in Attachment Resolution above).
|
||||
3. **Execute validations**: Each applicable validation is invoked as a standard tool call. The validation reads the sandbox state and returns its structured result. Validations may be run in parallel since they are read-only and cannot interfere with each other.
|
||||
4. **Process results**:
|
||||
- **All required validations pass**: Execution is complete. The plan transitions to the review/Apply phase.
|
||||
- **All required validations pass (and at least one ran)**: Execution is complete. The plan transitions to the review/Apply phase.
|
||||
- **No required validations were executed (empty summary)**: The apply gate is blocked. This is treated equivalently to a required validation failure — the plan cannot proceed to Apply. This condition arises when no validations are attached to the plan's resources. See the security invariant note in [Validation in Apply](#validation-in-apply).
|
||||
- **Any required validation fails**: The execution actor enters the fix-then-revalidate loop (see Validation Failure Handling below).
|
||||
- **Informational validations fail**: Results are recorded in the plan's validation summary. No fix attempts are made.
|
||||
|
||||
@@ -23076,7 +23089,7 @@ The validation-related fields stored in the plan data model:
|
||||
| Field | Location | Description |
|
||||
|-------|----------|-------------|
|
||||
| `validation_summary` | `plan.execution` | Array of validation results collected during Execute. Each entry includes the validation name, mode, `passed`, `message`, `data`, execution duration, and attempt number. |
|
||||
| `final_validation_results` | `plan.apply` | Snapshot of the final validation state at the time execution completed (all required validations passing). Identical to the last state of `validation_summary` but stored separately for quick reference. |
|
||||
| `final_validation_results` | `plan.apply` | Snapshot of the final validation state at the time execution completed (all required validations passing, and at least one required validation was executed). Identical to the last state of `validation_summary` but stored separately for quick reference. A plan can only reach the Apply phase if this snapshot is non-empty and all required entries have `passed: true`. |
|
||||
| `validation_attempts` | `plan.execution` | Total number of validation invocations across all fix-then-revalidate loops. Useful for understanding how much effort was spent on validation remediation. |
|
||||
| `validation_fix_history` | `plan.execution` | Per-validation log of fix attempts. Each entry records: the validation that failed, the fix the actor applied, and whether the subsequent re-validation passed. Enables auditing of the fix-then-revalidate process. |
|
||||
|
||||
@@ -47123,7 +47136,7 @@ This section defines the ordered milestone plan for CleverAgents v3.x, mapping a
|
||||
| 6 | Hierarchical decomposition creates 4+ levels of subplans | §Subplan Architecture — Hierarchy | `plan tree` shows 4+ nesting levels for complex tasks |
|
||||
| 7 | Parallel execution scales to 10+ concurrent subplans | §Subplan Execution — Parallel | 10 subplans execute concurrently without deadlock or resource starvation |
|
||||
| 8 | Decision correction recomputes only affected subtree | §Correction Model — Selective Recomputation | Unaffected decisions preserved; only downstream decisions recomputed |
|
||||
| 9 | Validation-gated apply: required validations must pass before apply | §Validation Abstraction — Apply Gating | Apply blocked when required validation fails; informational validation does not block |
|
||||
| 9 | Validation-gated apply: required validations must pass before apply | §Validation Abstraction — Apply Gating | Apply blocked when required validation fails; apply also blocked when no validations were run (empty summary); informational validation does not block |
|
||||
| 10 | A realistic porting task completes autonomously | §Autonomy Acceptance | End-to-end task (e.g., port a Python module) completes without human intervention |
|
||||
| 11 | `agents automation-profile add/list/show` functional | §CLI Commands — automation-profile | Round-trip: add profile, list shows it, show displays details |
|
||||
| 12 | `agents plan guard` shows active guards for a plan | §CLI Commands — plan guard | Command shows denylist, budget caps, tool limits |
|
||||
@@ -47135,7 +47148,7 @@ This section defines the ordered milestone plan for CleverAgents v3.x, mapping a
|
||||
- **Guard evaluation order**: Denylist checked first (fast reject), then budget caps, then tool call limits.
|
||||
- **Autonomy threshold**: `0.0` = always automatic; `1.0` = always manual; eight built-in profiles from `manual` to `full-auto`.
|
||||
- **Subtree recomputation**: Only decisions with `depends_on` edges to the corrected decision are recomputed; others preserved.
|
||||
- **Validation gating**: `required` validations block apply; `informational` validations log but do not block.
|
||||
- **Validation gating**: `required` validations block apply; an empty validation summary (no validations ran) also blocks apply; `informational` validations log but do not block.
|
||||
|
||||
#### Definition of Done
|
||||
|
||||
|
||||
@@ -279,140 +279,10 @@ Feature: Actor configuration uncovered lines coverage
|
||||
When I build an actor configuration from blob {"provider": "only-provider"}
|
||||
Then a ValueError should be raised containing "model is required"
|
||||
|
||||
Scenario: from_blob merges default and v2 and override options
|
||||
When I build an actor configuration from structured blob with defaults and overrides:
|
||||
"""
|
||||
{
|
||||
"blob": {
|
||||
"provider": "cli-provider",
|
||||
"model": "cli-model",
|
||||
"options": {"user": "blob"},
|
||||
"agents": {
|
||||
"writer": {
|
||||
"config": {
|
||||
"options": {"v2": "v2-option"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"default_options": {"base": "default"},
|
||||
"option_overrides": {"user": "override", "extra": "added"}
|
||||
}
|
||||
"""
|
||||
Then the actor configuration should have provider "cli-provider" and model "cli-model"
|
||||
And the actor configuration options should equal {"base": "default", "v2": "v2-option", "user": "override", "extra": "added"}
|
||||
|
||||
Scenario: from_blob with None blob defaults to empty data
|
||||
When I build actor config from None blob with provider and model overrides
|
||||
Then the actor configuration should have provider "override-provider" and model "override-model"
|
||||
|
||||
# --- _extract_v2_actor: lines 275-307 ---
|
||||
|
||||
Scenario: v2 YAML actor config infers provider and model
|
||||
Given an actor config file "v2.yaml" with content:
|
||||
"""
|
||||
cleveragents:
|
||||
default_router: main_router
|
||||
agents:
|
||||
paper_writer:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
unsafe: true
|
||||
options:
|
||||
temperature: 0.5
|
||||
routes:
|
||||
main_router:
|
||||
type: stream
|
||||
operators:
|
||||
- type: map
|
||||
params:
|
||||
agent: paper_writer
|
||||
publications:
|
||||
- __output__
|
||||
"""
|
||||
When I parse the actor configuration from file "v2.yaml" with overrides:
|
||||
"""
|
||||
{}
|
||||
"""
|
||||
Then the actor configuration should have provider "openai" and model "gpt-4"
|
||||
And the actor configuration unsafe flag should be true
|
||||
And the actor configuration graph descriptor should include key "routes"
|
||||
And the actor configuration graph descriptor should include key "agents"
|
||||
And the actor configuration graph descriptor should include key "cleveragents"
|
||||
And the actor configuration options should equal {"temperature": 0.5}
|
||||
|
||||
Scenario: v2 extraction includes merges and templates keys when present
|
||||
Given an actor config file "v2_merges.yaml" with content:
|
||||
"""
|
||||
agents:
|
||||
writer:
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
merges:
|
||||
combined:
|
||||
type: join
|
||||
templates:
|
||||
default:
|
||||
system_prompt: "hello"
|
||||
"""
|
||||
When I parse the actor configuration from file "v2_merges.yaml" with overrides:
|
||||
"""
|
||||
{}
|
||||
"""
|
||||
Then the actor configuration should have provider "openai" and model "gpt-4"
|
||||
And the actor configuration graph descriptor should include key "merges"
|
||||
And the actor configuration graph descriptor should include key "templates"
|
||||
|
||||
Scenario: v2 extraction handles agent entry without config block
|
||||
Given an actor config file "v2_no_config.yaml" with content:
|
||||
"""
|
||||
provider: fallback-provider
|
||||
model: fallback-model
|
||||
agents:
|
||||
first:
|
||||
type: llm
|
||||
"""
|
||||
When I parse the actor configuration from file "v2_no_config.yaml" with overrides:
|
||||
"""
|
||||
{}
|
||||
"""
|
||||
Then the actor configuration should have provider "fallback-provider" and model "fallback-model"
|
||||
|
||||
# --- _extract_v2_options: lines 316-326 ---
|
||||
|
||||
Scenario: v2 extraction skips non-dict agent entries
|
||||
Given an actor config file "invalid_v2.yaml" with content:
|
||||
"""
|
||||
provider: fallback-provider
|
||||
model: fallback-model
|
||||
agents:
|
||||
first: not-a-dict
|
||||
"""
|
||||
When I parse the actor configuration from file "invalid_v2.yaml" with overrides:
|
||||
"""
|
||||
{}
|
||||
"""
|
||||
Then the actor configuration should have provider "fallback-provider" and model "fallback-model"
|
||||
|
||||
Scenario: v2 options extraction returns None for agents without options
|
||||
Given an actor config file "v2_no_opts.yaml" with content:
|
||||
"""
|
||||
agents:
|
||||
writer:
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
"""
|
||||
When I parse the actor configuration from file "v2_no_opts.yaml" with overrides:
|
||||
"""
|
||||
{}
|
||||
"""
|
||||
Then the actor configuration should have provider "openai" and model "gpt-4"
|
||||
And the actor configuration options should equal {}
|
||||
|
||||
Scenario: from_blob reads provider_type and model_id aliases
|
||||
When I build an actor configuration from blob {"provider_type": "aliased-provider", "model_id": "aliased-model"}
|
||||
Then the actor configuration should have provider "aliased-provider" and model "aliased-model"
|
||||
|
||||
@@ -15,6 +15,7 @@ Feature: ActorRegistry.add() accepts spec-compliant actor YAML formats
|
||||
And the registered actor name should be "local/my-assistant"
|
||||
And the registered actor should exist in the actor service
|
||||
|
||||
@tdd_issue @tdd_issue_4300
|
||||
Scenario: registry.add() accepts spec-compliant actors: map with separate provider and model
|
||||
When I add a spec-compliant YAML with actors map and separate provider model
|
||||
Then the actor should be registered with provider "anthropic" and model "claude-3"
|
||||
@@ -53,24 +54,13 @@ Feature: ActorRegistry.add() accepts spec-compliant actor YAML formats
|
||||
Then the actor should be registered with provider "nested-provider" and model "top-level-model"
|
||||
And the registered actor should exist in the actor service
|
||||
|
||||
# ── Graph descriptor preserved from nested config ──────────────────
|
||||
|
||||
Scenario: registry.add() preserves graph descriptor from nested spec-compliant config
|
||||
When I add a spec-compliant YAML with actors map and combined actor field
|
||||
Then the registered actor graph descriptor should contain key "actors"
|
||||
|
||||
# ── Top-level provider+model still picks up nested unsafe/graph ──────
|
||||
# ── Top-level provider+model still picks up nested unsafe ───────────
|
||||
|
||||
Scenario: registry.add() with top-level provider and model detects nested unsafe flag
|
||||
When I add a YAML with top-level provider and model and nested actors map with unsafe flag
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor should be marked unsafe
|
||||
|
||||
Scenario: registry.add() with top-level provider and model detects nested graph descriptor
|
||||
When I add a YAML with top-level provider and model and nested actors map with graph descriptor
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor graph descriptor should contain key "actors"
|
||||
|
||||
# ── Unsafe confirmation gate ───────────────────────────────────────
|
||||
|
||||
Scenario: registry.add() rejects unsafe actor without confirmation
|
||||
@@ -94,12 +84,6 @@ Feature: ActorRegistry.add() accepts spec-compliant actor YAML formats
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor should not be marked unsafe
|
||||
|
||||
# ── Missing provider/model still rejected for non-v3 YAML ─────────
|
||||
|
||||
Scenario: registry.add() rejects non-v3 YAML without any provider or model
|
||||
When I attempt to add a YAML with no provider or model anywhere
|
||||
Then a spec-yaml ValidationError should be raised containing "provider"
|
||||
|
||||
# ── update=True path ───────────────────────────────────────────────
|
||||
|
||||
Scenario: registry.add() with update=True overwrites an existing actor using spec-compliant YAML
|
||||
@@ -139,366 +123,6 @@ Feature: ActorRegistry.add() accepts spec-compliant actor YAML formats
|
||||
And the registered actor should be marked unsafe
|
||||
And the registered actor should exist in the actor service
|
||||
|
||||
# ── Multi-actor YAML rejection ───────────────────────────────────
|
||||
|
||||
Scenario: registry.add() rejects multi-actor YAML with a ValidationError
|
||||
When I attempt to add a multi-actor YAML with two actor entries
|
||||
Then a spec-yaml ValidationError should be raised containing "single-actor"
|
||||
|
||||
Scenario: registry.add() rejects multi-actor YAML via agents: fallback when actors: is null
|
||||
When I attempt to add a YAML with actors null and multi-entry agents map
|
||||
Then a spec-yaml ValidationError should be raised containing "single-actor"
|
||||
|
||||
# ── _extract_v2_actor handles actors: key ──────────────────────────
|
||||
|
||||
Scenario: _extract_v2_actor extracts provider/model from actors: map
|
||||
When I call _extract_v2_actor with an actors map containing combined actor field
|
||||
Then the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
Scenario: _extract_v2_actor extracts from actors: map with separate fields
|
||||
When I call _extract_v2_actor with an actors map containing separate provider model
|
||||
Then the spec-yaml extracted provider should be "anthropic"
|
||||
And the spec-yaml extracted model should be "claude-3"
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
Scenario: _extract_v2_actor prefers actors: key over agents: key
|
||||
When I call _extract_v2_actor with both actors and agents maps
|
||||
Then the spec-yaml extracted provider should be "actors-provider"
|
||||
And the spec-yaml extracted model should be "actors-model"
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
Scenario: _extract_v2_actor graph descriptor contains agent key matching the actor entry name
|
||||
When I call _extract_v2_actor with an actors map containing combined actor field
|
||||
Then the spec-yaml extracted graph descriptor should contain key "agent"
|
||||
And the spec-yaml extracted graph descriptor agent value should be "my_assistant"
|
||||
|
||||
Scenario: _extract_v2_actor with actors map containing unsafe flag
|
||||
When I call _extract_v2_actor with an actors map containing unsafe true
|
||||
Then the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
And the spec-yaml extracted unsafe flag should be True
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
Scenario: _extract_v2_actor returns None for empty data
|
||||
When I call _extract_v2_actor with an empty dict
|
||||
Then the spec-yaml extracted provider should be None
|
||||
And the spec-yaml extracted model should be None
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted graph descriptor should be None
|
||||
|
||||
Scenario: _extract_v2_actor returns None for actors key with empty map
|
||||
When I call _extract_v2_actor with actors key containing empty map
|
||||
Then the spec-yaml extracted provider should be None
|
||||
And the spec-yaml extracted model should be None
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted graph descriptor should be None
|
||||
|
||||
Scenario: _extract_v2_actor returns None for actors key with None value
|
||||
When I call _extract_v2_actor with actors key containing None value
|
||||
Then the spec-yaml extracted provider should be None
|
||||
And the spec-yaml extracted model should be None
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted graph descriptor should be None
|
||||
|
||||
# ── _extract_v2_actor handles agents: key ─────────────────────────
|
||||
|
||||
Scenario: _extract_v2_actor returns graph descriptor with agents map_key
|
||||
When I call _extract_v2_actor with an agents map containing combined actor field
|
||||
Then the spec-yaml extracted graph descriptor should contain key "agents"
|
||||
And the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
|
||||
# ── _extract_v2_actor edge cases: non-dict / missing config ────────
|
||||
|
||||
Scenario: _extract_v2_actor returns None for non-dict first entry
|
||||
When I call _extract_v2_actor with a non-dict first entry in actors map
|
||||
Then the spec-yaml extracted provider should be None
|
||||
And the spec-yaml extracted model should be None
|
||||
And the spec-yaml extracted graph descriptor should be None
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
|
||||
Scenario: _extract_v2_actor returns None for dict entry missing config block
|
||||
When I call _extract_v2_actor with a dict entry missing config block
|
||||
Then the spec-yaml extracted provider should be None
|
||||
And the spec-yaml extracted model should be None
|
||||
And the spec-yaml extracted graph descriptor should be None
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
|
||||
# ── _extract_v2_actor edge case: actors: {} blocks agents: fallback ─
|
||||
|
||||
Scenario: _extract_v2_actor with empty actors dict blocks agents fallback
|
||||
When I call _extract_v2_actor with empty actors dict and valid agents map
|
||||
Then the spec-yaml extracted provider should be None
|
||||
And the spec-yaml extracted model should be None
|
||||
And the spec-yaml extracted graph descriptor should be None
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
|
||||
# ── _extract_v2_actor edge case: actors: [] (list type) ────────────
|
||||
|
||||
Scenario: _extract_v2_actor with actors as list returns None
|
||||
When I call _extract_v2_actor with actors as a list
|
||||
Then the spec-yaml extracted provider should be None
|
||||
And the spec-yaml extracted model should be None
|
||||
And the spec-yaml extracted graph descriptor should be None
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
|
||||
# ── _extract_v2_options handles actors: and agents: keys ───────────
|
||||
|
||||
Scenario: _extract_v2_options extracts options from actors: map
|
||||
When I call _extract_v2_options with an actors map containing options
|
||||
Then the spec-yaml extracted options should contain key "temperature" with value 0.7
|
||||
|
||||
Scenario: _extract_v2_options extracts options from agents: map
|
||||
When I call _extract_v2_options with an agents map containing options
|
||||
Then the spec-yaml extracted options should contain key "max_tokens" with value 1024
|
||||
|
||||
Scenario: _extract_v2_options returns None for actors key with empty map
|
||||
When I call _extract_v2_options with actors key containing empty map
|
||||
Then the spec-yaml extracted options should be None
|
||||
|
||||
Scenario: _extract_v2_options returns None for actors key with None value
|
||||
When I call _extract_v2_options with actors key containing None value
|
||||
Then the spec-yaml extracted options should be None
|
||||
|
||||
Scenario: _extract_v2_options returns None for actors key with list value
|
||||
When I call _extract_v2_options with actors key containing list value
|
||||
Then the spec-yaml extracted options should be None
|
||||
|
||||
Scenario: _extract_v2_options returns None when config block has no options key
|
||||
When I call _extract_v2_options with an actors map where config has no options key
|
||||
Then the spec-yaml extracted options should be None
|
||||
|
||||
Scenario: _extract_v2_options returns None for non-dict first entry in actors map
|
||||
When I call _extract_v2_options with a non-dict first entry in actors map
|
||||
Then the spec-yaml extracted options should be None
|
||||
|
||||
Scenario: _extract_v2_options returns None for dict entry missing config block
|
||||
When I call _extract_v2_options with a dict entry missing config block
|
||||
Then the spec-yaml extracted options should be None
|
||||
|
||||
Scenario: _extract_v2_options prefers actors: key over agents: key
|
||||
When I call _extract_v2_options with both actors and agents maps containing options
|
||||
Then the spec-yaml extracted options should contain key "source" with value "actors"
|
||||
|
||||
# ── Unsafe coercion edge cases (unsafe coercion) ────────────────
|
||||
|
||||
Scenario: _extract_v2_actor treats unsafe: "no" as False (not truthy string)
|
||||
When I call _extract_v2_actor with unsafe value "no"
|
||||
Then the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
|
||||
Scenario: _extract_v2_actor treats unsafe: "yes" as False (not truthy string)
|
||||
When I call _extract_v2_actor with unsafe value "yes"
|
||||
Then the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
|
||||
Scenario: _extract_v2_actor treats unsafe: 1 (integer) as True
|
||||
When I call _extract_v2_actor with unsafe value 1
|
||||
Then the spec-yaml extracted unsafe flag should be True
|
||||
And the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
|
||||
Scenario: registry.add() accepts actors map with unsafe: 1 (integer) and unsafe flag
|
||||
When I add a YAML with actors map where unsafe is integer 1 and the unsafe flag set
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor should be marked unsafe
|
||||
And the registered actor should exist in the actor service
|
||||
|
||||
Scenario: _extract_v2_actor treats unsafe: 1.0 (float) as True
|
||||
When I call _extract_v2_actor with unsafe value 1.0
|
||||
Then the spec-yaml extracted unsafe flag should be True
|
||||
And the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
|
||||
Scenario: _extract_v2_actor treats unsafe: 2 (integer > 1) as False
|
||||
When I call _extract_v2_actor with unsafe value 2
|
||||
Then the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
|
||||
Scenario: _extract_v2_actor treats unsafe: 0 (integer zero) as False
|
||||
When I call _extract_v2_actor with unsafe value 0
|
||||
Then the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
|
||||
# ── Top-level unsafe: 1 (integer) through registry.add() ────────────
|
||||
|
||||
Scenario: registry.add() rejects top-level unsafe: 1 (integer) without confirmation
|
||||
When I attempt to add a YAML with top-level unsafe integer 1 and no flag
|
||||
Then a spec-yaml ValidationError should be raised containing "unsafe"
|
||||
|
||||
Scenario: registry.add() accepts top-level unsafe: 1 (integer) with unsafe flag
|
||||
When I add a YAML with top-level unsafe integer 1 and the unsafe flag set
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor should be marked unsafe
|
||||
And the registered actor should exist in the actor service
|
||||
|
||||
# ── Top-level graph_descriptor key through registry.add() (T7) ──────
|
||||
|
||||
Scenario: registry.add() resolves graph descriptor from top-level graph_descriptor key
|
||||
When I add a YAML with top-level provider model and graph_descriptor key
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor graph descriptor should contain key "workflow"
|
||||
|
||||
Scenario: _extract_v2_actor includes top-level routes key in graph descriptor
|
||||
When I call _extract_v2_actor with an actors map and a top-level routes key
|
||||
Then the spec-yaml extracted graph descriptor should contain key "routes"
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
# ── Legacy graph key fallback (M3) ─────────────────────────────────
|
||||
|
||||
Scenario: registry.add() resolves graph descriptor from legacy top-level graph key
|
||||
When I add a YAML with top-level provider model and legacy graph key
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor graph descriptor should contain key "workflow"
|
||||
|
||||
# ── Empty actors map through registry.add() (M4) ───────────────────
|
||||
|
||||
Scenario: registry.add() with empty actors map does not fall back to agents map for provider/model
|
||||
When I attempt to add a YAML with empty actors map and valid agents map but no top-level provider
|
||||
Then a spec-yaml ValidationError should be raised containing "provider"
|
||||
|
||||
# ── provider_type / model_id aliases in nested config (m1) ─────────
|
||||
|
||||
Scenario: _extract_v2_actor extracts provider from provider_type alias in nested config
|
||||
When I call _extract_v2_actor with an actors map using provider_type alias
|
||||
Then the spec-yaml extracted provider should be "alias-provider"
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
Scenario: _extract_v2_actor extracts model from model_id alias in nested config
|
||||
When I call _extract_v2_actor with an actors map using model_id alias
|
||||
Then the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "alias-model"
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
# ── _extract_v2_options with empty dict (m4) ────────────────────────
|
||||
|
||||
Scenario: _extract_v2_options returns None for empty dict input
|
||||
When I call _extract_v2_options with an empty dict
|
||||
Then the spec-yaml extracted options should be None
|
||||
|
||||
# ── compiled_metadata value assertion (m5) ──────────────────────────
|
||||
|
||||
Scenario: registry.add() forwards compiled_metadata with correct values
|
||||
When I add a spec-compliant YAML with schema_version "2.0" and compiled_metadata
|
||||
Then the registered actor compiled metadata key "key" should have value "val"
|
||||
|
||||
# ── Combined actor field edge cases ────────────────────────────────
|
||||
|
||||
Scenario: Combined actor field without slash is ignored
|
||||
When I call _extract_v2_actor with an actors map where actor field has no slash
|
||||
Then the spec-yaml extracted provider should be None
|
||||
And the spec-yaml extracted model should be None
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
|
||||
Scenario: Combined actor field does not override explicit provider but fills missing model
|
||||
When I call _extract_v2_actor with an actors map where both actor and provider exist
|
||||
Then the spec-yaml extracted provider should be "explicit-provider"
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
Scenario: Combined actor field does not override explicit model but fills missing provider
|
||||
When I call _extract_v2_actor with an actors map where both actor and model exist
|
||||
Then the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "explicit-model"
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
# ── Combined actor field malformed input edge cases ────────────────
|
||||
|
||||
Scenario: Combined actor field with empty provider part yields no provider
|
||||
When I call _extract_v2_actor with an actors map where actor field has empty provider
|
||||
Then the spec-yaml extracted provider should be None
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
Scenario: Combined actor field with empty model part yields no model
|
||||
When I call _extract_v2_actor with an actors map where actor field has empty model
|
||||
Then the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be None
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
# ── Combined actor field with multiple slashes (L1) ────────────────
|
||||
|
||||
Scenario: Combined actor field with multiple slashes splits on first slash only
|
||||
When I call _extract_v2_actor with an actors map where actor field has multiple slashes
|
||||
Then the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "gpt-4/extra"
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
# ── actors: null + valid agents: through registry.add() (L2) ───────
|
||||
|
||||
Scenario: registry.add() with actors: null falls back to valid agents: map
|
||||
When I add a YAML with actors null and valid agents map
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor should exist in the actor service
|
||||
|
||||
# ── Top-level provider_type / model_id aliases through registry.add() (T8) ──
|
||||
|
||||
Scenario: registry.add() accepts top-level provider_type alias
|
||||
When I add a YAML with top-level provider_type alias and model
|
||||
Then the actor should be registered with provider "alias-provider" and model "gpt-4"
|
||||
And the registered actor should exist in the actor service
|
||||
|
||||
Scenario: registry.add() accepts top-level model_id alias
|
||||
When I add a YAML with top-level provider and model_id alias
|
||||
Then the actor should be registered with provider "openai" and model "alias-model"
|
||||
And the registered actor should exist in the actor service
|
||||
|
||||
# ── Top-level unsafe string coercion through registry.add() (T9) ───
|
||||
|
||||
Scenario: registry.add() treats top-level unsafe: "yes" as not unsafe (no gate rejection)
|
||||
When I add a YAML with top-level unsafe string "yes" and provider model
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor should not be marked unsafe
|
||||
|
||||
Scenario: registry.add() treats top-level unsafe: "no" as not unsafe (no gate rejection)
|
||||
When I add a YAML with top-level unsafe string "no" and provider model
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor should not be marked unsafe
|
||||
|
||||
# ── Nested options extraction through registry.add() (M1) ──────────
|
||||
|
||||
Scenario: registry.add() extracts and preserves nested config options
|
||||
When I add a spec-compliant YAML with actors map and nested options
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor config blob should contain options key "temperature" with value 0.9
|
||||
And the registered actor config blob should contain options key "max_tokens" with value 2000
|
||||
|
||||
Scenario: registry.add() merges nested and top-level options (nested base, top-level overrides)
|
||||
When I add a YAML with both top-level and nested options
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor config blob should contain options key "temperature" with value 0.7
|
||||
And the registered actor config blob should contain options key "max_tokens" with value 2000
|
||||
And the registered actor config blob should contain options key "top_p" with value 0.95
|
||||
|
||||
Scenario: registry.add() with non-dict top-level options uses nested options
|
||||
When I add a YAML with non-dict top-level options and nested options
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor config blob should contain options key "temperature" with value 0.9
|
||||
|
||||
Scenario: registry.add() sets source: "yaml" default in config_blob
|
||||
When I add a spec-compliant YAML with actors map and combined actor field
|
||||
Then the registered actor config blob should contain source "yaml"
|
||||
|
||||
# ── _extract_v2_options shallow copy mutation isolation (NIT-2) ─────
|
||||
|
||||
Scenario: _extract_v2_options returns a shallow copy that does not mutate the original blob
|
||||
When I call _extract_v2_options and mutate the returned dict
|
||||
Then the original blob options should be unmodified
|
||||
|
||||
# ── M4: update=True creates actor when it doesn't exist ────────────
|
||||
|
||||
Scenario: registry.add() with update=True creates actor when it doesn't exist
|
||||
@@ -518,25 +142,5 @@ Feature: ActorRegistry.add() accepts spec-compliant actor YAML formats
|
||||
When upsert_actor is configured to raise RuntimeError and I add a valid YAML
|
||||
Then a RuntimeError should have been propagated from add
|
||||
|
||||
# ── M7: actors: false (boolean) blocks agents fallback ─────────────
|
||||
|
||||
|
||||
Scenario: registry.add() with actors: false blocks agents fallback and raises provider error
|
||||
When I attempt to add a YAML with actors false and valid agents map
|
||||
Then a spec-yaml ValidationError should be raised containing "provider"
|
||||
|
||||
# ── M8: non-dict compiled_metadata causes Pydantic error ───────────
|
||||
|
||||
Scenario: registry.add() with non-dict compiled_metadata raises a Pydantic validation error
|
||||
When I attempt to add a valid YAML with compiled_metadata as a non-dict string
|
||||
Then a Pydantic validation error should be raised for compiled_metadata
|
||||
|
||||
# ── M9: provider: 0 (integer zero) falls through to provider_type ──
|
||||
|
||||
Scenario: registry.add() with provider: 0 falls through to provider_type fallback
|
||||
When I attempt to add a YAML with provider integer 0 and no provider_type
|
||||
Then a spec-yaml ValidationError should be raised containing "provider"
|
||||
|
||||
Scenario: registry.add() with provider: 0 and valid provider_type uses provider_type
|
||||
When I add a YAML with provider integer 0 and a valid provider_type
|
||||
Then the actor should be registered with provider "fallback-provider" and model "gpt-4"
|
||||
And the registered actor should exist in the actor service
|
||||
|
||||
@@ -26,12 +26,6 @@ Feature: v3 Actor YAML schema support in CLI and registry
|
||||
When I call ActorConfiguration.from_blob with the v3 blob
|
||||
Then the configuration should have a graph_descriptor with type "graph"
|
||||
|
||||
Scenario: ActorConfiguration.from_blob falls through to v2 for legacy YAML
|
||||
Given a v2 actor blob with agents and routes
|
||||
When I call ActorConfiguration.from_blob with the v2 blob
|
||||
Then the configuration should have provider "openai"
|
||||
And the configuration should have model "gpt-4"
|
||||
|
||||
Scenario: ActorRegistry.add validates v3 LLM actor YAML
|
||||
Given a mock actor service for v3 testing
|
||||
And a v3 LLM actor YAML text
|
||||
@@ -67,11 +61,6 @@ Feature: v3 Actor YAML schema support in CLI and registry
|
||||
And the reactive config should have a graph route
|
||||
And the graph route edges should use source and target keys
|
||||
|
||||
Scenario: v3 format detection returns false for v2 data
|
||||
Given a v2 actor config dict with agents key
|
||||
When I check if the config is v3 format
|
||||
Then it should not be detected as v3
|
||||
|
||||
# M14: test update=True path
|
||||
Scenario: ActorRegistry.add with update=True overwrites existing actor
|
||||
Given a mock actor service for v3 testing
|
||||
@@ -174,27 +163,6 @@ Feature: v3 Actor YAML schema support in CLI and registry
|
||||
When I call ActorRegistry.add with a non-mapping YAML string
|
||||
Then a ValidationError should be raised mentioning mapping
|
||||
|
||||
# Coverage: registry.py — _add_legacy v3 schema validation failure
|
||||
Scenario: ActorRegistry._add_legacy rejects invalid v3 YAML via schema validation
|
||||
Given a mock actor service for v3 testing
|
||||
And a legacy v3 YAML text with invalid schema
|
||||
When I call ActorRegistry.add with the legacy invalid v3 YAML
|
||||
Then a ValidationError should be raised mentioning invalid v3 actor
|
||||
|
||||
# Coverage: registry.py — _add_legacy missing provider/model in non-v3 blob
|
||||
Scenario: ActorRegistry._add_legacy rejects non-v3 blob missing provider and model
|
||||
Given a mock actor service for v3 testing
|
||||
And a legacy non-v3 YAML text missing provider and model
|
||||
When I call ActorRegistry.add with the legacy non-v3 YAML
|
||||
Then a ValidationError should be raised mentioning provider and model
|
||||
|
||||
# Coverage: registry.py — _add_legacy unsafe flag rejection
|
||||
Scenario: ActorRegistry._add_legacy rejects unsafe legacy actor without allow_unsafe
|
||||
Given a mock actor service for v3 testing
|
||||
And a legacy actor YAML text marked unsafe
|
||||
When I call ActorRegistry.add with the legacy unsafe YAML
|
||||
Then a ValidationError should be raised mentioning unsafe
|
||||
|
||||
# Coverage: registry.py — upsert_actor v3 validation failure
|
||||
Scenario: ActorRegistry.upsert_actor rejects invalid v3 config_blob
|
||||
Given a mock actor service for v3 testing
|
||||
|
||||
@@ -459,22 +459,6 @@ Feature: Consolidated Actor
|
||||
Then the config options should include the overrides
|
||||
|
||||
|
||||
Scenario: _extract_v2_actor parses v2 agents block
|
||||
When I extract v2 actor from a v2-style config with agents block
|
||||
Then the extracted provider and model should be set
|
||||
And the graph descriptor should contain agent info
|
||||
|
||||
|
||||
Scenario: _extract_v2_actor returns None for missing agents
|
||||
When I extract v2 actor from a config without agents block
|
||||
Then all extracted values should be None
|
||||
|
||||
|
||||
Scenario: _extract_v2_options extracts options from v2 config
|
||||
When I extract v2 options from a v2-style config
|
||||
Then the extracted options should contain expected keys
|
||||
|
||||
|
||||
Scenario: from_file loads and parses config from disk
|
||||
Given a temporary JSON config file with provider and model
|
||||
When I create an ActorConfiguration from the file
|
||||
@@ -876,6 +860,8 @@ Feature: Consolidated Actor
|
||||
When I add actor from YAML text:
|
||||
"""
|
||||
name: local/test-actor
|
||||
type: llm
|
||||
description: A test actor
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
"""
|
||||
@@ -888,6 +874,8 @@ Feature: Consolidated Actor
|
||||
When I add actor from YAML text with schema_version "2.0":
|
||||
"""
|
||||
name: local/versioned
|
||||
type: llm
|
||||
description: A versioned actor
|
||||
provider: anthropic
|
||||
model: claude-3
|
||||
"""
|
||||
@@ -899,6 +887,8 @@ Feature: Consolidated Actor
|
||||
When I add actor from YAML text with compiled_metadata:
|
||||
"""
|
||||
name: local/compiled
|
||||
type: llm
|
||||
description: A compiled actor
|
||||
provider: openai
|
||||
model: gpt-4o
|
||||
"""
|
||||
@@ -910,12 +900,16 @@ Feature: Consolidated Actor
|
||||
When I add actor from YAML text:
|
||||
"""
|
||||
name: local/updatable
|
||||
type: llm
|
||||
description: An updatable actor
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
"""
|
||||
And I update actor from YAML text:
|
||||
"""
|
||||
name: local/updatable
|
||||
type: llm
|
||||
description: An updatable actor
|
||||
provider: openai
|
||||
model: gpt-4-turbo
|
||||
"""
|
||||
@@ -927,6 +921,8 @@ Feature: Consolidated Actor
|
||||
When I add actor from YAML text:
|
||||
"""
|
||||
name: local/removable
|
||||
type: llm
|
||||
description: A removable actor
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
"""
|
||||
@@ -939,12 +935,16 @@ Feature: Consolidated Actor
|
||||
When I add actor from YAML text:
|
||||
"""
|
||||
name: local/alpha
|
||||
type: llm
|
||||
description: An alpha actor
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
"""
|
||||
And I add actor from YAML text with update:
|
||||
"""
|
||||
name: local/beta
|
||||
type: llm
|
||||
description: A beta actor
|
||||
provider: anthropic
|
||||
model: claude-3
|
||||
"""
|
||||
@@ -957,6 +957,8 @@ Feature: Consolidated Actor
|
||||
When I add actor from YAML text:
|
||||
"""
|
||||
name: local/auto-prefixed
|
||||
type: llm
|
||||
description: An auto-prefixed actor
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
"""
|
||||
@@ -968,6 +970,8 @@ Feature: Consolidated Actor
|
||||
When I add actor from YAML text:
|
||||
"""
|
||||
name: local/fetchable
|
||||
type: llm
|
||||
description: A fetchable actor
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
"""
|
||||
@@ -978,6 +982,8 @@ Feature: Consolidated Actor
|
||||
Given an actor registry with no configured providers for persistence
|
||||
When I attempt to add actor from YAML text without name:
|
||||
"""
|
||||
type: llm
|
||||
description: No name actor
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
"""
|
||||
@@ -989,12 +995,16 @@ Feature: Consolidated Actor
|
||||
When I add actor from YAML text:
|
||||
"""
|
||||
name: local/duplicate
|
||||
type: llm
|
||||
description: A duplicate actor
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
"""
|
||||
And I attempt to add duplicate actor from YAML text:
|
||||
"""
|
||||
name: local/duplicate
|
||||
type: llm
|
||||
description: A duplicate actor
|
||||
provider: openai
|
||||
model: gpt-4-turbo
|
||||
"""
|
||||
@@ -1008,17 +1018,7 @@ Feature: Consolidated Actor
|
||||
And the actor "local/legacy-yaml" should have schema_version "1.5"
|
||||
|
||||
|
||||
Scenario: Default schema version is applied when not specified
|
||||
Given an actor registry with no configured providers for persistence
|
||||
When I add actor from YAML text:
|
||||
"""
|
||||
name: local/default-version
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
"""
|
||||
Then the actor "local/default-version" should have schema_version "1.0"
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Originally from: actor_runtime.feature
|
||||
# Feature: Tool-Calling Actor Runtime
|
||||
|
||||
@@ -107,12 +107,12 @@ Feature: Plan explain and decision tree CLI commands
|
||||
# plan tree - json format
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@tdd_issue @tdd_issue_4254 @tdd_expected_fail
|
||||
@tdd_issue @tdd_issue_4254
|
||||
Scenario: Tree with json format
|
||||
Given a set of test decisions forming a tree
|
||||
When I format the tree as json
|
||||
Then the json tree output should be valid json
|
||||
And the json tree output should contain "decision_id"
|
||||
Then the json tree output should be a valid json envelope
|
||||
And the json tree output should contain "command"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# plan tree - yaml format
|
||||
|
||||
@@ -65,13 +65,13 @@ Feature: Plan explain and tree CLI command coverage
|
||||
Given pec a mock DecisionService returning a list of decisions
|
||||
When pec I invoke "tree" with format "json"
|
||||
Then pec the exit code should be 0
|
||||
And pec the output should be valid json list
|
||||
And pec the output should be valid json envelope
|
||||
|
||||
Scenario: Tree CLI renders yaml format
|
||||
Given pec a mock DecisionService returning a list of decisions
|
||||
When pec I invoke "tree" with format "yaml"
|
||||
Then pec the exit code should be 0
|
||||
And pec the output should contain "decision_id:"
|
||||
And pec the output should contain "command:"
|
||||
|
||||
Scenario: Tree CLI renders table format
|
||||
Given pec a mock DecisionService returning a list of decisions
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
@mock_only
|
||||
Feature: PR Compliance Checklist in Implementation Pool Supervisor
|
||||
|
||||
As a pool supervisor
|
||||
I want to pass a mandatory PR compliance checklist to every worker prompt
|
||||
So that implementation workers complete all required items before creating a PR and avoid systemic merge blockers
|
||||
|
||||
Background:
|
||||
Given the implementation-pool-supervisor.md agent definition exists
|
||||
|
||||
Scenario: Pool supervisor worker prompt includes the PR compliance checklist
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes the PR compliance checklist section
|
||||
And Pool: the checklist is marked as MANDATORY
|
||||
|
||||
Scenario: Checklist item 1 — CHANGELOG.md update required
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes a CHANGELOG.md checklist item
|
||||
And Pool: the item instructs workers to add an entry under the Unreleased section
|
||||
|
||||
Scenario: Checklist item 2 — CONTRIBUTORS.md update required
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes a CONTRIBUTORS.md checklist item
|
||||
And Pool: the item instructs workers to add or update their contribution entry
|
||||
|
||||
Scenario: Checklist item 3 — commit footer required
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes a commit footer checklist item
|
||||
And Pool: the item specifies the ISSUES CLOSED footer format
|
||||
|
||||
Scenario: Checklist item 4 — CI must pass before PR creation
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes a CI passes checklist item
|
||||
And Pool: the item instructs workers to verify all quality gates are green
|
||||
|
||||
Scenario: Checklist item 5 — BDD/Behave tests required
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes a BDD tests checklist item
|
||||
And Pool: the item instructs workers to add or update Behave feature files
|
||||
|
||||
Scenario: Checklist item 6 — Epic reference required in PR description
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes an Epic reference checklist item
|
||||
And Pool: the item instructs workers to reference the parent Epic issue number
|
||||
|
||||
Scenario: Checklist item 7 — Labels must be applied
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes a labels checklist item
|
||||
And Pool: the item instructs workers to apply labels via forgejo-label-manager
|
||||
|
||||
Scenario: Checklist item 8 — Milestone must be assigned
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes a milestone checklist item
|
||||
And Pool: the item instructs workers to assign the earliest open milestone
|
||||
|
||||
Scenario: All 8 checklist items are present in the worker prompt
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body contains all 8 mandatory checklist items
|
||||
@@ -0,0 +1,23 @@
|
||||
@tdd_issue @tdd_issue_9055
|
||||
Feature: PyYAML is a declared project dependency with minimum secure version
|
||||
As a CleverAgents developer
|
||||
I want the PyYAML dependency to be listed in pyproject.toml with a minimum secure version
|
||||
So that vulnerable older versions of PyYAML cannot be installed
|
||||
|
||||
Background:
|
||||
Given the pyproject.toml file exists at "pyproject.toml"
|
||||
|
||||
@tdd_issue @tdd_issue_9055
|
||||
Scenario: PyYAML is listed in project dependencies with minimum version constraint
|
||||
When I read the project dependencies from pyproject.toml
|
||||
Then the dependency list should include a package matching "pyyaml>="
|
||||
|
||||
@tdd_issue @tdd_issue_9055
|
||||
Scenario: PyYAML minimum version is >=6.0.3
|
||||
When I read the PyYAML dependency specification from pyproject.toml
|
||||
Then the minimum version should be at least "6.0.3"
|
||||
|
||||
@tdd_issue @tdd_issue_9055
|
||||
Scenario: yaml module is importable as a project dependency
|
||||
When I attempt to import the "yaml" module
|
||||
Then the import should succeed without errors
|
||||
@@ -17,11 +17,6 @@ from cleveragents.actor.yaml_loader import (
|
||||
interpolate_env_vars,
|
||||
load_yaml_text,
|
||||
)
|
||||
from cleveragents.actor.yaml_loader import (
|
||||
_restore_template_syntax,
|
||||
interpolate_env_vars,
|
||||
load_yaml_text,
|
||||
)
|
||||
|
||||
|
||||
@then('an actor config ValueError should mention "{text}"')
|
||||
@@ -314,76 +309,6 @@ def step_assert_options(context: Context) -> None:
|
||||
assert context.actor_cfg.options["temperature"] == 0.9
|
||||
|
||||
|
||||
@when("I extract v2 actor from a v2-style config with agents block")
|
||||
def step_extract_v2_actor(context: Context) -> None:
|
||||
data: dict[str, Any] = {
|
||||
"agents": {
|
||||
"main_agent": {
|
||||
"config": {
|
||||
"provider": "anthropic",
|
||||
"model": "claude-3",
|
||||
"unsafe": True,
|
||||
"options": {"max_tokens": 1000},
|
||||
}
|
||||
}
|
||||
},
|
||||
"routes": [{"from": "main_agent", "to": "end"}],
|
||||
}
|
||||
context.extracted = ActorConfiguration._extract_v2_actor(data)
|
||||
|
||||
|
||||
@then("the extracted provider and model should be set")
|
||||
def step_assert_extracted(context: Context) -> None:
|
||||
provider, model, _graph, unsafe = context.extracted
|
||||
assert provider == "anthropic"
|
||||
assert model == "claude-3"
|
||||
assert unsafe is True
|
||||
|
||||
|
||||
@then("the graph descriptor should contain agent info")
|
||||
def step_assert_graph_descriptor(context: Context) -> None:
|
||||
_, _, graph, _ = context.extracted
|
||||
assert graph is not None
|
||||
assert "agent" in graph
|
||||
assert "routes" in graph
|
||||
|
||||
|
||||
@when("I extract v2 actor from a config without agents block")
|
||||
def step_extract_v2_no_agents(context: Context) -> None:
|
||||
context.extracted = ActorConfiguration._extract_v2_actor({"provider": "openai"})
|
||||
|
||||
|
||||
@then("all extracted values should be None")
|
||||
def step_assert_all_none(context: Context) -> None:
|
||||
provider, model, graph, unsafe = context.extracted
|
||||
assert provider is None
|
||||
assert model is None
|
||||
assert graph is None
|
||||
assert unsafe is False
|
||||
|
||||
|
||||
@when("I extract v2 options from a v2-style config")
|
||||
def step_extract_v2_options(context: Context) -> None:
|
||||
data: dict[str, Any] = {
|
||||
"agents": {
|
||||
"main_agent": {
|
||||
"config": {
|
||||
"provider": "openai",
|
||||
"model": "gpt-4",
|
||||
"options": {"temperature": 0.7, "max_tokens": 500},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
context.options = ActorConfiguration._extract_v2_options(data)
|
||||
|
||||
|
||||
@then("the extracted options should contain expected keys")
|
||||
def step_assert_extracted_options(context: Context) -> None:
|
||||
assert context.options is not None
|
||||
assert "temperature" in context.options
|
||||
|
||||
|
||||
@when("I create an ActorConfiguration from the file")
|
||||
def step_from_file(context: Context) -> None:
|
||||
context.actor_cfg = ActorConfiguration.from_file(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -525,88 +525,6 @@ def step_validation_error_mapping(context: Any) -> None:
|
||||
assert "mapping" in msg, f"Error should mention 'mapping': {context.add_error}"
|
||||
|
||||
|
||||
@given("a legacy v3 YAML text with invalid schema")
|
||||
def step_legacy_v3_yaml_invalid_schema(context: Any) -> None:
|
||||
# is_v3_yaml detects version starting with "3" or non-null type field.
|
||||
# Provide a blob that triggers the v3 validation path but fails schema.
|
||||
context.legacy_v3_yaml_invalid = yaml.safe_dump(
|
||||
{
|
||||
"name": "local/bad-v3",
|
||||
"version": "3.0",
|
||||
"type": "invalid_type_value", # not llm/graph/tool — fails ActorConfigSchema
|
||||
"provider": "openai",
|
||||
"model": "gpt-4",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@when("I call ActorRegistry.add with the legacy invalid v3 YAML")
|
||||
def step_call_registry_add_legacy_invalid_v3(context: Any) -> None:
|
||||
try:
|
||||
context.registry.add(context.legacy_v3_yaml_invalid)
|
||||
context.add_error = None
|
||||
except ValidationError as exc:
|
||||
context.add_error = exc
|
||||
|
||||
|
||||
@then("a ValidationError should be raised mentioning invalid v3 actor")
|
||||
def step_validation_error_invalid_v3(context: Any) -> None:
|
||||
assert context.add_error is not None, "Expected a ValidationError to be raised"
|
||||
msg = str(context.add_error).lower()
|
||||
assert "invalid" in msg or "v3" in msg or "validation" in msg, (
|
||||
f"Error should mention invalid v3 actor: {context.add_error}"
|
||||
)
|
||||
|
||||
|
||||
@given("a legacy non-v3 YAML text missing provider and model")
|
||||
def step_legacy_non_v3_yaml_missing_fields(context: Any) -> None:
|
||||
context.legacy_non_v3_yaml = yaml.safe_dump(
|
||||
{
|
||||
"name": "local/no-provider",
|
||||
# no type field → not v3, no provider/model → should fail
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@when("I call ActorRegistry.add with the legacy non-v3 YAML")
|
||||
def step_call_registry_add_legacy_non_v3(context: Any) -> None:
|
||||
try:
|
||||
context.registry.add(context.legacy_non_v3_yaml)
|
||||
context.add_error = None
|
||||
except ValidationError as exc:
|
||||
context.add_error = exc
|
||||
|
||||
|
||||
@then("a ValidationError should be raised mentioning provider and model")
|
||||
def step_validation_error_provider_model(context: Any) -> None:
|
||||
assert context.add_error is not None, "Expected a ValidationError to be raised"
|
||||
msg = str(context.add_error).lower()
|
||||
assert "provider" in msg or "model" in msg, (
|
||||
f"Error should mention 'provider' or 'model': {context.add_error}"
|
||||
)
|
||||
|
||||
|
||||
@given("a legacy actor YAML text marked unsafe")
|
||||
def step_legacy_actor_yaml_unsafe(context: Any) -> None:
|
||||
context.legacy_unsafe_yaml = yaml.safe_dump(
|
||||
{
|
||||
"name": "local/legacy-unsafe",
|
||||
"provider": "openai",
|
||||
"model": "gpt-4",
|
||||
"unsafe": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@when("I call ActorRegistry.add with the legacy unsafe YAML")
|
||||
def step_call_registry_add_legacy_unsafe(context: Any) -> None:
|
||||
try:
|
||||
context.registry.add(context.legacy_unsafe_yaml)
|
||||
context.add_error = None
|
||||
except ValidationError as exc:
|
||||
context.add_error = exc
|
||||
|
||||
|
||||
@when("I call ActorRegistry.upsert_actor with an invalid v3 config_blob")
|
||||
def step_call_registry_upsert_invalid_v3(context: Any) -> None:
|
||||
# Provide a blob that triggers is_v3_yaml() but fails ActorConfigSchema.
|
||||
@@ -629,6 +547,15 @@ def step_call_registry_upsert_invalid_v3(context: Any) -> None:
|
||||
context.add_error = exc
|
||||
|
||||
|
||||
@then("a ValidationError should be raised mentioning invalid v3 actor")
|
||||
def step_validation_error_invalid_v3(context: Any) -> None:
|
||||
assert context.add_error is not None, "Expected a ValidationError to be raised"
|
||||
msg = str(context.add_error).lower()
|
||||
assert "invalid" in msg or "v3" in msg or "validation" in msg, (
|
||||
f"Error should mention invalid v3 actor: {context.add_error}"
|
||||
)
|
||||
|
||||
|
||||
# ── Coverage gap: config_parser.py ───────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -93,22 +93,6 @@ def step_v3_graph_blob(context: Any) -> None:
|
||||
}
|
||||
|
||||
|
||||
@given("a v2 actor blob with agents and routes")
|
||||
def step_v2_blob(context: Any) -> None:
|
||||
context.v2_blob = {
|
||||
"agents": {
|
||||
"main": {
|
||||
"type": "llm",
|
||||
"config": {
|
||||
"provider": "openai",
|
||||
"model": "gpt-4",
|
||||
},
|
||||
}
|
||||
},
|
||||
"routes": {},
|
||||
}
|
||||
|
||||
|
||||
@given("a mock actor service for v3 testing")
|
||||
def step_mock_actor_service(context: Any) -> None:
|
||||
mock_actor_service = MagicMock()
|
||||
@@ -247,13 +231,6 @@ def step_v3_graph_config_dict(context: Any) -> None:
|
||||
}
|
||||
|
||||
|
||||
@given("a v2 actor config dict with agents key")
|
||||
def step_v2_config_dict(context: Any) -> None:
|
||||
context.v2_config = {
|
||||
"agents": {"main": {"type": "llm", "config": {"provider": "openai"}}},
|
||||
}
|
||||
|
||||
|
||||
@given("an existing actor in the mock service")
|
||||
def step_existing_actor(context: Any) -> None:
|
||||
"""Set up mock to return an existing actor for duplicate checks."""
|
||||
@@ -274,14 +251,6 @@ def step_call_from_blob_v3(context: Any) -> None:
|
||||
)
|
||||
|
||||
|
||||
@when("I call ActorConfiguration.from_blob with the v2 blob")
|
||||
def step_call_from_blob_v2(context: Any) -> None:
|
||||
context.actor_config = ActorConfiguration.from_blob(
|
||||
blob=context.v2_blob,
|
||||
name="local/test",
|
||||
)
|
||||
|
||||
|
||||
@when("I call ActorRegistry.add with the v3 YAML")
|
||||
def step_call_registry_add_v3(context: Any) -> None:
|
||||
context.result_actor = context.registry.add(context.v3_yaml_text)
|
||||
@@ -329,11 +298,6 @@ def step_parse_v3_config(context: Any) -> None:
|
||||
context.reactive_config = parser._build(context.v3_config)
|
||||
|
||||
|
||||
@when("I check if the config is v3 format")
|
||||
def step_check_v3_format(context: Any) -> None:
|
||||
context.is_v3 = ReactiveConfigParser._is_v3_format(context.v2_config)
|
||||
|
||||
|
||||
# ── Then steps ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -470,11 +434,6 @@ def step_reactive_has_graph_route(context: Any) -> None:
|
||||
assert len(route.edges) >= 1, f"Expected at least 1 edge, got {len(route.edges)}"
|
||||
|
||||
|
||||
@then("it should not be detected as v3")
|
||||
def step_not_v3(context: Any) -> None:
|
||||
assert context.is_v3 is False, "Expected v2 data to NOT be detected as v3"
|
||||
|
||||
|
||||
@then("the graph route edges should use source and target keys")
|
||||
def step_graph_edges_use_source_target(context: Any) -> None:
|
||||
routes = context.reactive_config.routes
|
||||
|
||||
@@ -807,6 +807,22 @@ def step_pec_output_valid_json_list(context: Context) -> None:
|
||||
assert isinstance(data, list), "Expected JSON array"
|
||||
|
||||
|
||||
@then("pec the output should be valid json envelope")
|
||||
def step_pec_output_valid_json_envelope(context: Context) -> None:
|
||||
parsed = json.loads(context.pec_result.output.strip())
|
||||
assert isinstance(parsed, dict), f"Expected JSON object, got {type(parsed)}"
|
||||
assert _ENVELOPE_KEYS.issubset(parsed.keys()), (
|
||||
f"Expected envelope keys {_ENVELOPE_KEYS}, got {set(parsed.keys())}"
|
||||
)
|
||||
data = parsed["data"]
|
||||
assert isinstance(data, dict), (
|
||||
f"Expected envelope data to be a dict, got {type(data)}"
|
||||
)
|
||||
assert "plan_id" in data, (
|
||||
f"Expected 'plan_id' in envelope data, got {set(data.keys())}"
|
||||
)
|
||||
|
||||
|
||||
@then("pec the tree should exclude the superseded grandchild")
|
||||
def step_pec_tree_excludes_orphan(context: Context) -> None:
|
||||
# Tree should have exactly one root with one child and zero grandchildren.
|
||||
|
||||
@@ -405,6 +405,18 @@ def step_tree_json_valid(context: Context) -> None:
|
||||
assert isinstance(parsed, list), "Expected a JSON array"
|
||||
|
||||
|
||||
@then("the json tree output should be a valid json envelope")
|
||||
def step_tree_json_valid_envelope(context: Context) -> None:
|
||||
parsed = json.loads(context.pe_tree_json)
|
||||
assert isinstance(parsed, dict), (
|
||||
f"Expected a JSON object (envelope), got {type(parsed)}"
|
||||
)
|
||||
_envelope_keys = {"command", "status", "exit_code", "data", "timing", "messages"}
|
||||
assert _envelope_keys.issubset(parsed.keys()), (
|
||||
f"Expected envelope keys {_envelope_keys}, got {set(parsed.keys())}"
|
||||
)
|
||||
|
||||
|
||||
@then('the json tree output should contain "{text}"')
|
||||
def step_tree_json_contains(context: Context, text: str) -> None:
|
||||
assert text in context.pe_tree_json, f"Expected '{text}' in tree JSON"
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Step definitions for PR compliance checklist in implementation pool supervisor.
|
||||
|
||||
This file uses parameterized @then decorators with unique step text that
|
||||
distinguishes pool-supervisor checks from the shared compliance checklist
|
||||
steps in pr_compliance_checklist_steps.py, preventing Behave AmbiguousStep
|
||||
errors when both feature files are run together.
|
||||
|
||||
Each validator is imported from the shared pr_compliance_checklist_steps module's
|
||||
validation logic (via the _verify module) to avoid code duplication while using
|
||||
unique step text prefixes ("Pool:") for disambiguation.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
AGENT_DEF_PATH = (
|
||||
PROJECT_ROOT / ".opencode" / "agents" / "implementation-pool-supervisor.md"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared validation helpers — identical logic to pr_compliance_checklist_steps.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_required_items = [
|
||||
"CHANGELOG.md",
|
||||
"CONTRIBUTORS.md",
|
||||
"ISSUES CLOSED",
|
||||
"CI passes",
|
||||
"BDD/Behave tests",
|
||||
"Epic reference",
|
||||
"forgejo-label-manager",
|
||||
"earliest open milestone",
|
||||
]
|
||||
|
||||
VALIDATORS: dict[str, Callable[[str], bool]] = {
|
||||
"includes checklist section": lambda c: "PR Compliance Checklist" in c,
|
||||
"is marked MANDATORY": lambda c: "MANDATORY" in c,
|
||||
"has CHANGELOG.md item": lambda c: "CHANGELOG.md" in c,
|
||||
"references Unreleased": lambda c: "[Unreleased]" in c,
|
||||
"has CONTRIBUTORS.md item": lambda c: "CONTRIBUTORS.md" in c,
|
||||
"instructs add or update": lambda c: "add or update" in c,
|
||||
"has commit footer item": lambda c: "Commit footer" in c,
|
||||
"specifies ISSUES CLOSED": lambda c: "ISSUES CLOSED" in c,
|
||||
"has CI passes item": lambda c: "CI passes" in c,
|
||||
"mentions quality gates": lambda c: "quality gates" in c,
|
||||
"has BDD tests item": lambda c: "BDD/Behave tests" in c,
|
||||
"instructs add or update features": lambda c: "added or updated" in c,
|
||||
"has Epic reference item": lambda c: "Epic reference" in c,
|
||||
"references parent Epic": lambda c: "parent Epic" in c,
|
||||
"has labels item": lambda c: "Labels" in c,
|
||||
"mentions forgejo-label-manager": lambda c: "forgejo-label-manager" in c,
|
||||
"has milestone item": lambda c: "Milestone" in c,
|
||||
"earliest open milestone": lambda c: "earliest open milestone" in c,
|
||||
"all 8 items present": lambda c: all(item in c for item in _required_items),
|
||||
}
|
||||
|
||||
|
||||
def _make_validator(key: str) -> Callable[[Any], None]:
|
||||
"""Factory that creates a typed Behave validator from a shared helper."""
|
||||
|
||||
def validator(context: Any) -> None:
|
||||
content = context.agent_def_content
|
||||
check_fn = VALIDATORS.get(key)
|
||||
assert check_fn(content), f"Pool supervisor agent definition failed: {key}"
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unique @given and @when — scoped to the pool supervisor agent def only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("the implementation-pool-supervisor.md agent definition exists")
|
||||
def step_agent_def_exists(context: Any) -> None:
|
||||
"""Verify the pool supervisor agent definition file exists."""
|
||||
assert AGENT_DEF_PATH.exists(), f"Agent definition not found at {AGENT_DEF_PATH}"
|
||||
context.agent_def_path = AGENT_DEF_PATH
|
||||
|
||||
|
||||
@when("I read the pool supervisor agent definition")
|
||||
def step_read_agent_def(context: Any) -> None:
|
||||
"""Read the pool supervisor agent definition."""
|
||||
context.agent_def_content = AGENT_DEF_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unique @then — prefixed with "Pool:" so they never conflict with the
|
||||
# shared pr_compliance_checklist_steps.py step definitions.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Scenario: Pool supervisor worker prompt includes the PR compliance checklist
|
||||
@then("Pool: worker prompt body includes the PR compliance checklist section")
|
||||
def pool_step_prompt_includes_checklist(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes the PR compliance checklist."""
|
||||
_make_validator("includes checklist section")(context)
|
||||
|
||||
|
||||
@then("Pool: the checklist is marked as MANDATORY")
|
||||
def pool_step_checklist_is_mandatory(context: Any) -> None:
|
||||
"""Verify the pool-supervisor checklist is marked as MANDATORY."""
|
||||
_make_validator("is marked MANDATORY")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 1 — CHANGELOG.md update required
|
||||
@then("Pool: worker prompt body includes a CHANGELOG.md checklist item")
|
||||
def pool_step_prompt_includes_changelog_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes a CHANGELOG.md checklist item."""
|
||||
_make_validator("has CHANGELOG.md item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item instructs workers to add an entry under the Unreleased section")
|
||||
def pool_step_changelog_item_unreleased(context: Any) -> None:
|
||||
"""Verify the CHANGELOG.md item mentions the Unreleased section."""
|
||||
_make_validator("references Unreleased")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 2 — CONTRIBUTORS.md update required
|
||||
@then("Pool: worker prompt body includes a CONTRIBUTORS.md checklist item")
|
||||
def pool_step_prompt_includes_contributors_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes a CONTRIBUTORS.md checklist item."""
|
||||
_make_validator("has CONTRIBUTORS.md item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item instructs workers to add or update their contribution entry")
|
||||
def pool_step_contributors_item_add_update(context: Any) -> None:
|
||||
"""Verify the CONTRIBUTORS.md item instructs workers to add or update."""
|
||||
_make_validator("instructs add or update")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 3 — commit footer required
|
||||
@then("Pool: worker prompt body includes a commit footer checklist item")
|
||||
def pool_step_prompt_includes_commit_footer_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes a commit footer checklist item."""
|
||||
_make_validator("has commit footer item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item specifies the ISSUES CLOSED footer format")
|
||||
def pool_step_commit_footer_issues_closed(context: Any) -> None:
|
||||
"""Verify the commit footer item specifies the ISSUES CLOSED format."""
|
||||
_make_validator("specifies ISSUES CLOSED")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 4 — CI must pass before PR creation
|
||||
@then("Pool: worker prompt body includes a CI passes checklist item")
|
||||
def pool_step_prompt_includes_ci_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes a CI passes checklist item."""
|
||||
_make_validator("has CI passes item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item instructs workers to verify all quality gates are green")
|
||||
def pool_step_ci_item_quality_gates(context: Any) -> None:
|
||||
"""Verify the CI item instructs workers to verify quality gates are green."""
|
||||
_make_validator("mentions quality gates")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 5 — BDD/Behave tests required
|
||||
@then("Pool: worker prompt body includes a BDD tests checklist item")
|
||||
def pool_step_prompt_includes_bdd_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes a BDD/Behave tests checklist item."""
|
||||
_make_validator("has BDD tests item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item instructs workers to add or update Behave feature files")
|
||||
def pool_step_bdd_item_feature_files(context: Any) -> None:
|
||||
"""Verify the BDD item instructs workers to add or update feature files."""
|
||||
_make_validator("instructs add or update features")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 6 — Epic reference required in PR description
|
||||
@then("Pool: worker prompt body includes an Epic reference checklist item")
|
||||
def pool_step_prompt_includes_epic_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes an Epic reference checklist item."""
|
||||
_make_validator("has Epic reference item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item instructs workers to reference the parent Epic issue number")
|
||||
def pool_step_epic_item_parent_reference(context: Any) -> None:
|
||||
"""Verify the Epic item instructs workers to reference the parent Epic."""
|
||||
_make_validator("references parent Epic")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 7 — Labels must be applied
|
||||
@then("Pool: worker prompt body includes a labels checklist item")
|
||||
def pool_step_prompt_includes_labels_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes a labels checklist item."""
|
||||
_make_validator("has labels item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item instructs workers to apply labels via forgejo-label-manager")
|
||||
def pool_step_labels_item_forgejo_label_manager(context: Any) -> None:
|
||||
"""Verify the labels item instructs workers to use forgejo-label-manager."""
|
||||
_make_validator("mentions forgejo-label-manager")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 8 — Milestone must be assigned
|
||||
@then("Pool: worker prompt body includes a milestone checklist item")
|
||||
def pool_step_prompt_includes_milestone_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes a milestone checklist item."""
|
||||
_make_validator("has milestone item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item instructs workers to assign the earliest open milestone")
|
||||
def pool_step_milestone_item_earliest(context: Any) -> None:
|
||||
"""Verify the milestone item instructs workers to assign the earliest open milestone."""
|
||||
_make_validator("earliest open milestone")(context)
|
||||
|
||||
|
||||
# Scenario: All 8 checklist items are present in the worker prompt
|
||||
@then("Pool: worker prompt body contains all 8 mandatory checklist items")
|
||||
def pool_step_prompt_contains_all_8_items(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body contains all 8 mandatory checklist items."""
|
||||
_make_validator("all 8 items present")(context)
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Step definitions for PyYAML security dependency verification (#9055).
|
||||
|
||||
Note: the shared steps for reading pyproject.toml and verifying importability
|
||||
are defined in ``tdd_a2a_sdk_dependency_steps.py`` to avoid AmbiguousStep
|
||||
conflicts. This file contains only the PyYAML-specific step definitions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from behave import then, when
|
||||
from behave.runner import Context
|
||||
from packaging.version import parse as parse_version
|
||||
|
||||
|
||||
@then('the dependency list should include a package matching "{pattern}"')
|
||||
def step_dependency_matches_pattern(context: Context, pattern: str) -> None:
|
||||
"""Assert that a dependency in the project dependencies matches the given regex pattern."""
|
||||
deps: list[str] = context.project_dependencies
|
||||
found = any(re.search(pattern, dep) for dep in deps)
|
||||
assert found, (
|
||||
f"No dependency matching '{pattern}' found in "
|
||||
f"[project.dependencies]. Current deps: {deps}"
|
||||
)
|
||||
|
||||
|
||||
@when("I read the PyYAML dependency specification from pyproject.toml")
|
||||
def step_read_pyyaml_dependency(context: Context) -> None:
|
||||
"""Read and parse the PyYAML dependency from pyproject.toml."""
|
||||
content = context.pyproject_path.read_bytes()
|
||||
data: dict[str, Any] = _load_toml(content)
|
||||
deps: list[str] = data.get("project", {}).get("dependencies", [])
|
||||
|
||||
# Find the pyyaml dependency entry
|
||||
pyyaml_dep = None
|
||||
for dep in deps:
|
||||
if dep.strip().startswith("pyyaml"):
|
||||
pyyaml_dep = dep.strip()
|
||||
break
|
||||
|
||||
assert pyyaml_dep is not None, (
|
||||
f"PyYAML dependency not found in [project.dependencies]. Current deps: {deps}"
|
||||
)
|
||||
|
||||
# Parse the minimum version from the constraint
|
||||
# Examples: "pyyaml>=6.0.3", "pyyaml>=6.0", "pyyaml==6.0.2"
|
||||
match = re.search(r"(?:>=|==|~>)([\d]+(?:\.[\d]+)*(?:\.\w+)*)", pyyaml_dep)
|
||||
assert match is not None, (
|
||||
f"Could not parse version from PyYAML dependency: {pyyaml_dep}"
|
||||
)
|
||||
|
||||
context.pyyaml_version_spec = pyyaml_dep
|
||||
context.pyyaml_min_version = match.group(1)
|
||||
|
||||
|
||||
@then('the minimum version should be at least "{expected}"')
|
||||
def step_pyyaml_minimum_version(context: Context, expected: str) -> None:
|
||||
"""Assert that the PyYAML minimum version is >= expected version."""
|
||||
min_version = context.pyyaml_min_version
|
||||
assert parse_version(min_version) >= parse_version(expected), (
|
||||
f"PyYAML minimum version {min_version} is less than required {expected}. "
|
||||
f"Dependency spec: {context.pyyaml_version_spec}"
|
||||
)
|
||||
|
||||
|
||||
def _flatten_toml_dict(d: dict[Any, Any]) -> dict[str, Any]:
|
||||
"""Recursively convert a tomlkit dict to a plain Python dict.
|
||||
|
||||
tomlkit wraps every value in a proxy object that behaves like the
|
||||
underlying Python type but is not identical to it. This helper
|
||||
strips those wrappers so the result is a plain ``dict[str, Any]``
|
||||
compatible with ``tomllib`` output.
|
||||
"""
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in d.items():
|
||||
if isinstance(value, dict):
|
||||
result[key] = _flatten_toml_dict(value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _toml_to_python(data: object) -> dict[str, Any]:
|
||||
"""Convert a tomlkit document to a plain Python dict.
|
||||
|
||||
Delegates recursive flattening to the module-level
|
||||
``_flatten_toml_dict`` helper so the logic stays testable in
|
||||
isolation and ruff does not flag nested function definitions.
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
return _flatten_toml_dict(data)
|
||||
raise TypeError(f"Expected dict-like TOML table, got {type(data).__name__}")
|
||||
|
||||
|
||||
def _load_toml(content: bytes) -> dict[str, Any]:
|
||||
"""Load a TOML file from bytes. Uses tomllib if available, else fallback."""
|
||||
try:
|
||||
import tomllib
|
||||
|
||||
return tomllib.loads(content.decode("utf-8"))
|
||||
except ImportError: # pragma: no cover — Python <3.11
|
||||
# Fallback for older Python versions without tomllib
|
||||
import tomlkit
|
||||
|
||||
parsed_doc = tomlkit.parse(content.decode("utf-8"))
|
||||
return _toml_to_python(parsed_doc)
|
||||
@@ -47,6 +47,7 @@ dependencies = [
|
||||
"tomlkit>=0.13.0", # TOML writing with comment preservation for config CLI
|
||||
"tenacity>=8.2.0", # Retry framework for service layer resilience
|
||||
"aiohttp>=3.13.4", # CVE-2026-34515 mitigation: open redirect vulnerability
|
||||
"pyyaml>=6.0.3", # Security: address known YAML parsing vulnerabilities
|
||||
"a2a-sdk>=0.3.0,<1.0.0", # A2A Python SDK — required transport for local (stdio) and server (HTTP) modes (ADR-047); pinned <1.0.0 (removed legacy A2AClient)
|
||||
]
|
||||
|
||||
|
||||
@@ -4,40 +4,6 @@ Library Collections
|
||||
Library Process
|
||||
|
||||
*** Test Cases ***
|
||||
V2 Actor Config Produces Provider And Graph Descriptor
|
||||
${config}= Set Variable ${OUTPUT DIR}/v2_actor.yaml
|
||||
${content}= Catenate SEPARATOR=\n
|
||||
... cleveragents:
|
||||
... ${SPACE*2}default_router: main_router
|
||||
... agents:
|
||||
... ${SPACE*2}paper_writer:
|
||||
... ${SPACE*4}type: llm
|
||||
... ${SPACE*4}config:
|
||||
... ${SPACE*6}provider: openai
|
||||
... ${SPACE*6}model: gpt-4
|
||||
... ${SPACE*6}unsafe: true
|
||||
... ${SPACE*6}options:
|
||||
... ${SPACE*8}temperature: 0.5
|
||||
... routes:
|
||||
... ${SPACE*2}main_router:
|
||||
... ${SPACE*4}type: stream
|
||||
... ${SPACE*4}operators:
|
||||
... ${SPACE*6}- type: map
|
||||
... ${SPACE*8}params:
|
||||
... ${SPACE*10}agent: paper_writer
|
||||
... ${SPACE*4}publications:
|
||||
... ${SPACE*6}- __output__
|
||||
Create File ${config} ${content}
|
||||
${result}= Run Process ${PYTHON} robot/helper_actor_config.py ${config}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
${payload}= Evaluate __import__('json').loads('''${result.stdout.strip()}''')
|
||||
Should Be Equal ${payload['provider']} openai
|
||||
Should Be Equal ${payload['model']} gpt-4
|
||||
Should Be True ${payload['unsafe']}
|
||||
List Should Contain Value ${payload['graph_keys']} agents
|
||||
List Should Contain Value ${payload['graph_keys']} routes
|
||||
Should Be Equal As Numbers ${payload['options']['temperature']} 0.5
|
||||
|
||||
Missing Config File Exits With Non-Zero Code And Stderr Message
|
||||
[Tags] tdd_issue tdd_issue_4202 tdd_expected_fail
|
||||
${missing}= Set Variable ${OUTPUT DIR}/does_not_exist_actor.yaml
|
||||
|
||||
@@ -386,11 +386,24 @@ M6 E2E Hierarchical Decomposition Via Plan Tree
|
||||
# P0-4: Hard assertion — tree command must succeed.
|
||||
Should Be Equal As Integers ${tree.rc} 0 msg=plan tree failed (rc=${tree.rc}): ${tree.stderr}
|
||||
Should Not Be Empty ${tree.stdout} Plan tree output should not be empty
|
||||
# P0-4: Hard assertion — at least one decision node must exist after execution.
|
||||
${decision_count}= Evaluate $tree.stdout.count('"decision_id"')
|
||||
Log Decision tree contains ${decision_count} decision node(s)
|
||||
Should Be True ${decision_count} >= 1
|
||||
... Plan tree should contain at least one decision node after execution (found ${decision_count})
|
||||
# P0-4: Hard assertion — tree output must contain the spec-required envelope.
|
||||
# The new envelope format wraps the tree in a command envelope with
|
||||
# "command", "status", "exit_code", "data", "timing", "messages" keys.
|
||||
# The "data" field contains "plan_id", "tree", "summary", "child_plans",
|
||||
# and "decision_ids" (a mapping of human-readable keys to decision ULIDs).
|
||||
${has_command_key}= Evaluate '"command"' in $tree.stdout
|
||||
Should Be True ${has_command_key}
|
||||
... Plan tree JSON output should contain spec-required envelope "command" key
|
||||
${has_data_key}= Evaluate '"data"' in $tree.stdout
|
||||
Should Be True ${has_data_key}
|
||||
... Plan tree JSON output should contain spec-required envelope "data" key
|
||||
${has_plan_id_key}= Evaluate '"plan_id"' in $tree.stdout
|
||||
Should Be True ${has_plan_id_key}
|
||||
... Plan tree JSON output should contain "plan_id" in envelope data
|
||||
# Check for decision_ids mapping (proves at least one decision was recorded)
|
||||
${has_decision_ids}= Evaluate '"decision_ids"' in $tree.stdout
|
||||
Should Be True ${has_decision_ids}
|
||||
... Plan tree should contain decision_ids mapping after execution
|
||||
# Check for hierarchical children (decomposition infrastructure indicator)
|
||||
${has_children_key}= Evaluate '"children"' in $tree.stdout
|
||||
IF ${has_children_key}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, cast
|
||||
from typing import Any, cast
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -42,7 +42,7 @@ class ActorConfiguration(BaseModel):
|
||||
|
||||
@staticmethod
|
||||
def load_blob_from_file(config_path: Path) -> dict[str, Any]:
|
||||
"""Load a configuration blob from a JSON or YAML file (v2 parity)."""
|
||||
"""Load a configuration blob from a JSON or YAML file."""
|
||||
path = config_path.expanduser()
|
||||
if not path.exists():
|
||||
raise ValueError(f"Config file not found: {path}")
|
||||
@@ -88,36 +88,25 @@ class ActorConfiguration(BaseModel):
|
||||
) -> ActorConfiguration:
|
||||
"""Coerce an incoming config blob plus CLI overrides into a validated model.
|
||||
|
||||
Supports both the legacy v2 ``agents/routes`` YAML layout and the v3
|
||||
``ActorConfigSchema`` format (identified by a top-level ``type`` key
|
||||
whose value is one of ``llm``, ``graph``, or ``tool``).
|
||||
Supports the v3 ``ActorConfigSchema`` format (identified by a top-level
|
||||
``type`` key whose value is one of ``llm``, ``graph``, or ``tool``).
|
||||
Provider and model may be at the top level OR nested inside
|
||||
``actors.<actor-name>.config``.
|
||||
"""
|
||||
|
||||
data: dict[str, Any] = dict(blob) if isinstance(blob, dict) else {}
|
||||
|
||||
# Try v3 extraction first (presence of 'type' with a v3 value).
|
||||
v3_provider, v3_model, v3_graph, v3_unsafe = cls._extract_v3_actor(data)
|
||||
|
||||
# Fall back to v2 extraction when v3 didn't match.
|
||||
v2_provider, v2_model, v2_graph, v2_unsafe = cls._extract_v2_actor(data)
|
||||
v2_options = cls._extract_v2_options(data)
|
||||
|
||||
resolved_provider = (
|
||||
provider
|
||||
or data.get("provider")
|
||||
or data.get("provider_type")
|
||||
or v3_provider
|
||||
or v2_provider
|
||||
)
|
||||
resolved_model = (
|
||||
model or data.get("model") or data.get("model_id") or v3_model or v2_model
|
||||
provider or data.get("provider") or data.get("provider_type") or v3_provider
|
||||
)
|
||||
resolved_model = model or data.get("model") or data.get("model_id") or v3_model
|
||||
resolved_graph = (
|
||||
graph_descriptor
|
||||
or data.get("graph_descriptor")
|
||||
or data.get("graph")
|
||||
or v3_graph
|
||||
or v2_graph
|
||||
)
|
||||
|
||||
options_raw = data.get("options")
|
||||
@@ -127,8 +116,6 @@ class ActorConfiguration(BaseModel):
|
||||
merged_options: dict[str, Any] = {}
|
||||
if default_options:
|
||||
merged_options.update(default_options)
|
||||
if v2_options:
|
||||
merged_options.update(v2_options)
|
||||
if options_value:
|
||||
merged_options.update(options_value)
|
||||
if option_overrides:
|
||||
@@ -136,10 +123,7 @@ class ActorConfiguration(BaseModel):
|
||||
|
||||
top_unsafe_raw = data.get("unsafe", False)
|
||||
resolved_unsafe = (
|
||||
(top_unsafe_raw is True or top_unsafe_raw == 1)
|
||||
or v3_unsafe
|
||||
or v2_unsafe
|
||||
or unsafe
|
||||
(top_unsafe_raw is True or top_unsafe_raw == 1) or v3_unsafe or unsafe
|
||||
)
|
||||
|
||||
if not resolved_provider:
|
||||
@@ -167,27 +151,6 @@ class ActorConfiguration(BaseModel):
|
||||
unsafe=resolved_unsafe,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@staticmethod
|
||||
def _resolve_actors_map(
|
||||
data: dict[str, Any],
|
||||
) -> tuple[Any, Literal["actors", "agents"]]:
|
||||
"""Resolve the actors/agents map from a parsed YAML blob.
|
||||
|
||||
The spec allows either ``actors:`` or ``agents:`` as the top-level
|
||||
map key (oneOf). ``actors:`` is preferred (spec-canonical); the
|
||||
``agents:`` key is only consulted when ``actors:`` is absent or
|
||||
explicitly ``null``.
|
||||
|
||||
Returns ``(map_obj, map_key)`` where *map_obj* is the raw value
|
||||
(may be ``None``, a non-dict, or a dict) and *map_key* is the
|
||||
string ``"actors"`` or ``"agents"``.
|
||||
"""
|
||||
actors_val = data.get("actors")
|
||||
if actors_val is not None:
|
||||
return actors_val, "actors"
|
||||
return data.get("agents"), "agents"
|
||||
|
||||
@staticmethod
|
||||
def _extract_v3_actor(
|
||||
data: dict[str, Any],
|
||||
@@ -195,13 +158,13 @@ class ActorConfiguration(BaseModel):
|
||||
"""Derive provider/model/graph from the v3 Actor YAML schema format.
|
||||
|
||||
The v3 schema is identified by a top-level ``type`` key whose value
|
||||
is one of ``llm``, ``graph``, or ``tool``. The model is taken from
|
||||
the top-level ``model`` key. Because v3 YAML does not carry a
|
||||
``provider`` field the provider is inferred from the model string:
|
||||
is one of ``llm``, ``graph``, or ``tool`` — OR by a top-level
|
||||
``actors:`` map containing at least one entry whose ``config:`` block
|
||||
carries a ``type`` field with one of those values.
|
||||
|
||||
* If the model contains ``/`` the prefix is used as provider
|
||||
(e.g. ``openai/gpt-4`` → provider ``openai``).
|
||||
* Otherwise ``"custom"`` is used as a fallback.
|
||||
Provider and model may appear at the top level OR nested inside
|
||||
``actors.<actor-name>.config``. When found in the nested config,
|
||||
they take precedence over top-level values.
|
||||
|
||||
For ``type: graph`` actors the ``route`` block is wrapped into a
|
||||
graph descriptor.
|
||||
@@ -211,28 +174,65 @@ class ActorConfiguration(BaseModel):
|
||||
may be ``None`` if the data does not look like v3.
|
||||
"""
|
||||
actor_type = data.get("type")
|
||||
if not isinstance(actor_type, str):
|
||||
return None, None, None, False
|
||||
if actor_type.lower() not in _V3_ACTOR_TYPES:
|
||||
return None, None, None, False
|
||||
if not isinstance(actor_type, str) or actor_type.lower() not in _V3_ACTOR_TYPES:
|
||||
actors_val = data.get("actors")
|
||||
if isinstance(actors_val, dict) and actors_val:
|
||||
for _, first_entry in actors_val.items():
|
||||
if isinstance(first_entry, dict):
|
||||
config_block = first_entry.get("config")
|
||||
if isinstance(config_block, dict):
|
||||
nested_type = config_block.get("type")
|
||||
is_v3_type = (
|
||||
isinstance(nested_type, str)
|
||||
and nested_type.lower() in _V3_ACTOR_TYPES
|
||||
)
|
||||
if is_v3_type:
|
||||
actor_type = nested_type
|
||||
break
|
||||
break
|
||||
if not isinstance(actor_type, str):
|
||||
return None, None, None, False
|
||||
|
||||
model_value = data.get("model")
|
||||
# C2: model is optional for TOOL actors — only reject when model
|
||||
# is absent for types that require it (LLM, GRAPH).
|
||||
if not model_value or not isinstance(model_value, str):
|
||||
if actor_type.lower() != "tool":
|
||||
return None, None, None, False
|
||||
# TOOL actors may omit model; use empty string as sentinel.
|
||||
model_value = ""
|
||||
|
||||
# Infer provider from model string or default to "custom".
|
||||
provider_value = (
|
||||
infer_provider_from_model(model_value) if model_value else "custom"
|
||||
)
|
||||
|
||||
provider_value = data.get("provider")
|
||||
unsafe_flag = bool(data.get("unsafe", False))
|
||||
|
||||
# Build a graph descriptor for graph actors from the route block.
|
||||
if not model_value or not isinstance(model_value, str):
|
||||
if actor_type.lower() != "tool":
|
||||
actors_val = data.get("actors")
|
||||
if isinstance(actors_val, dict) and actors_val:
|
||||
for _, first_entry in actors_val.items():
|
||||
if isinstance(first_entry, dict):
|
||||
config_block = first_entry.get("config")
|
||||
if isinstance(config_block, dict):
|
||||
mv = config_block.get("model")
|
||||
if isinstance(mv, str) and mv:
|
||||
model_value = mv
|
||||
break
|
||||
break
|
||||
if not model_value:
|
||||
if actor_type.lower() != "tool":
|
||||
return None, None, None, False
|
||||
model_value = ""
|
||||
|
||||
if not provider_value or not isinstance(provider_value, str):
|
||||
actors_val = data.get("actors")
|
||||
if isinstance(actors_val, dict) and actors_val:
|
||||
for _, first_entry in actors_val.items():
|
||||
if isinstance(first_entry, dict):
|
||||
config_block = first_entry.get("config")
|
||||
if isinstance(config_block, dict):
|
||||
pv = config_block.get("provider")
|
||||
if isinstance(pv, str) and pv:
|
||||
provider_value = pv
|
||||
break
|
||||
break
|
||||
|
||||
if not provider_value:
|
||||
provider_value = (
|
||||
infer_provider_from_model(model_value) if model_value else "custom"
|
||||
)
|
||||
|
||||
graph_descriptor: dict[str, Any] | None = None
|
||||
if actor_type.lower() == "graph":
|
||||
route_raw = data.get("route")
|
||||
@@ -244,127 +244,3 @@ class ActorConfiguration(BaseModel):
|
||||
}
|
||||
|
||||
return provider_value, model_value, graph_descriptor, unsafe_flag
|
||||
|
||||
@staticmethod
|
||||
def _extract_v2_actor(
|
||||
data: dict[str, Any],
|
||||
) -> tuple[str | None, str | None, dict[str, Any] | None, bool]:
|
||||
"""Derive provider/model/graph from the v2/spec YAML actor config format.
|
||||
|
||||
Supports both the legacy ``agents:`` map key and the spec-compliant
|
||||
``actors:`` map key. Within each actor's ``config:`` block, the
|
||||
combined ``actor`` field (e.g. ``"openai/gpt-4"``) is also accepted
|
||||
as an alternative to separate ``provider`` + ``model`` fields.
|
||||
"""
|
||||
|
||||
map_obj, map_key = ActorConfiguration._resolve_actors_map(data)
|
||||
|
||||
# NOTE: Any non-None value for ``actors:`` — including falsy values
|
||||
# such as ``actors: {}``, ``actors: false``, ``actors: 0``, or
|
||||
# ``actors: ""`` — blocks the ``agents:`` fallback. This is
|
||||
# intentional: a present ``actors:`` key explicitly declares the
|
||||
# actors map (even if empty or invalid), whereas ``actors: null``
|
||||
# (or absent) defers to ``agents:``.
|
||||
#
|
||||
# NOTE: Only the **first** entry in the actors/agents map is inspected
|
||||
# for provider, model, unsafe, and graph descriptor. The ``add()``
|
||||
# method rejects multi-actor YAML (>1 entry) with a ``ValidationError``
|
||||
# to prevent silent data loss. The multi-actor case is handled by the
|
||||
# v3 schema validation path.
|
||||
if isinstance(map_obj, dict) and map_obj:
|
||||
map_dict = cast(dict[str, Any], map_obj)
|
||||
# Only the first actor entry is used for provider/model extraction.
|
||||
for actor_key, first_entry in map_dict.items():
|
||||
if isinstance(first_entry, dict):
|
||||
entry_dict = cast(dict[str, Any], first_entry)
|
||||
config_block = entry_dict.get("config")
|
||||
if isinstance(config_block, dict):
|
||||
config_dict = cast(dict[str, Any], config_block)
|
||||
|
||||
# Support the combined ``actor`` field format
|
||||
# (e.g. ``"openai/gpt-4"`` → provider + model).
|
||||
# NOTE: ``provider_type`` and ``model_id`` are
|
||||
# accepted as aliases for backward compatibility,
|
||||
# even though the spec's JSON schema for the
|
||||
# ``config:`` block only lists ``provider``,
|
||||
# ``model``, and ``actor``.
|
||||
# Use ``is not None`` (not ``or``) so that falsy
|
||||
# values like ``""`` or ``0`` are preserved rather
|
||||
# than falling through to the alias.
|
||||
provider_value = config_dict.get("provider")
|
||||
if provider_value is None:
|
||||
provider_value = config_dict.get("provider_type")
|
||||
model_value = config_dict.get("model")
|
||||
if model_value is None:
|
||||
model_value = config_dict.get("model_id")
|
||||
|
||||
combined_actor = config_dict.get("actor")
|
||||
if (
|
||||
combined_actor
|
||||
and isinstance(combined_actor, str)
|
||||
and "/" in combined_actor
|
||||
):
|
||||
parts = combined_actor.split("/", 1)
|
||||
if not provider_value:
|
||||
provider_value = parts[0]
|
||||
if not model_value:
|
||||
model_value = parts[1]
|
||||
|
||||
unsafe_raw = config_dict.get("unsafe", False)
|
||||
# Accept only boolean ``True`` or integer ``1`` as
|
||||
# unsafe. Note: ``unsafe_raw == 1`` also matches
|
||||
# ``1.0`` (float) due to Python's numeric equality
|
||||
# rules — this is fail-safe (treats 1.0 as unsafe).
|
||||
unsafe_flag = unsafe_raw is True or unsafe_raw == 1
|
||||
# The graph descriptor is always built and returned
|
||||
# when a valid ``config:`` block is found, regardless
|
||||
# of whether ``provider``/``model`` were extracted.
|
||||
# This allows the caller to preserve the graph
|
||||
# structure even when provider/model come from
|
||||
# elsewhere (e.g. top-level fields).
|
||||
descriptor: dict[str, Any] = {
|
||||
"agent": actor_key,
|
||||
map_key: dict(map_dict),
|
||||
}
|
||||
for key in (
|
||||
"routes",
|
||||
"merges",
|
||||
"templates",
|
||||
"cleveragents",
|
||||
):
|
||||
if key in data and data[key] is not None:
|
||||
descriptor[key] = data[key]
|
||||
return (
|
||||
str(provider_value) if provider_value else None,
|
||||
str(model_value) if model_value else None,
|
||||
descriptor,
|
||||
unsafe_flag,
|
||||
)
|
||||
break # first entry only
|
||||
return None, None, None, False
|
||||
|
||||
@staticmethod
|
||||
def _extract_v2_options(data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Extract option defaults from the v2/spec actor config format.
|
||||
|
||||
Supports both ``actors:`` (spec-canonical) and ``agents:`` (legacy)
|
||||
map keys.
|
||||
"""
|
||||
|
||||
map_obj, _ = ActorConfiguration._resolve_actors_map(data)
|
||||
if isinstance(map_obj, dict) and map_obj:
|
||||
map_dict = cast(dict[str, Any], map_obj)
|
||||
for _, first_entry in map_dict.items():
|
||||
if isinstance(first_entry, dict):
|
||||
entry_dict = cast(dict[str, Any], first_entry)
|
||||
config_block = entry_dict.get("config")
|
||||
if isinstance(config_block, dict):
|
||||
config_dict = cast(dict[str, Any], config_block)
|
||||
options = config_dict.get("options")
|
||||
if isinstance(options, dict):
|
||||
# Return a shallow copy so downstream mutations
|
||||
# (e.g. ``config_blob["options"]`` updates) do
|
||||
# not propagate back into the original blob.
|
||||
return dict(cast(dict[str, Any], options))
|
||||
break # first entry only
|
||||
return None
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
"""Legacy (v2) actor registration logic extracted from ``ActorRegistry``.
|
||||
|
||||
This module handles the v2 blob registration path, including provider/model
|
||||
extraction, unsafe flag enforcement, and nested option merging. It is used
|
||||
by :class:`ActorRegistry` to keep the main registry module under the 500-line
|
||||
limit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pydantic
|
||||
|
||||
from cleveragents.actor.config import ActorConfiguration
|
||||
from cleveragents.actor.schema import ActorConfigSchema, is_v3_yaml
|
||||
from cleveragents.application.services.actor_service import ActorService
|
||||
from cleveragents.core.exceptions import NotFoundError, ValidationError
|
||||
from cleveragents.domain.models.core import Actor
|
||||
|
||||
|
||||
def add_legacy(
|
||||
blob: dict[str, Any],
|
||||
*,
|
||||
yaml_text: str,
|
||||
update: bool,
|
||||
schema_version: str,
|
||||
compiled_metadata: dict[str, Any] | None,
|
||||
unsafe: bool = False,
|
||||
allow_unsafe: bool = False,
|
||||
actor_service: ActorService,
|
||||
ensure_namespaced: callable, # type: ignore[type-arg]
|
||||
) -> Actor:
|
||||
"""Register an actor from a legacy (v2) blob.
|
||||
|
||||
Args:
|
||||
blob: Parsed YAML blob.
|
||||
yaml_text: Original YAML source text.
|
||||
update: When ``True`` allow overwriting an existing actor.
|
||||
schema_version: Schema version string.
|
||||
compiled_metadata: Optional compiler-produced metadata dict.
|
||||
unsafe: Asserts that the actor IS unsafe.
|
||||
allow_unsafe: Permits registration of an already-unsafe actor.
|
||||
actor_service: The actor persistence service.
|
||||
ensure_namespaced: Function to namespace actor names.
|
||||
|
||||
Returns:
|
||||
The persisted ``Actor`` domain object.
|
||||
"""
|
||||
name_raw: str = blob.get("name", "")
|
||||
if not name_raw:
|
||||
raise ValidationError("Actor YAML must include a 'name' field.")
|
||||
|
||||
# ── Validate v3 YAML via ActorConfigSchema if detected ──────────────────
|
||||
# This ensures v3 actors are validated against the full schema, including
|
||||
# cycle detection, required fields, and enum validation.
|
||||
blob_is_v3 = is_v3_yaml(blob)
|
||||
if blob_is_v3:
|
||||
try:
|
||||
ActorConfigSchema.model_validate(blob)
|
||||
except pydantic.ValidationError as exc:
|
||||
raise ValidationError(f"Invalid v3 actor configuration: {exc}") from exc
|
||||
|
||||
name = ensure_namespaced(name_raw)
|
||||
provider_raw = blob.get("provider") or blob.get("provider_type", "")
|
||||
model_raw = blob.get("model") or blob.get("model_id", "")
|
||||
|
||||
# ── Guard: reject multi-actor YAML ─────────────────────────────────
|
||||
# ``add()`` is designed for single-actor YAML files. Provider, model,
|
||||
# unsafe, and graph extraction all operate on the *first* entry only,
|
||||
# so a multi-actor map would silently discard later entries (including
|
||||
# their ``unsafe`` flags — a spec-compliance and security concern).
|
||||
# Multi-actor configurations must be split and registered individually.
|
||||
# Uses ``_resolve_actors_map()`` for consistent ``actors:``/``agents:``
|
||||
# resolution across all call sites.
|
||||
actors_map, _ = ActorConfiguration._resolve_actors_map(blob)
|
||||
if isinstance(actors_map, dict) and len(actors_map) > 1:
|
||||
raise ValidationError(
|
||||
"registry.add() only supports single-actor YAML files. "
|
||||
"Multi-actor configurations must be registered individually."
|
||||
)
|
||||
|
||||
# Always extract from the nested ``actors:``/``agents:`` map so that
|
||||
# ``unsafe`` and ``graph_descriptor`` are captured even when top-level
|
||||
# ``provider``/``model`` are present. This matches the behaviour of
|
||||
# ``from_blob()`` which unconditionally calls ``_extract_v2_actor()``.
|
||||
nested_provider, nested_model, nested_graph, nested_unsafe = (
|
||||
ActorConfiguration._extract_v2_actor(blob)
|
||||
)
|
||||
if not provider_raw and nested_provider:
|
||||
provider_raw = nested_provider
|
||||
if not model_raw and nested_model:
|
||||
model_raw = nested_model
|
||||
|
||||
if not blob_is_v3 and (not provider_raw or not model_raw):
|
||||
raise ValidationError("Actor YAML must include 'provider' and 'model' fields.")
|
||||
|
||||
# For v3 actors without provider/model (e.g. TOOL actors), provider_raw
|
||||
# and model_raw may be empty strings here. This is expected — the legacy
|
||||
# canonicalization path in upsert_actor() / from_blob() will reject
|
||||
# provider-less TOOL actors. See follow-up issue #9971.
|
||||
provider = str(provider_raw) if provider_raw else ""
|
||||
model = str(model_raw) if model_raw else ""
|
||||
|
||||
top_unsafe_raw = blob.get("unsafe", False)
|
||||
# Accept only boolean ``True`` or integer ``1`` as unsafe. Note:
|
||||
# ``top_unsafe_raw == 1`` also matches ``1.0`` (float) due to
|
||||
# Python's numeric equality rules — this is fail-safe (treats 1.0
|
||||
# as unsafe).
|
||||
#
|
||||
# ``unsafe`` (the parameter) is an *assertion* — "this actor IS
|
||||
# unsafe" (maps to the spec's "CLI --unsafe flag").
|
||||
# ``allow_unsafe`` is a *permission* — "I PERMIT an already-unsafe
|
||||
# actor to be registered" but does NOT itself make the actor unsafe.
|
||||
# Therefore only ``unsafe`` participates in the stored value; the
|
||||
# spec rule is: "the result is true if *any* of: top-level unsafe,
|
||||
# nested unsafe, or the CLI --unsafe flag is true."
|
||||
effective_unsafe = (
|
||||
(top_unsafe_raw is True or top_unsafe_raw == 1) or nested_unsafe or unsafe
|
||||
)
|
||||
if effective_unsafe and not (unsafe or allow_unsafe):
|
||||
raise ValidationError(
|
||||
"Actor configuration is marked unsafe; pass unsafe=True "
|
||||
"or allow_unsafe=True to confirm."
|
||||
)
|
||||
if not update:
|
||||
try:
|
||||
actor_service.get_actor(name)
|
||||
except NotFoundError:
|
||||
pass # Actor does not exist yet — proceed with add
|
||||
else:
|
||||
raise ValidationError(
|
||||
f"Actor '{name}' already exists. Pass update=True to overwrite."
|
||||
)
|
||||
|
||||
# ── Extract nested options so they are not silently discarded ─────
|
||||
# ``from_blob()`` calls ``_extract_v2_options()`` internally, but
|
||||
# ``add()`` bypasses ``from_blob()``. Without this call, options
|
||||
# nested inside ``actors.<name>.config.options`` would be lost.
|
||||
v2_options = ActorConfiguration._extract_v2_options(blob)
|
||||
|
||||
config_blob: dict[str, Any] = dict(blob)
|
||||
config_blob.setdefault("source", "yaml")
|
||||
if v2_options is not None:
|
||||
existing_options = config_blob.get("options")
|
||||
if existing_options is None or not isinstance(existing_options, dict):
|
||||
# No valid top-level options dict — use nested options directly.
|
||||
config_blob["options"] = v2_options
|
||||
else:
|
||||
# Both top-level and nested options exist. Match ``from_blob()``
|
||||
# merge semantics: nested options as base, top-level overrides.
|
||||
merged = dict(v2_options)
|
||||
merged.update(existing_options)
|
||||
config_blob["options"] = merged
|
||||
|
||||
top_graph_raw = blob.get("graph_descriptor")
|
||||
top_graph = top_graph_raw if top_graph_raw is not None else blob.get("graph")
|
||||
resolved_graph = top_graph if top_graph is not None else nested_graph
|
||||
|
||||
return actor_service.upsert_actor(
|
||||
name=name,
|
||||
provider=provider,
|
||||
model=model,
|
||||
config_blob=config_blob,
|
||||
graph_descriptor=resolved_graph,
|
||||
unsafe=effective_unsafe,
|
||||
set_default=False,
|
||||
yaml_text=yaml_text,
|
||||
schema_version=schema_version,
|
||||
compiled_metadata=compiled_metadata,
|
||||
)
|
||||
@@ -26,7 +26,6 @@ import pydantic
|
||||
import yaml
|
||||
|
||||
from cleveragents.actor.config import ActorConfiguration
|
||||
from cleveragents.actor.legacy_registry import add_legacy
|
||||
from cleveragents.actor.schema import ActorConfigSchema, is_v3_yaml
|
||||
from cleveragents.actor.v3_registry import add_v3, is_v3_blob
|
||||
from cleveragents.application.services.actor_service import ActorService
|
||||
@@ -326,6 +325,7 @@ class ActorRegistry:
|
||||
# v3 path: validate against ActorConfigSchema when detected
|
||||
# ----------------------------------------------------------
|
||||
if is_v3_blob(blob):
|
||||
effective_allow_unsafe = allow_unsafe or unsafe
|
||||
return add_v3(
|
||||
blob,
|
||||
yaml_text=yaml_text,
|
||||
@@ -333,22 +333,12 @@ class ActorRegistry:
|
||||
schema_version=version,
|
||||
compiled_metadata=compiled_metadata,
|
||||
actor_service=self._actor_service,
|
||||
allow_unsafe=allow_unsafe,
|
||||
allow_unsafe=effective_allow_unsafe,
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Legacy (v2) path: flat provider/model extraction
|
||||
# ----------------------------------------------------------
|
||||
return add_legacy(
|
||||
blob,
|
||||
yaml_text=yaml_text,
|
||||
update=update,
|
||||
schema_version=version,
|
||||
compiled_metadata=compiled_metadata,
|
||||
unsafe=unsafe,
|
||||
allow_unsafe=allow_unsafe,
|
||||
actor_service=self._actor_service,
|
||||
ensure_namespaced=self._ensure_namespaced,
|
||||
raise ValidationError(
|
||||
"Invalid actor configuration: no valid type field found. "
|
||||
"Expected a v3 YAML with type: llm, type: graph, or type: tool."
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -26,9 +26,111 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_v3_blob(blob: dict[str, Any]) -> bool:
|
||||
"""Return ``True`` when *blob* looks like a v3 ``ActorConfigSchema``."""
|
||||
"""Return ``True`` when *blob* looks like a v3 ``ActorConfigSchema``.
|
||||
|
||||
A blob is considered v3 if it has:
|
||||
1. A top-level ``type`` field with a v3 value (llm/graph/tool), OR
|
||||
2. An ``actors:`` map containing at least one entry whose ``type`` field
|
||||
has a v3 value (nested actor format per spec line 21417).
|
||||
|
||||
The nested format allows configurations like:
|
||||
|
||||
name: local/my-actor
|
||||
actors:
|
||||
my_actor:
|
||||
type: llm
|
||||
config:
|
||||
provider: anthropic
|
||||
model: claude-3.5-sonnet
|
||||
|
||||
Returns:
|
||||
True if the blob appears to be v3 format.
|
||||
"""
|
||||
actor_type = blob.get("type")
|
||||
return isinstance(actor_type, str) and actor_type.lower() in _V3_ACTOR_TYPES
|
||||
if isinstance(actor_type, str) and actor_type.lower() in _V3_ACTOR_TYPES:
|
||||
return True
|
||||
|
||||
actors_val = blob.get("actors")
|
||||
if isinstance(actors_val, dict) and actors_val:
|
||||
for _, entry in actors_val.items():
|
||||
if isinstance(entry, dict):
|
||||
nested_type = entry.get("type")
|
||||
if (
|
||||
isinstance(nested_type, str)
|
||||
and nested_type.lower() in _V3_ACTOR_TYPES
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _extract_nested_v3_config(data: dict[str, Any]) -> None:
|
||||
"""Extract provider/model/type/description/name from nested actors.<name> block.
|
||||
|
||||
Per spec line 21417, v3 YAML may have provider/model at the top level
|
||||
OR nested inside ``actors.<actor-name>.config``. When found in the nested
|
||||
config, values are promoted to the top level.
|
||||
|
||||
Similarly, ``type``, ``description``, and ``name`` may be at the top level
|
||||
OR nested inside ``actors.<actor-name>`` (for type/description) or
|
||||
``actors.<actor-name>.config`` (for name). This function promotes all
|
||||
of these from their nested locations when not present at the top level.
|
||||
|
||||
Args:
|
||||
data: The actor blob (modified in place).
|
||||
"""
|
||||
actors_val = data.get("actors")
|
||||
if not isinstance(actors_val, dict) or not actors_val:
|
||||
return
|
||||
|
||||
for _, entry in actors_val.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
|
||||
if "type" not in data:
|
||||
type_val = entry.get("type")
|
||||
if isinstance(type_val, str) and type_val:
|
||||
data["type"] = type_val
|
||||
|
||||
if "description" not in data:
|
||||
desc_val = entry.get("description")
|
||||
if isinstance(desc_val, str) and desc_val:
|
||||
data["description"] = desc_val
|
||||
|
||||
if "name" not in data:
|
||||
name_val = entry.get("name")
|
||||
if isinstance(name_val, str) and name_val:
|
||||
data["name"] = name_val
|
||||
|
||||
config_block = entry.get("config")
|
||||
if isinstance(config_block, dict):
|
||||
if "model" not in data:
|
||||
mv = config_block.get("model")
|
||||
if isinstance(mv, str) and mv:
|
||||
data["model"] = mv
|
||||
|
||||
if "provider" not in data:
|
||||
pv = config_block.get("provider")
|
||||
if isinstance(pv, str) and pv:
|
||||
data["provider"] = pv
|
||||
|
||||
if "name" not in data:
|
||||
name_val = config_block.get("name")
|
||||
if isinstance(name_val, str) and name_val:
|
||||
data["name"] = name_val
|
||||
|
||||
if "description" not in data:
|
||||
desc_val = config_block.get("description")
|
||||
if isinstance(desc_val, str) and desc_val:
|
||||
data["description"] = desc_val
|
||||
|
||||
if (
|
||||
"type" in data
|
||||
and "description" in data
|
||||
and "model" in data
|
||||
and "provider" in data
|
||||
):
|
||||
break
|
||||
|
||||
|
||||
def add_v3(
|
||||
@@ -73,6 +175,10 @@ def add_v3(
|
||||
if isinstance(name_raw, str) and name_raw and "/" not in name_raw:
|
||||
data["name"] = f"local/{name_raw}"
|
||||
|
||||
# Extract provider and model from nested actors.<name>.config block if present.
|
||||
# This handles the v3 nested format per spec line 21417.
|
||||
_extract_nested_v3_config(data)
|
||||
|
||||
# Infer provider before schema validation — the schema now requires
|
||||
# provider for LLM and GRAPH actors (added by #5869).
|
||||
if not data.get("provider"):
|
||||
|
||||
@@ -13,6 +13,12 @@ from __future__ import annotations
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
||||
from langchain_core.messages.tool import ToolMessage
|
||||
|
||||
from cleveragents.domain.models.core.session import MessageRole
|
||||
from cleveragents.tool.actor_runtime import LLMResponse, LLMToolCall
|
||||
|
||||
# Token cost estimation constants (USD per 1K tokens).
|
||||
# These are rough defaults; real cost comes from the LLM response metadata.
|
||||
_COST_PER_1K_INPUT = 0.001
|
||||
@@ -54,9 +60,7 @@ def extract_token_usage(response: Any) -> tuple[int, int]:
|
||||
if usage:
|
||||
try:
|
||||
inp = int(usage.get("input_tokens") or usage.get("prompt_tokens") or 0)
|
||||
out = int(
|
||||
usage.get("output_tokens") or usage.get("completion_tokens") or 0
|
||||
)
|
||||
out = int(usage.get("output_tokens") or usage.get("completion_tokens") or 0)
|
||||
return inp, out
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
@@ -94,15 +98,6 @@ def history_to_langchain_messages(
|
||||
Returns an ordered list of LangChain message objects, empty if
|
||||
no history is available.
|
||||
"""
|
||||
from langchain_core.messages import (
|
||||
AIMessage,
|
||||
HumanMessage,
|
||||
SystemMessage,
|
||||
)
|
||||
from langchain_core.messages.tool import ToolMessage
|
||||
|
||||
from cleveragents.domain.models.core.session import MessageRole
|
||||
|
||||
lc_messages: list[Any] = []
|
||||
for msg in messages:
|
||||
role = msg.role
|
||||
@@ -161,7 +156,6 @@ class LangChainSessionCaller:
|
||||
self._last_response: Any = None
|
||||
# Build initial accumulated messages: optional system + history
|
||||
self._accumulated: list[Any] = []
|
||||
from langchain_core.messages import SystemMessage
|
||||
|
||||
# Prepend system prompt only when history doesn't already have one
|
||||
has_system = any(isinstance(m, SystemMessage) for m in history)
|
||||
@@ -199,11 +193,6 @@ class LangChainSessionCaller:
|
||||
:class:`~cleveragents.tool.actor_runtime.LLMResponse` with the
|
||||
LLM's text response.
|
||||
"""
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langchain_core.messages.tool import ToolMessage
|
||||
|
||||
from cleveragents.tool.actor_runtime import LLMResponse, LLMToolCall
|
||||
|
||||
if self._first_call:
|
||||
# First iteration: append the user's prompt and invoke
|
||||
self._accumulated.append(HumanMessage(content=prompt))
|
||||
|
||||
@@ -21,6 +21,7 @@ from collections.abc import Callable, Iterator
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import structlog
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
try:
|
||||
@@ -406,8 +407,6 @@ class SessionWorkflow:
|
||||
Used by ``tell_stream()`` to avoid the duplicate-user-message bug (C1).
|
||||
The caller controls when history is sampled.
|
||||
"""
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
lc_history = history_to_langchain_messages(history_messages)
|
||||
|
||||
messages: list[Any] = []
|
||||
|
||||
@@ -27,7 +27,7 @@ import re
|
||||
import shutil
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Literal, cast
|
||||
|
||||
@@ -4117,6 +4117,131 @@ def _get_decision_label(decision_type: str, per_type_ordinal: int = 0) -> str:
|
||||
return base_label
|
||||
|
||||
|
||||
def _build_tree_data(
|
||||
plan_id: str,
|
||||
tree_data: list[dict[str, object]],
|
||||
decisions: list[Decision],
|
||||
show_superseded: bool = False,
|
||||
started_at: datetime | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Build the data payload for ``agents plan tree --format json/yaml``.
|
||||
|
||||
Returns the ``data`` dict that will be wrapped in the spec-required
|
||||
command envelope by ``format_output``.
|
||||
"""
|
||||
filtered = (
|
||||
decisions if show_superseded else [d for d in decisions if not d.is_superseded]
|
||||
)
|
||||
|
||||
def count_nodes(nodes: list[dict[str, object]]) -> int:
|
||||
count = 0
|
||||
for node in nodes:
|
||||
count += 1
|
||||
children = node.get("children", [])
|
||||
if isinstance(children, list):
|
||||
count += count_nodes(children)
|
||||
return count
|
||||
|
||||
def compute_depth(nodes: list[dict[str, object]]) -> int:
|
||||
if not nodes:
|
||||
return 0
|
||||
max_depth = 0
|
||||
for node in nodes:
|
||||
children = node.get("children", [])
|
||||
if isinstance(children, list) and children:
|
||||
max_depth = max(max_depth, 1 + compute_depth(children))
|
||||
return max_depth
|
||||
|
||||
nodes_count = count_nodes(tree_data)
|
||||
tree_depth = compute_depth(tree_data)
|
||||
|
||||
child_plan_ids: set[str] = set()
|
||||
for d in filtered:
|
||||
if d.decision_type in ("subplan_spawn", "subplan_parallel_spawn") and d.plan_id:
|
||||
child_plan_ids.add(d.plan_id)
|
||||
|
||||
child_plans_count = len(child_plan_ids)
|
||||
child_plans_str = f"{child_plans_count}+" if child_plans_count > 0 else "0"
|
||||
|
||||
invariants_count = sum(
|
||||
1 for d in filtered if d.decision_type == "invariant_enforced"
|
||||
)
|
||||
|
||||
superseded_count = sum(1 for d in decisions if d.is_superseded)
|
||||
|
||||
summary = {
|
||||
"nodes": nodes_count,
|
||||
"depth": tree_depth,
|
||||
"child_plans": child_plans_str,
|
||||
"invariants": invariants_count,
|
||||
"superseded": superseded_count,
|
||||
}
|
||||
|
||||
type_counts: dict[str, int] = {}
|
||||
decision_ids: dict[str, str] = {}
|
||||
|
||||
for d in filtered:
|
||||
type_counts[d.decision_type] = type_counts.get(d.decision_type, 0) + 1
|
||||
ordinal = type_counts[d.decision_type]
|
||||
|
||||
if d.decision_type == "prompt_definition":
|
||||
key = "root"
|
||||
elif d.decision_type == "invariant_enforced":
|
||||
key = f"invariant_{ordinal}"
|
||||
elif d.decision_type == "strategy_choice":
|
||||
key = "strategy"
|
||||
elif d.decision_type == "implementation_choice":
|
||||
key = f"implementation_{ordinal}"
|
||||
elif d.decision_type == "subplan_spawn":
|
||||
key = f"spawn_{ordinal}"
|
||||
elif d.decision_type == "subplan_parallel_spawn":
|
||||
key = f"parallel_{ordinal}"
|
||||
else:
|
||||
key = f"{d.decision_type}_{ordinal}"
|
||||
|
||||
decision_ids[key] = d.decision_id
|
||||
|
||||
child_plans_list: list[dict[str, object]] = []
|
||||
for d in filtered:
|
||||
if d.decision_type in ("subplan_spawn", "subplan_parallel_spawn") and d.plan_id:
|
||||
child_plans_list.append(
|
||||
{
|
||||
"id": d.plan_id,
|
||||
"phase": "execute",
|
||||
"state": "queued",
|
||||
}
|
||||
)
|
||||
|
||||
def convert_tree_node(node: dict[str, object]) -> dict[str, object]:
|
||||
"""Convert internal tree node format to spec format."""
|
||||
spec_node: dict[str, object] = {
|
||||
"type": node.get("type"),
|
||||
"description": node.get("question") or node.get("description"),
|
||||
}
|
||||
|
||||
if node.get("confidence") is not None:
|
||||
spec_node["confidence"] = node.get("confidence")
|
||||
|
||||
if node.get("type") in ("subplan_spawn", "subplan_parallel_spawn"):
|
||||
spec_node["plan_id"] = node.get("plan_id", "")
|
||||
|
||||
children = node.get("children", [])
|
||||
if isinstance(children, list) and children:
|
||||
spec_node["children"] = [convert_tree_node(child) for child in children]
|
||||
|
||||
return spec_node
|
||||
|
||||
spec_tree = convert_tree_node(tree_data[0]) if tree_data else None
|
||||
|
||||
return {
|
||||
"plan_id": plan_id,
|
||||
"tree": spec_tree,
|
||||
"summary": summary,
|
||||
"child_plans": child_plans_list,
|
||||
"decision_ids": decision_ids,
|
||||
}
|
||||
|
||||
|
||||
@app.command("tree")
|
||||
def tree_decisions_cmd(
|
||||
plan_id: Annotated[
|
||||
@@ -4139,6 +4264,7 @@ def tree_decisions_cmd(
|
||||
"""Display the decision tree for a plan."""
|
||||
from cleveragents.application.container import get_container
|
||||
|
||||
_tree_cmd_start = datetime.now(UTC)
|
||||
container = get_container()
|
||||
svc = container.decision_service()
|
||||
decisions = svc.list_decisions(plan_id)
|
||||
@@ -4153,7 +4279,17 @@ def tree_decisions_cmd(
|
||||
)
|
||||
|
||||
if fmt in (OutputFormat.JSON, OutputFormat.YAML):
|
||||
console.print(format_output(tree_data, fmt))
|
||||
tree_data_dict = _build_tree_data(
|
||||
plan_id, tree_data, decisions, show_superseded, started_at=_tree_cmd_start
|
||||
)
|
||||
console.print(
|
||||
format_output(
|
||||
tree_data_dict,
|
||||
fmt,
|
||||
command="plan tree",
|
||||
messages=[{"level": "ok", "text": "Decision tree rendered"}],
|
||||
)
|
||||
)
|
||||
elif fmt == OutputFormat.TABLE:
|
||||
# Flatten for table view
|
||||
filtered = (
|
||||
|
||||
@@ -446,6 +446,7 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-ulid" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "restrictedpython" },
|
||||
{ name = "rx" },
|
||||
{ name = "structlog" },
|
||||
@@ -528,6 +529,7 @@ requires-dist = [
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||
{ name = "python-ulid", specifier = ">=2.7.0" },
|
||||
{ name = "radon", marker = "extra == 'dev'", specifier = ">=6.0.1" },
|
||||
{ name = "restrictedpython", specifier = ">=7.0" },
|
||||
|
||||
Reference in New Issue
Block a user