Repository navigation
Conversation
PR Review: #863 — feat(decisions): add the decision platform core, not yet wiredSummaryAdds the vendor-neutral launch-decision platform (two points Important (should fix)
Nits (optional)
TestsCoverage is unusually thorough. Every PR-body claim maps to an assertion-rich test: additive/idempotent migration (3 indexes asserted), startup sweep → interrupted, 90-day retention via VerificationBaseline (verifier, isolated worktree,
VerdictApprove with nits — the platform is inert by default, the security and boundary claims hold under executable verification, and tests are strong; please fix the latent |
3f2782e to
b2f319d
Compare
Update: CI fixes (force-pushed
|
fanhongy
left a comment
There was a problem hiding this comment.
Independent automated review commissioned by the PR author and performed by a separate CAO review agent. These findings are the agent's assessment, not the author's manual self-review.
Review of #863 at b2f319dc814dcdbadac16e0a240841bc3010d76c
Summary
This PR adds the decision platform core: points, policy, settings, deciders, the record store, shadow execution, telemetry and operator controls. As the PR says, nothing in src/ calls prepare_launch or bind_launch yet, so worker launches don't change. I focused on the parts that are live after merge:
cao decisions;- the six ops MCP tools;
cao-server --decision;- the
decision_recordsmigration; - the startup sweep and the retention purge in
cleanup_old_data; - the
script_step_ofrefactor; - the TUI catalog.
The agent-facing MCP server gains nothing. Before this PR, the ops MCP process already imported clients.database, so the new tools add no import-time side effect. The script_runner refactor behaves the same as before.
There are no P1 findings. I found one P2, in retention handling, and one P3, in test maintainability.
Findings
P2: cao decisions tune --retention-days 0 (or a negative value) is accepted and becomes a one-day purge at startup
-
Where:
src/cli_agent_orchestrator/cli/commands/decisions.py:73:--retention-daysis an unboundedint.src/cli_agent_orchestrator/decisions/settings.py:198-213:tunesaves the value without validating it.src/cli_agent_orchestrator/decisions/settings.py:106:load_settingsclamps it to 1–36500 without a warning.src/cli_agent_orchestrator/services/cleanup_service.py:107-116: at every server start, records withcreated_at < now - retention_daysare deleted.
-
Scenario: An operator who wants to keep decision records indefinitely runs
cao decisions tune --retention-days 0. CAO's existing retention settings follow that convention:services/workflow_retention.py:117-127and:260-270(from the PR #526 review) say that0disables the bound, because reading 0 as "cut off now" "wiped every run on boot". Here, the command exits 0 and saves0.load_settings()then returnsretention_days == 1, and the nextcao-serverstart deletes every decision record older than one day.docs/decisions.mddocuments the clamps foron_timeout_msand the threshold, but not for retention. -
Evidence (isolated
HOME/CAO_HOME_DIR; the probe calls the real CLI command, thencleanup_old_data()):tune -5 exit 0 | saved: -5 | effective: 1 tune 0 exit 0 | saved: 0 | effective: 1 rows before cleanup: 3 # records aged 2, 30 and 80 days rows after startup cleanup: 0 -
Impact: Once the wiring PR writes records, a single command meant to keep the audit trail deletes all but the last day of it at the next restart, with no error. The value is saved now, so it carries over to that release.
-
Fix: Pick one of these:
- Reject out-of-range values where they enter:
type=click.IntRange(1, 36500)on--retention-days, plus aValueErrorinsettings.tunefor direct callers. - Support
0as "keep forever" (skip the purge incleanup_service), to matchworkflow_retention.
In both cases, log a warning when a saved value is clamped, document the retention range in
docs/decisions.md, and add a test fortune --retention-days 0/-1. - Reject out-of-range values where they enter:
P3: The agent-boundary scan rejects common words in every agent tool and pins the full descriptions of unrelated tools
-
Where:
test/fixtures/decision_boundary.py:10-19:VOCABULARYincludespoints,tiersanddecisions.test/fixtures/decision_boundary.py:22-46: the scan.test/fixtures/decision_boundary_base.json.- These are used by
test/decisions/test_operator.py:71-94and:176-212against the live agent MCP tool list.
-
Scenario: A later, unrelated PR may:
- add an agent MCP tool whose text says "data points" or "pricing tiers"; or
- edit one word of the
memory_storeorworkflow_resumedescription.
Either change fails
test/decisions, although no decision surface changed. When I ranassert_agent_boundaryon a tool described as"Plot data points for the run.", it raisedAssertionError: ('x', 'points'). Changingmemory_store's "Store a" to "Store one" also raisedAssertionError. -
Fix: Limit the vocabulary to identifiers specific to decisions:
model.route,effort.route,model_tiers,exclude_profiles,cao.decidersanddecisions_*tool names. Check tool names and input property names instead of description text, and keep the AST import guard as the main control. The verbatim base pins formemory_storeandworkflow_resumecan then go.
Notes (not findings)
- PR description: The body says refusal reasons are "a closed set,
POLICY_REJECTION_REASONS".git grep POLICY_REJECTION_REASONSfinds no match in the tree. The reasons exist only asreasonclass attributes indecisions/policy.py, so either correct the description or add and test the constant. - Latent behaviours to settle before the wiring PR. I haven't counted these as findings, because no production code reaches them in this PR:
onpoints are awaited one after another (decisions/engine.py:280-305). Twoonpoints can therefore block a launch for about 2 ×on_timeout_ms, while the docs say "waits up to the configured timeout".prepare_launchlets profile-loaderValueError/RuntimeError(engine.py:124-131) and the unknown-providerValueErrorfrom_honors(engine.py:78-79) escape. These are notDecisionInputError.- The
allowed_providersvalidation gap atpolicy.py:115, which the PR acknowledges. --decisionchecks the point but not the state (settings.py:216-221).--decision model.route=ONstarts the server with the point off, and the only signal is a warning in the log.- The
MissingTierbranch atengine.py:298-302is unreachable, becauseengine.py:205-210already rejects everyMissingTier.
Validation
- Read
metadata.json,diff.patch,commits.txtandcontext.md. The checkoutHEADisb2f319dc814dcdbadac16e0a240841bc3010d76c, and I reviewed the diff against merge-base95a0975c(40 files, +5448/−43). pyteston CPython 3.12 (--no-cov -p no:cacheprovider): 494 passed. Suites:test/decisions,test/services/test_cleanup_service.py,test/services/test_script_runner.py,test/ops_mcp_server,test/test_command_catalog_matches_click.py,test/test_http_only_boundary.pyandtest/clients/test_database.py.pyteston CPython 3.10,test/decisionsplustest/services/test_cleanup_service.py: 247 passed.mypyon the new or changed modules (decisions/,model_tiers.py,decision_tools.py,cli/commands/decisions.py,cleanup_service.py): no issues in 18 files.black --checkon the new packages and fixtures: clean.- Probes, run with an isolated
HOME/CAO_HOME_DIR:- the retention probe above;
- the boundary-scan probe above;
- a base-vs-head comparison of ops MCP import side effects (
clients.databasewas already imported on base).
- CI on the head is all green, per the PR metadata. These conclusions don't depend on it.
- The checkout was not modified (
git statusis clean).
haofeif
left a comment
There was a problem hiding this comment.
Review at b2f319dc (merge-base 95a0975c): P3 only, so COMMENT.
CI is 41/41 green, and the branch merges cleanly with current main fd5113ea, which includes #881 (no settings, ephemeral-policy or boundary-vocabulary conflicts). Nothing calls prepare_launch/bind_launch yet. What goes live on merge is the startup block, the retention purge, the CLI and ops-MCP controls, and the new table. I reviewed those and the engine seam.
P3 (inline)
- The operator kill switch can't override
--decisionorCAO_DECISION_*on a running server, and there is no all-points off (#810 acceptance) —decisions/settings.pyL72. - Unguarded OpenTelemetry import on the
cao-serverstartup path —decisions/telemetry.pyL5. tune --retention-days 0/negative is saved, then silently clamped to 1 day —cli/commands/decisions.pyL73. I agree with the commissioned review on this one. I rate it P3, not P2, only because no decision record can be written until the wiring PR. Please fix it before that PR lands.model.route.exclude_profilesalso suppresses effort.route —decisions/engine.pyL212. I agree with @gutosantos82 here.
I also agree with these, without separate threads: the PR body's closed set POLICY_REJECTION_REASONS doesn't exist anywhere in the tree, so either add it or fix the body. --decision doesn't validate the state; a typo fails safe to off but is silent at startup.
Checked and fine: _honors (provider_class and honors_model are classmethods), _migrate_decision_records (checkfirst=True, idempotent), sweep() (guards its own errors), and the TUI rows (Hidden, no routes).
|
Thanks @haofeif. I pushed
This also covers @gutosantos82's three asks: the 🤖 Generated with Claude Code |
Add the decisions package: - a decider registry through the cao.deciders entry-point group, with a built-in fixed_table decider; - operator settings and policy, with model tiers and profile exclusions; - keyed message hashing; - a decision record store with retention and a startup recovery sweep; - a bounded shadow runner and telemetry; - the prepare_launch/bind_launch launch seam. No handler calls the seam yet, so delegation behaviour is unchanged. Every point is off by default. Operators control the platform through `cao decisions` and the cao-ops-mcp decision tools. The agent-facing MCP server cannot read or change decision state, and boundary tests enforce that. Secrets are redacted before any decider sees a request. Decision records never hold the message body or the task purpose. Also extract script_step_of in the script runner, so workflow-step detection has a single implementation. Document the platform in docs/decisions.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #810. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lemetry - `cao decisions tune` and `settings.tune()` refuse an on timeout outside 50-5000 ms, a threshold outside 0-1 and a retention outside 1-36500 days, instead of saving the value for load-time clamping. A retention of 0 no longer becomes a one-day purge at the next startup. Out-of-range values in settings.json or the environment are still clamped, now with a warning. - `cao-server --decision` refuses an unknown state, and applies no flag unless every flag is valid. - `model.route.exclude_profiles` pins only `model.route`; other points, such as `effort.route`, are still asked and recorded. - Decision telemetry degrades to a no-op when OpenTelemetry is absent, as `telemetry/` already does, so a base install without the extra can start the server. - docs: `--decision` is described as setting the matching variable; startup overrides hold until restart, so `set <point> off` cannot stop them, and there is no all-points off yet; the shadow limits are read once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#881 added a contract test that runs once `decisions/targets.py` exists: `profile_source` must agree with the launch path's classification. `profile_source` now returns EPHEMERAL for a name in the reserved ephemeral namespace, decided by the name alone without reading a profile, and INSTALLED otherwise. Before, it always returned INSTALLED. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9ca0f65 to
cba5d57
Compare
|
I rebased this branch onto main
🤖 Generated with Claude Code |
PR Review: #863 — feat(decisions): add the decision platform core, not yet wiredSummaryRe-review at head Important (should fix)
Nits (optional)New this round:
Carried over, unchanged:
TestsStrong this round. Every fix ships with targeted tests: VerificationDynamic verification did not return before synthesis. Static evidence at this head: CI 41/42 SUCCESS, the one failure being VerdictApprove with nits — the two new commits do exactly what the author claims: the three code fixes for the maintainer's P3 threads are correct and well-tested, the kill-switch behaviour is now accurately documented pending a later all-points-off command, and the rebase onto #881 classifies reserved ephemeral names correctly by name alone. The platform stays inert by default with verified agent-boundary and redaction properties. The remaining Important items are all cheap and worth landing in this PR: make |
…eck allowed providers - `init_db`'s `create_all` already creates `decision_records` and its indexes on new and existing databases, so the separate migrator added nothing; it is removed, and its tests now cover `create_all`. - Reading the hash key restricts a key file with group or other access to 0600. A key that cannot be restricted is not used, so no record is written with it. - `PolicyBounds.validate()` no longer skips a provider listed in `allowed_providers` that has no `model_tiers` entry, which is what `docs/decisions.md` already says. - The on-mode loop no longer re-checks a missing default tier, which the earlier policy check always refuses first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lags layer - `load_settings` loses its `flags=` parameter. Production never passed it: `cao-server --decision` sets the environment variable, and the server passes its startup environment. Tests now pass `environment=`. - The CLI takes its state and tier choices from `PointState` and `model_tiers.TIERS`, and the shadow limits use named ranges like the tunables. - A comment explains why `--threshold nan` exits 1 while `inf` exits 2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ndling
- docs/decisions.md:
- a decider package is trusted code, run only once an active point needs it;
- a shadow decision that finishes before bind emits without the launched
model;
- a loose key file is restricted on read;
- rotate with no launches in flight.
- CODEBASE.md lists `decisions/`, and docs/configuration.md points to the
`CAO_DECISION_*` variables, where the flag and the variable are one layer.
- The import guard also rejects `importlib.import_module` and `__import__`
calls with a literal decision module name. The boundary fixture explains
its deliberate coupling to unrelated agent tools.
- The telemetry `export` fixture clears `OTEL_SDK_DISABLED`, and new tests
cover an excluded name on an ephemeral target and `tune` with an existing
settings file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A hash key file that cannot be restricted to 0600 now raises `InsecureKeyError`, and the store logs a warning naming the file, as it does for an invalid key. The docs say how to recover. - The import guard also resolves relative literal names, `name=` and `package=` keywords and `__import__` `fromlist` entries. Its docstring states what neither check covers. - The real `init_db` upgrade test asserts that `decision_records` and its indexes are created on an old database. - The `--decision` error text is built from the state set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks @gutosantos82. I pushed four commits on Important
Nits, done
Nits, not changed
Local results are in the updated body. An independent review of the first three commits found no P1 or P2 issues; 🤖 Generated with Claude Code |
PR Review: #863 — feat(decisions): add the decision platform core, not yet wiredSummaryRe-review at head Important (should fix)None introduced this round. All six Important items from our review at Nits (optional)New this round:
TestsStrong and targeted. Every source change in the delta ships with a test that would fail on revert: VerificationDynamic verification did not return before synthesis. Static evidence at this head: CI 42/42 SUCCESS (the VerdictApprove with nits — the four new commits do exactly what the author's reply claims: the redundant migrator is removed in favour of |
haofeif
left a comment
There was a problem hiding this comment.
Re-review at ee4d97e3 (merge-base fd5113ea): APPROVE. No P1 or P2.
CI: 42/42 checks pass at this head.
Before merge: rebase onto main. GitHub reports this PR as conflicting. Since fd5113ea, main has 7 new commits (#891, #892, #898, #899, #902, #903 and a repin). git merge-tree shows one conflict, in src/cli_agent_orchestrator/cli/main.py: #902 replaced the cli.add_command(...) list with the lazy _COMMANDS table. Do not keep the old import and add_command lines. Add one row:
("decisions", _PKG + "decisions", "decisions", "Manage local launch decisions.", False),_LazyGroup.get_command reads only this table, so it does not find a command that cli.add_command adds. test_table_matches_real_commands and test_every_catalog_row_has_a_click_command fail if the row is missing or its short help is different. The other files that both sides change merge cleanly (api/main.py, clients/database.py, pyproject.toml, CHANGELOG.md, README.md, docs/configuration.md), and the merged Python files parse. A ruleset dismisses approvals on push, so I will review the rebase diff again.
Earlier findings (review 5428700593 at b2f319dc)
- Kill switch while an override is set: documented in
docs/decisions.md(Settings). A restart is necessary, and there is no all-points off yet. I accept this for a PR that is not wired. The #810 criterion (one command turns every point off) stays open for the wiring PR, which addscao decisions off. - Unguarded OpenTelemetry import: fixed. The
ImportErrorguard degrades only for a missingopentelemetry*module, andemit_recordis then a no-op. tuneranges: fixed. The CLI (IntRange/FloatRange) andsettings.tune()(_check_range) use the same constants.exclude_profilesgatingeffort.route: fixed.excluded(point)applies only tomodel.route, for the asked points and for theout_of_scoperecords.- Doc nit ("read fresh for each call"): fixed. The doc names the shadow limits that the server reads once.
I am resolving the four threads.
Checked and fine (delta e90cdef8..ee4d97e3, and the merge with main)
targets.profile_sourceuses onlyagent_profiles.routes_to_ephemeral_store, which #898 did not change. The #881test_future_profile_source_contractis the same onmain.- #898 removed
EphemeralLaunchRefused. This PR does not use it. - Ephemeral
autostill refuses withauto_requires_decision_platformand does not look for this package, so the merge does not change ephemeral behaviour. _migrate_decision_recordsis gone.create_allcreates the table and its indexes on an old database, and the new upgrade test covers this._read_keyrestricts a loose key on the open descriptor, or does not use the key.allowed_providersnow checks a listed provider that has no tier table.apply_flagsvalidates every flag before it sets a variable.- No test module name in
test/decisions/collides with another test module onmain.
Refs #810. This is the second of three PRs for the decision platform's first slice. It adds the platform, but nothing calls it yet, so no launch changes. The third PR wires it into
assign,handoffand run steps.What does run on merge: at startup the server builds the decision engine and sweeps unfinished decision rows, the migration adds the
decision_recordstable, the existing cleanup purges decision records past retention, and the CLI and ops MCP controls are available. With both points off, none of this changes a launch.#810 asks CAO to choose a model size for a delegation when the caller doesn't name one. This PR builds the parts that choose, record and control that choice. They live in CAO core, with no vendor names. The decider that answers is a plugin.
What
Two decision points:
model.routeaskssmall,mediumorlarge;effort.routeaskslow,mediumorhigh.Both also accept
unsure. Each point isoff,shadoworon, and both are off by default.on_timeout_ms(default 1000). It uses an answer whose confidence is at least the threshold (default 0.70). Otherwise it falls back to the profile default, then the policy default, then the provider's own default.Deciders:
cao.decidersentry-point group.fixed_tablelooks up a tier by profile, then by role.Policy:
PolicyBoundscaps a decider's answer at a ceiling and refuses an explicit value above it.model_tierssetting maps each provider's tiers to model IDs.model_tiers.<provider>.<tier>. Every provider listed inallowed_providersis checked, including one with nomodel_tiersentry.reason, taken from the policy error that raised it. The wiring PR names that set asPOLICY_REJECTION_REASONSand tests that it is closed.Records:
decision_recordstable holds one row per point per launch in shadow or on.init_db's existingcreate_alladds it, with its indexes, to new and existing databases.decision-hash.key, mode 0600, and can be rotated. A key file with group or other access is restricted when it is read; if that fails, it isn't used, and a warning names it.Shadow runner: bounded at 4 concurrent and 64 pending tasks. Overflow is recorded as
shadow_dropped.Telemetry:
cao.decisionspan per record, pluscao.decision.requestsandcao.decision.latencymetrics.Operator controls:
cao decisions, withstatus,set,tier,table,exclude,tune,listandpurge;cao-server --decision <point>=<state>;CAO_DECISION_*environment variables;All of them are local and in-process, with no HTTP routes. The agent-facing MCP server gets none of them, and boundary tests enforce that.
tunerefuses values outside its ranges (on timeout 50–5000 ms, threshold 0–1, retention 1–36500 days), and--decisionrefuses an unknown point or state.--decisionsets the matchingCAO_DECISION_*variable. The server captures these overrides at startup, soset <point> offcan't stop an overridden point until a restart, and there is no all-points off yet. A later [Feat] Decision points: opt-in, auditable judgments in orchestration, starting with Auto model routing #810 PR addscao decisions off, which beats the overrides on a running server.The launch seam:
prepare_launchandbind_launchindecisions/engine.py, pluslaunch_note/render_note, which format the result note the wiring PR adds. No handler calls any of them in this PR.Also:
script_step_ofis extracted in the script runner, so workflow-step detection has one implementation.docs/decisions.mddocuments the platform, with a link from the README.decisions/targets.py:profile_sourcereports a name in feat(ephemeral): create and store ephemeral agents, not yet launchable #881's reserved ephemeral namespace as an ephemeral target, by name alone, as feat(ephemeral): create and store ephemeral agents, not yet launchable #881's contract test requires.Tests
test/decisions/(new) covers:test/fixtures/decision_conformance.pyholds reusable behaviour cases. This PR runs them against the engine directly. The wiring PR runs the same cases through the delegation handlers.test/fixtures/decision_boundary.pyscans the agent-facing MCP surface for decision vocabulary, including with a plugin installed.test_source_tokens_are_neutralkeeps integration-specific names out ofsrc/.typing.get_type_hintswraps a parameter that defaults toNonein one extraOptional, soworkflow_resume'sdecisionsinput renders as a nestedanyOf. The boundary test unwraps only that exact shape, and only when the result equals the pinned base. Any other shape fails the unchanged comparison.Local results at
b2f319dc:main. The one failure,test_kimi_code_compat.py::TestA41TrustBound::test_socket_is_skipped, also fails onmainlocally because of a long AF_UNIX temp path, so CI decides that one;test/services/test_secret_gate.pycheck that needs Unicode 15.0 tables, and 3.10 ships 13.0.0;test/decisions/,test/mcp_server/andtest/api/, 1,917 passed;main, none new;Review fixes, now
f69a66aeafter the rebase ontofd5113ea(first pushed as9ca0f653):test/decisions/: 270 passed on CPython 3.10 and 3.12, and again withCAO_HOME_DIRunset;b2f319dc;Rebase and contract fix at
cba5d575:9ca0f653withfd5113ea;test/decisions/,test/services/test_ephemeral_service.pyand the agent-profile tests: 479 passed on CPython 3.12, includingtest_future_profile_source_contract, which fails with the old stub;Second review round at
ee4d97e3(four commits oncba5d575):test/decisions/andtest/services/test_ephemeral_service.py: 405 passed on CPython 3.10 and 3.12, and again withCAO_HOME_DIRunset;utils/orchestration.py, from feat(ephemeral): create and store ephemeral agents, not yet launchable #881); itsvalidatechange is now identical on both sides.What comes next
The last PR, opened after this one merges, wires the seam into
assign,handoff, elastic assign and run steps. Both points stay off by default.🤖 Generated with Claude Code