brindle 0.3.4__tar.gz → 0.3.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {brindle-0.3.4 → brindle-0.3.6}/.gitignore +2 -0
- {brindle-0.3.4 → brindle-0.3.6}/PKG-INFO +21 -17
- {brindle-0.3.4 → brindle-0.3.6}/README.md +20 -16
- {brindle-0.3.4 → brindle-0.3.6}/pyproject.toml +6 -2
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/agents.py +18 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/autopilot.py +20 -10
- brindle-0.3.6/src/brindle/builtin_agents/developer-fable.md +7 -0
- brindle-0.3.6/src/brindle/builtin_agents/developer-gemini.md +7 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/builtin_agents/developer-heavy.md +2 -2
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/config.py +29 -10
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/cost_estimate.py +8 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/cull.py +11 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/db.py +17 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/events.py +76 -11
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/mcp_server.py +8 -8
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/policy.py +15 -1
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pro/learning.py +1 -1
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pro/team_events.py +1 -1
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pro/team_policy.py +8 -4
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/tasks.py +1 -1
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/usage.py +62 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/watch.py +20 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/watch_costs.py +87 -9
- {brindle-0.3.4 → brindle-0.3.6}/LICENSE +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/SCHEDULE-A +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/__init__.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/__main__.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/account.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/airgap.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/antigravity.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/budget.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/builtin_agents/__init__.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/builtin_agents/backend-auditor.md +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/builtin_agents/developer-codex.md +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/builtin_agents/developer-light.md +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/builtin_agents/developer-local.md +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/builtin_agents/developer.md +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/builtin_agents/qa.md +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/builtin_agents/reviewer-gemini.md +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/builtin_agents/reviewer-local.md +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/builtin_agents/reviewer.md +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/builtin_agents/subagent.md +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/builtin_agents/supervisor.md +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/builtin_rules/__init__.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/builtin_rules/security/backend.md +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/builtin_rules/tests/only.md +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/ci_adapters.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/ci_client.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/ci_federation.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/ci_hosts.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/cli.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/codemap.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/codex_hook.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/company_identity.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/company_login.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/conflicts.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/cost.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/demo.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/detect.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/doctor.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/escapes.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/gates.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/git.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/guardrails.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/history.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/identity_lock.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/inbox.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/keys_cmds.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/keystore.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/launch_health.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/learned_rules.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/learning.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/legacy.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/model_access.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/model_availability.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/native/__init__.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/native/client.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/native/loop.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/native/permissions.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/native/runner.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/native/serve.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/native/tools.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pane_auth.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/permissions.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pipeline.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/plugins.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pool.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pricing.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pro/__init__.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pro/_files.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pro/account.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pro/audit_chain.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pro/audit_export.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pro/auth.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pro/brief_learning.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pro/cost_centers.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pro/credentials.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pro/features.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pro/keys.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pro/license.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pro/loopback.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pro/managed_models.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pro/org_profiles.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pro/orgkey.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pro/rollout.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pro/settings_sync.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/pro/status.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/procs.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/profiles.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/providers.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/quota.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/repo_cmds.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/repos.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/rewind.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/rule_checks.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/savings.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/scratch.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/secrets.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/self_serve.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/services.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/sessions.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/sidebar_follow.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/signin_pause.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/status_cache.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/tmux.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/view.py +0 -0
- {brindle-0.3.4 → brindle-0.3.6}/src/brindle/workspaces.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: brindle
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.6
|
|
4
4
|
Summary: Run coding agents in parallel, each on its own git branch, with a supervisor that delegates, reviews, and merges
|
|
5
5
|
Project-URL: Homepage, https://pawdelta.com/brindle/
|
|
6
6
|
Project-URL: Documentation, https://pawdelta.com/brindle/docs/recommended-use
|
|
@@ -89,7 +89,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
|
|
|
89
89
|
brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
|
|
90
90
|
|
|
91
91
|
`brindle --version` prints the installed version; the tmux status bar of every brindle
|
|
92
|
-
session shows it too (`brindle 0.3.
|
|
92
|
+
session shows it too (`brindle 0.3.6`). A session started before an upgrade keeps
|
|
93
93
|
running the old code, and shows the old number, until you restart it.
|
|
94
94
|
|
|
95
95
|
## Quick start
|
|
@@ -531,7 +531,7 @@ Autopilot, merge gates and cleanup:
|
|
|
531
531
|
| `max_agents` | `4` | workers running at once per session (`0`: no cap) |
|
|
532
532
|
| `check_timeout` | `900` | seconds each check may take |
|
|
533
533
|
| `check_concurrency` | `2` | check runs at once on this machine, across branches; the rest wait their turn (`0`: no cap). A run far past its last duration is reported to the supervisor |
|
|
534
|
-
| `fable_escalation` | on, unless the worker runs on a cloud account (Bedrock, Vertex or Foundry) | retry a failed heavy task once on `developer-heavy`
|
|
534
|
+
| `fable_escalation` | on, unless the worker runs on a cloud account (Bedrock, Vertex or Foundry) | retry a failed heavy task once on `developer-heavy` before handing it to you; `true` turns it on for a cloud account, `false` off |
|
|
535
535
|
| `usage_limit` | `90` | autopilot stops pushing on at this % of your Claude usage limit |
|
|
536
536
|
| `limit_cooldown_minutes` | `300` for Antigravity | how long a provider that hit its limit counts as unavailable |
|
|
537
537
|
| `graphify` | if the graph is there | point agents at the repo's [graphify](https://github.com/safishamsi/graphify) code map (`false` turns it off) |
|
|
@@ -584,27 +584,30 @@ When a workspace is created (including from the pool), brindle starts one contai
|
|
|
584
584
|
|
|
585
585
|
### Routing by weight
|
|
586
586
|
|
|
587
|
-
The supervisor sizes a task and passes `weight` (`"light"`, `"medium"
|
|
588
|
-
`"heavy"`
|
|
587
|
+
The supervisor sizes a task and passes `weight` (`"light"`, `"medium"`,
|
|
588
|
+
`"heavy"` or `"advanced"`, with `"reasoning"` and `"complex"` accepted as aliases) to
|
|
589
|
+
`assign`/`handoff`; brindle picks an available profile for that tier.
|
|
589
590
|
Light is small, well-specified, mechanical work (docs, renames, simple tests);
|
|
590
|
-
medium is a normal feature or bugfix in one area; heavy is design
|
|
591
|
-
cross-cutting
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
591
|
+
medium is a normal feature or bugfix in one area; heavy is architecture design,
|
|
592
|
+
broad cross-cutting refactors, or subtle bugs; advanced is complex, long-running
|
|
593
|
+
system-design and implementation tasks. The `routing`
|
|
594
|
+
config maps each tier to profile names: the first is the tier's baseline, the
|
|
595
|
+
rest are candidates hosted learning may pick instead (defaults shown; set one
|
|
596
|
+
tier and the others keep theirs):
|
|
595
597
|
|
|
596
598
|
```json
|
|
597
599
|
{
|
|
598
600
|
"routing": {
|
|
599
|
-
"light":
|
|
600
|
-
"medium":
|
|
601
|
-
"heavy":
|
|
601
|
+
"light": ["developer-light", "developer", "developer-codex", "developer-local"],
|
|
602
|
+
"medium": ["developer", "developer-codex", "developer-heavy"],
|
|
603
|
+
"heavy": ["developer-heavy", "developer-gemini", "developer", "developer-codex"],
|
|
604
|
+
"advanced": ["developer-fable", "developer-heavy", "developer-gemini"]
|
|
602
605
|
}
|
|
603
606
|
}
|
|
604
607
|
```
|
|
605
608
|
|
|
606
|
-
No tier prefers another provider for its own sake: all-Claude
|
|
607
|
-
be what works best for you, and that's for the learner to find out.
|
|
609
|
+
No tier prefers another provider for its own sake: all-Claude, all-Codex or
|
|
610
|
+
all-Gemini may be what works best for you, and that's for the learner to find out.
|
|
608
611
|
|
|
609
612
|
Routing merges per tier in this order: built-in defaults, `~/.brindle/config.json`
|
|
610
613
|
(every repo), the repo's `.brindle/config.json` (shared with the team), then
|
|
@@ -622,8 +625,9 @@ each tier. Unknown profile names are skipped; doctor and the routing reply
|
|
|
622
625
|
report the missing name and the file that supplied it.
|
|
623
626
|
|
|
624
627
|
`developer-light` runs on Claude Haiku at low effort; `developer` on your Claude
|
|
625
|
-
Code default model; `developer-codex` on Codex; `developer-heavy` on Claude
|
|
626
|
-
at high effort
|
|
628
|
+
Code default model; `developer-codex` on Codex; `developer-heavy` on Claude Opus
|
|
629
|
+
at high effort; `developer-gemini` on Google Antigravity (Gemini) with high effort
|
|
630
|
+
(Tier 3); `developer-fable` on Claude Fable at high effort (Tier 4 advanced).
|
|
627
631
|
A profile is skipped when its CLI isn't installed (`claude`, `codex`, `agy`), the
|
|
628
632
|
local model server isn't answering, or its provider is at your `usage_limit`. If
|
|
629
633
|
hosted learning is on it chooses among the profiles left; otherwise the first
|
|
@@ -59,7 +59,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
|
|
|
59
59
|
brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
|
|
60
60
|
|
|
61
61
|
`brindle --version` prints the installed version; the tmux status bar of every brindle
|
|
62
|
-
session shows it too (`brindle 0.3.
|
|
62
|
+
session shows it too (`brindle 0.3.6`). A session started before an upgrade keeps
|
|
63
63
|
running the old code, and shows the old number, until you restart it.
|
|
64
64
|
|
|
65
65
|
## Quick start
|
|
@@ -501,7 +501,7 @@ Autopilot, merge gates and cleanup:
|
|
|
501
501
|
| `max_agents` | `4` | workers running at once per session (`0`: no cap) |
|
|
502
502
|
| `check_timeout` | `900` | seconds each check may take |
|
|
503
503
|
| `check_concurrency` | `2` | check runs at once on this machine, across branches; the rest wait their turn (`0`: no cap). A run far past its last duration is reported to the supervisor |
|
|
504
|
-
| `fable_escalation` | on, unless the worker runs on a cloud account (Bedrock, Vertex or Foundry) | retry a failed heavy task once on `developer-heavy`
|
|
504
|
+
| `fable_escalation` | on, unless the worker runs on a cloud account (Bedrock, Vertex or Foundry) | retry a failed heavy task once on `developer-heavy` before handing it to you; `true` turns it on for a cloud account, `false` off |
|
|
505
505
|
| `usage_limit` | `90` | autopilot stops pushing on at this % of your Claude usage limit |
|
|
506
506
|
| `limit_cooldown_minutes` | `300` for Antigravity | how long a provider that hit its limit counts as unavailable |
|
|
507
507
|
| `graphify` | if the graph is there | point agents at the repo's [graphify](https://github.com/safishamsi/graphify) code map (`false` turns it off) |
|
|
@@ -554,27 +554,30 @@ When a workspace is created (including from the pool), brindle starts one contai
|
|
|
554
554
|
|
|
555
555
|
### Routing by weight
|
|
556
556
|
|
|
557
|
-
The supervisor sizes a task and passes `weight` (`"light"`, `"medium"
|
|
558
|
-
`"heavy"`
|
|
557
|
+
The supervisor sizes a task and passes `weight` (`"light"`, `"medium"`,
|
|
558
|
+
`"heavy"` or `"advanced"`, with `"reasoning"` and `"complex"` accepted as aliases) to
|
|
559
|
+
`assign`/`handoff`; brindle picks an available profile for that tier.
|
|
559
560
|
Light is small, well-specified, mechanical work (docs, renames, simple tests);
|
|
560
|
-
medium is a normal feature or bugfix in one area; heavy is design
|
|
561
|
-
cross-cutting
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
561
|
+
medium is a normal feature or bugfix in one area; heavy is architecture design,
|
|
562
|
+
broad cross-cutting refactors, or subtle bugs; advanced is complex, long-running
|
|
563
|
+
system-design and implementation tasks. The `routing`
|
|
564
|
+
config maps each tier to profile names: the first is the tier's baseline, the
|
|
565
|
+
rest are candidates hosted learning may pick instead (defaults shown; set one
|
|
566
|
+
tier and the others keep theirs):
|
|
565
567
|
|
|
566
568
|
```json
|
|
567
569
|
{
|
|
568
570
|
"routing": {
|
|
569
|
-
"light":
|
|
570
|
-
"medium":
|
|
571
|
-
"heavy":
|
|
571
|
+
"light": ["developer-light", "developer", "developer-codex", "developer-local"],
|
|
572
|
+
"medium": ["developer", "developer-codex", "developer-heavy"],
|
|
573
|
+
"heavy": ["developer-heavy", "developer-gemini", "developer", "developer-codex"],
|
|
574
|
+
"advanced": ["developer-fable", "developer-heavy", "developer-gemini"]
|
|
572
575
|
}
|
|
573
576
|
}
|
|
574
577
|
```
|
|
575
578
|
|
|
576
|
-
No tier prefers another provider for its own sake: all-Claude
|
|
577
|
-
be what works best for you, and that's for the learner to find out.
|
|
579
|
+
No tier prefers another provider for its own sake: all-Claude, all-Codex or
|
|
580
|
+
all-Gemini may be what works best for you, and that's for the learner to find out.
|
|
578
581
|
|
|
579
582
|
Routing merges per tier in this order: built-in defaults, `~/.brindle/config.json`
|
|
580
583
|
(every repo), the repo's `.brindle/config.json` (shared with the team), then
|
|
@@ -592,8 +595,9 @@ each tier. Unknown profile names are skipped; doctor and the routing reply
|
|
|
592
595
|
report the missing name and the file that supplied it.
|
|
593
596
|
|
|
594
597
|
`developer-light` runs on Claude Haiku at low effort; `developer` on your Claude
|
|
595
|
-
Code default model; `developer-codex` on Codex; `developer-heavy` on Claude
|
|
596
|
-
at high effort
|
|
598
|
+
Code default model; `developer-codex` on Codex; `developer-heavy` on Claude Opus
|
|
599
|
+
at high effort; `developer-gemini` on Google Antigravity (Gemini) with high effort
|
|
600
|
+
(Tier 3); `developer-fable` on Claude Fable at high effort (Tier 4 advanced).
|
|
597
601
|
A profile is skipped when its CLI isn't installed (`claude`, `codex`, `agy`), the
|
|
598
602
|
local model server isn't answering, or its provider is at your `usage_limit`. If
|
|
599
603
|
hosted learning is on it chooses among the profiles left; otherwise the first
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "brindle"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.6"
|
|
4
4
|
description = "Run coding agents in parallel, each on its own git branch, with a supervisor that delegates, reviews, and merges"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -51,6 +51,7 @@ audit_export = "brindle.pro.audit_export:make"
|
|
|
51
51
|
[dependency-groups]
|
|
52
52
|
dev = [
|
|
53
53
|
"pytest>=8",
|
|
54
|
+
"pytest-cov>=7.0",
|
|
54
55
|
"pytest-xdist>=3.8.0",
|
|
55
56
|
"python-hcl2>=8.1.4",
|
|
56
57
|
"pyyaml>=6.0.3",
|
|
@@ -74,4 +75,7 @@ only-include = ["src", "README.md", "LICENSE", "SCHEDULE-A", "pyproject.toml"]
|
|
|
74
75
|
[tool.pytest.ini_options]
|
|
75
76
|
testpaths = ["tests"]
|
|
76
77
|
pythonpath = ["tests"]
|
|
77
|
-
markers = [
|
|
78
|
+
markers = [
|
|
79
|
+
"serial: run outside xdist (`pytest -m serial`); the parallel pass skips these",
|
|
80
|
+
"variant(reason): an input variant that covers no new code on purpose (see scripts/check_new_tests.py)",
|
|
81
|
+
]
|
|
@@ -2052,6 +2052,24 @@ def _review_profile(db: DB, ws: Workspace, profile: str | None, cfg: RepoConfig)
|
|
|
2052
2052
|
f"reviewer profile {profile!r} can't run: {why}. "
|
|
2053
2053
|
"Fix that, or set review_profile (or pass profile) to a different reviewer"
|
|
2054
2054
|
)
|
|
2055
|
+
try:
|
|
2056
|
+
from brindle import policy as pol
|
|
2057
|
+
d = pol.check_assign(cfg, ws.repo_root, profile, "code review", "review")
|
|
2058
|
+
if not d.allowed:
|
|
2059
|
+
for fallback in (cfg.reviewer, "reviewer", "reviewer-gemini", "reviewer-local"):
|
|
2060
|
+
if fallback != profile:
|
|
2061
|
+
try:
|
|
2062
|
+
load_profile(fallback, ws.repo_root)
|
|
2063
|
+
fb_d = pol.check_assign(cfg, ws.repo_root, fallback, "code review", "review")
|
|
2064
|
+
if fb_d.allowed:
|
|
2065
|
+
return fallback
|
|
2066
|
+
except Exception:
|
|
2067
|
+
continue
|
|
2068
|
+
raise AgentError(f"reviewer profile {profile!r} refused by policy: {d.reason}")
|
|
2069
|
+
except AgentError:
|
|
2070
|
+
raise
|
|
2071
|
+
except Exception:
|
|
2072
|
+
pass
|
|
2055
2073
|
return profile
|
|
2056
2074
|
|
|
2057
2075
|
|
|
@@ -81,9 +81,9 @@ to be asked each step.
|
|
|
81
81
|
task's work must merge before another starts; brindle queues it until then.
|
|
82
82
|
- Unless you need a specific profile, pass `weight` on every `assign`:
|
|
83
83
|
"light" (small, well-specified, mechanical: docs, renames, simple tests),
|
|
84
|
-
"medium" (normal feature work
|
|
85
|
-
"heavy" (
|
|
86
|
-
|
|
84
|
+
"medium" (normal feature work or bugfixes in one area),
|
|
85
|
+
"heavy" (architecture design, broad cross-cutting refactors, or subtle bugs that failed earlier fix rounds), or
|
|
86
|
+
"advanced" (complex, long-running system-design and implementation tasks). brindle picks an available
|
|
87
87
|
profile for that tier.
|
|
88
88
|
- Keep task briefs short: the goal in a sentence or two, the files, and the
|
|
89
89
|
test that proves it done. Workers read the tests and code themselves;
|
|
@@ -506,7 +506,7 @@ def _route_by_weight(db: DB, cfg: RepoConfig, repo_root: str, weight: str, task:
|
|
|
506
506
|
skipped: list[str] = []
|
|
507
507
|
remaining: list[str] = []
|
|
508
508
|
over: dict[str, str] = {}
|
|
509
|
-
weight_order = {"light": 0, "medium": 1, "heavy": 2}
|
|
509
|
+
weight_order = {"light": 0, "medium": 1, "heavy": 2, "advanced": 3, "reasoning": 3, "complex": 3}
|
|
510
510
|
effective_ceiling = ceiling or (cfg.max_weight if (cfg.max_weight and cfg.max_weight in weight_order) else None)
|
|
511
511
|
order = cfg.routing.get(weight, [])
|
|
512
512
|
if effective_ceiling:
|
|
@@ -597,6 +597,7 @@ def effective_profile_model(p, repo_root: str | None = None) -> str:
|
|
|
597
597
|
return m.strip()
|
|
598
598
|
|
|
599
599
|
|
|
600
|
+
|
|
600
601
|
def model_cost_info(name: str, cfg: RepoConfig | None = None, repo_root: str | None = None) -> str:
|
|
601
602
|
"""Format model name, effort, and cost tier as a cost signal string."""
|
|
602
603
|
try:
|
|
@@ -608,7 +609,7 @@ def model_cost_info(name: str, cfg: RepoConfig | None = None, repo_root: str | N
|
|
|
608
609
|
if p.effort:
|
|
609
610
|
model_info.append(f"{p.effort} effort")
|
|
610
611
|
pw = profile_weight(name, cfg, repo_root)
|
|
611
|
-
if pw
|
|
612
|
+
if pw in ("heavy", "advanced", "reasoning"):
|
|
612
613
|
cost_tier = "most expensive tier"
|
|
613
614
|
elif pw == "light":
|
|
614
615
|
cost_tier = "lowest cost tier"
|
|
@@ -621,7 +622,9 @@ def model_cost_info(name: str, cfg: RepoConfig | None = None, repo_root: str | N
|
|
|
621
622
|
|
|
622
623
|
|
|
623
624
|
def profile_weight(name: str, cfg: RepoConfig | None = None, repo_root: str | None = None) -> str:
|
|
624
|
-
"""The tier/weight of a profile ('light', 'medium', 'heavy')."""
|
|
625
|
+
"""The tier/weight of a profile ('light', 'medium', 'heavy', 'advanced')."""
|
|
626
|
+
if "-fable" in name or "-advanced" in name or "-reasoning" in name:
|
|
627
|
+
return "advanced"
|
|
625
628
|
try:
|
|
626
629
|
p = load_profile(name, repo_root)
|
|
627
630
|
m = effective_profile_model(p, repo_root).lower()
|
|
@@ -647,6 +650,9 @@ def profile_weight(name: str, cfg: RepoConfig | None = None, repo_root: str | No
|
|
|
647
650
|
in_light = name in cfg.routing.get("light", [])
|
|
648
651
|
in_med = name in cfg.routing.get("medium", [])
|
|
649
652
|
in_heavy = name in cfg.routing.get("heavy", [])
|
|
653
|
+
in_advanced = name in cfg.routing.get("advanced", []) or name in cfg.routing.get("reasoning", [])
|
|
654
|
+
if in_advanced and not in_heavy and not in_med and not in_light:
|
|
655
|
+
return "advanced"
|
|
650
656
|
if in_heavy and not in_med and not in_light:
|
|
651
657
|
return "heavy"
|
|
652
658
|
if in_light and not in_med and not in_heavy:
|
|
@@ -670,7 +676,7 @@ def choose_profile(db: DB, caller_id: str, repo_root: str, requested: str | None
|
|
|
670
676
|
filled with what ``brindle.savings`` records: the ``profile``, the
|
|
671
677
|
``baseline`` (the pick without learning), ``learned`` and the ``weight``."""
|
|
672
678
|
cfg = load_repo_config(repo_root)
|
|
673
|
-
weight_order = {"light": 0, "medium": 1, "heavy": 2}
|
|
679
|
+
weight_order = {"light": 0, "medium": 1, "heavy": 2, "advanced": 3, "reasoning": 3, "complex": 3}
|
|
674
680
|
ceiling = cfg.max_weight if (cfg.max_weight and cfg.max_weight in weight_order) else None
|
|
675
681
|
|
|
676
682
|
if requested:
|
|
@@ -700,20 +706,24 @@ def choose_profile(db: DB, caller_id: str, repo_root: str, requested: str | None
|
|
|
700
706
|
why.append(f"milestone profile -> {name}{model_cost_info(name, cfg, repo_root)}")
|
|
701
707
|
if not name:
|
|
702
708
|
from brindle import learning
|
|
709
|
+
from brindle.config import normalize_weight
|
|
703
710
|
|
|
704
711
|
if weight:
|
|
705
|
-
|
|
712
|
+
norm_weight = normalize_weight(weight)
|
|
713
|
+
if norm_weight not in WEIGHTS:
|
|
706
714
|
raise AutopilotError(f"weight must be one of {', '.join(WEIGHTS)}, not {weight!r}")
|
|
715
|
+
weight = norm_weight
|
|
707
716
|
if ceiling and weight_order[weight] > weight_order[ceiling]:
|
|
708
717
|
orig_weight = weight
|
|
709
718
|
weight = ceiling
|
|
710
719
|
if why is not None:
|
|
711
720
|
why.append(f"weight {orig_weight} capped to {weight} by max_weight")
|
|
712
721
|
if files and all(f.startswith("docs/") or f.endswith(".md") for f in files):
|
|
713
|
-
if weight
|
|
722
|
+
if weight in ("advanced", "reasoning", "heavy"):
|
|
723
|
+
orig_demote = weight
|
|
714
724
|
weight = "medium"
|
|
715
725
|
if why is not None:
|
|
716
|
-
why.append("weight demoted from
|
|
726
|
+
why.append(f"weight demoted from {orig_demote} to medium (docs only)")
|
|
717
727
|
elif weight == "medium":
|
|
718
728
|
weight = "light"
|
|
719
729
|
if why is not None:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: developer-heavy
|
|
3
|
-
description: Implements a design-heavy or subtle task on its own branch, on
|
|
3
|
+
description: Implements a design-heavy or subtle task on its own branch, on Opus with high effort (the heavy tier of weight routing)
|
|
4
4
|
extends: developer
|
|
5
|
-
model:
|
|
5
|
+
model: opus
|
|
6
6
|
effort: high
|
|
7
7
|
---
|
|
@@ -43,15 +43,32 @@ def user_config_path() -> Path:
|
|
|
43
43
|
return brindle_home() / CONFIG_FILE
|
|
44
44
|
|
|
45
45
|
|
|
46
|
-
WEIGHTS = ("light", "medium", "heavy")
|
|
46
|
+
WEIGHTS = ("light", "medium", "heavy", "advanced")
|
|
47
|
+
WEIGHT_ALIASES = {
|
|
48
|
+
"reasoning": "advanced",
|
|
49
|
+
"complex": "advanced",
|
|
50
|
+
"complex reasoning": "advanced",
|
|
51
|
+
"deep": "advanced",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def normalize_weight(weight: str | None) -> str | None:
|
|
56
|
+
"""Normalize a weight string, resolving aliases like 'reasoning' or 'complex' to 'advanced'."""
|
|
57
|
+
if not weight:
|
|
58
|
+
return None
|
|
59
|
+
w = weight.strip().lower()
|
|
60
|
+
return WEIGHT_ALIASES.get(w, w)
|
|
61
|
+
|
|
62
|
+
|
|
47
63
|
# The first profile of each tier is its baseline; the rest are candidates
|
|
48
64
|
# brindle Pro's learner may pick instead when its evidence says they do better.
|
|
49
|
-
# Nothing here prefers another provider for its own sake: all-Claude
|
|
50
|
-
# all-
|
|
65
|
+
# Nothing here prefers another provider for its own sake: all-Claude, all-Codex
|
|
66
|
+
# or all-Gemini may well be best, and that's the learner's call.
|
|
51
67
|
DEFAULT_ROUTING = {
|
|
52
68
|
"light": ["developer-light", "developer", "developer-codex", "developer-local"],
|
|
53
69
|
"medium": ["developer", "developer-codex", "developer-heavy"],
|
|
54
|
-
"heavy": ["developer", "developer-
|
|
70
|
+
"heavy": ["developer-heavy", "developer-gemini", "developer", "developer-codex"],
|
|
71
|
+
"advanced": ["developer-fable", "developer-heavy", "developer-gemini"],
|
|
55
72
|
}
|
|
56
73
|
|
|
57
74
|
|
|
@@ -91,7 +108,7 @@ class RepoConfig:
|
|
|
91
108
|
merge_into: str | None = None # branch worker branches are cut from and merge into (None: the supervisor's / default branch)
|
|
92
109
|
auto_merge_default_branch: bool = False # let the pipeline merge into the repo's default branch on its own
|
|
93
110
|
delegation: str = "balanced" # how readily a supervisor hands work to workers: "conservative" (save tokens), "balanced" or "fast" (save time)
|
|
94
|
-
max_weight: str | None = None # ceiling for weight routing: "light", "medium", or None (unlimited)
|
|
111
|
+
max_weight: str | None = None # ceiling for weight routing: "light", "medium", "heavy", "advanced", or None (unlimited)
|
|
95
112
|
plan_first: bool = False # workers propose a plan and wait for approval before editing
|
|
96
113
|
# "on": brindle answers workers' permission requests by its rules (see
|
|
97
114
|
# brindle.permissions). Unset, it's "on" for Pro and up and "off" on Free.
|
|
@@ -245,15 +262,17 @@ def load_repo_config(repo_root: str | Path) -> RepoConfig:
|
|
|
245
262
|
routing = source.get("routing")
|
|
246
263
|
if isinstance(routing, dict):
|
|
247
264
|
for tier, names in routing.items():
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
cfg.
|
|
265
|
+
norm_tier = normalize_weight(tier)
|
|
266
|
+
if norm_tier in WEIGHTS and isinstance(names, list):
|
|
267
|
+
cfg.routing[norm_tier] = [n for n in names if isinstance(n, str)]
|
|
268
|
+
cfg.routing_sources[norm_tier] = str(path)
|
|
251
269
|
for source in (shared, local): # per weight, so a repo can override one and keep the rest
|
|
252
270
|
second = source.get("second_review")
|
|
253
271
|
if isinstance(second, dict):
|
|
254
272
|
for tier, name in second.items():
|
|
255
|
-
|
|
256
|
-
|
|
273
|
+
norm_tier = normalize_weight(tier)
|
|
274
|
+
if norm_tier in WEIGHTS and isinstance(name, str) and name:
|
|
275
|
+
cfg.second_review[norm_tier] = name
|
|
257
276
|
for source in (user, shared, local): # per model, so a repo can override one and keep the rest
|
|
258
277
|
pricing = source.get("pricing")
|
|
259
278
|
if isinstance(pricing, dict):
|
|
@@ -62,12 +62,20 @@ PRIOR: dict[tuple[str, str], tuple[Tokens, Tokens]] = {
|
|
|
62
62
|
Tokens(120_000, 80_000, 800_000, 10_000_000)),
|
|
63
63
|
("worker", "heavy"): (Tokens(120_000, 90_000, 900_000, 12_000_000),
|
|
64
64
|
Tokens(250_000, 180_000, 1_800_000, 25_000_000)),
|
|
65
|
+
("worker", "advanced"): (Tokens(200_000, 150_000, 1_500_000, 20_000_000),
|
|
66
|
+
Tokens(450_000, 300_000, 3_000_000, 40_000_000)),
|
|
67
|
+
("worker", "reasoning"): (Tokens(200_000, 150_000, 1_500_000, 20_000_000),
|
|
68
|
+
Tokens(450_000, 300_000, 3_000_000, 40_000_000)),
|
|
65
69
|
("review", "light"): (Tokens(15_000, 6_000, 80_000, 600_000),
|
|
66
70
|
Tokens(30_000, 12_000, 160_000, 1_200_000)),
|
|
67
71
|
("review", "medium"): (Tokens(20_000, 10_000, 100_000, 1_000_000),
|
|
68
72
|
Tokens(40_000, 20_000, 200_000, 2_000_000)),
|
|
69
73
|
("review", "heavy"): (Tokens(40_000, 20_000, 200_000, 2_500_000),
|
|
70
74
|
Tokens(80_000, 40_000, 400_000, 5_000_000)),
|
|
75
|
+
("review", "advanced"): (Tokens(60_000, 30_000, 300_000, 4_000_000),
|
|
76
|
+
Tokens(120_000, 60_000, 600_000, 8_000_000)),
|
|
77
|
+
("review", "reasoning"): (Tokens(60_000, 30_000, 300_000, 4_000_000),
|
|
78
|
+
Tokens(120_000, 60_000, 600_000, 8_000_000)),
|
|
71
79
|
}
|
|
72
80
|
PRIOR_LOW, PRIOR_HIGH = 0.5, 1.5 # how far a prior's range is widened
|
|
73
81
|
|
|
@@ -143,6 +143,17 @@ def sweep(db: DB, now: float | None = None) -> list[str]:
|
|
|
143
143
|
|
|
144
144
|
done.extend(signin_pause.sweep(db, now))
|
|
145
145
|
|
|
146
|
+
# 1g. Periodic cost events (Team org_budgets): report delta spend for supervisor and workers.
|
|
147
|
+
from brindle import events
|
|
148
|
+
|
|
149
|
+
roots = {ws.repo_root for ws in db.find_workspaces() if ws.repo_root}
|
|
150
|
+
for r in roots:
|
|
151
|
+
try:
|
|
152
|
+
cfg = load_repo_config(r)
|
|
153
|
+
done.extend(events.emit_periodic_cost(cfg, r))
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
|
|
146
157
|
# 2. Workers nobody needs any more.
|
|
147
158
|
limits: dict[str, float] = {}
|
|
148
159
|
for a in db.list_agents():
|
|
@@ -374,6 +374,11 @@ CREATE TABLE IF NOT EXISTS launch_health (
|
|
|
374
374
|
baseline TEXT NOT NULL DEFAULT '',
|
|
375
375
|
outcome TEXT
|
|
376
376
|
);
|
|
377
|
+
CREATE TABLE IF NOT EXISTS agent_reported_spend (
|
|
378
|
+
agent_id TEXT PRIMARY KEY,
|
|
379
|
+
reported_usd REAL NOT NULL,
|
|
380
|
+
updated_at REAL NOT NULL
|
|
381
|
+
);
|
|
377
382
|
"""
|
|
378
383
|
|
|
379
384
|
|
|
@@ -1677,3 +1682,15 @@ class DB:
|
|
|
1677
1682
|
cols = ", ".join(f"{k}=?" for k in fields)
|
|
1678
1683
|
with self.tx() as c:
|
|
1679
1684
|
c.execute(f"UPDATE tasks SET {cols} WHERE id=?", (*fields.values(), task_id))
|
|
1685
|
+
|
|
1686
|
+
def get_reported_spend(self, agent_id: str) -> float:
|
|
1687
|
+
row = self.conn.execute("SELECT reported_usd FROM agent_reported_spend WHERE agent_id=?", (agent_id,)).fetchone()
|
|
1688
|
+
return float(row[0]) if row else 0.0
|
|
1689
|
+
|
|
1690
|
+
def set_reported_spend(self, agent_id: str, reported_usd: float) -> None:
|
|
1691
|
+
with self.tx() as c:
|
|
1692
|
+
c.execute(
|
|
1693
|
+
"INSERT INTO agent_reported_spend (agent_id, reported_usd, updated_at) VALUES (?, ?, ?) "
|
|
1694
|
+
"ON CONFLICT(agent_id) DO UPDATE SET reported_usd=excluded.reported_usd, updated_at=excluded.updated_at",
|
|
1695
|
+
(agent_id, reported_usd, time.time())
|
|
1696
|
+
)
|
|
@@ -34,7 +34,7 @@ log = logging.getLogger(__name__)
|
|
|
34
34
|
|
|
35
35
|
GROUP = plugins.EVENTS
|
|
36
36
|
KINDS = ("assign", "handoff", "review", "escalated", "merge", "remove", "deny_assign",
|
|
37
|
-
"deny_merge")
|
|
37
|
+
"deny_merge", "cost")
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
@dataclass(frozen=True)
|
|
@@ -100,9 +100,9 @@ def _model(profile: str | None, repo_root: str) -> str | None:
|
|
|
100
100
|
|
|
101
101
|
|
|
102
102
|
def _cost(kind: str, worker: Agent | None, repo_root: str) -> float | None:
|
|
103
|
-
"""What ``worker`` spent, for the ``remove`` event that ends it, when the
|
|
103
|
+
"""What ``worker`` spent, for the ``remove`` or ``cost`` event that ends it or tracks spend, when the
|
|
104
104
|
org counts spend (Team ``org_budgets``); None otherwise or when it can't be priced."""
|
|
105
|
-
if kind
|
|
105
|
+
if kind not in ("remove", "cost") or worker is None:
|
|
106
106
|
return None
|
|
107
107
|
try:
|
|
108
108
|
from brindle import budget
|
|
@@ -111,8 +111,14 @@ def _cost(kind: str, worker: Agent | None, repo_root: str) -> float | None:
|
|
|
111
111
|
|
|
112
112
|
if not license.has("org_budgets"):
|
|
113
113
|
return None
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
db = DB()
|
|
115
|
+
spent = budget.worker_spend(db, worker, repo_root)
|
|
116
|
+
if spent is None:
|
|
117
|
+
return None
|
|
118
|
+
get_rep = getattr(db, "get_reported_spend", None)
|
|
119
|
+
reported = get_rep(worker.id) if callable(get_rep) else 0.0
|
|
120
|
+
delta = round(spent - (reported or 0.0), 4)
|
|
121
|
+
return delta if delta > 0 else None
|
|
116
122
|
except Exception:
|
|
117
123
|
log.debug("brindle: couldn't price %s for the event", worker.id, exc_info=True)
|
|
118
124
|
return None
|
|
@@ -125,7 +131,7 @@ def _by_model(kind: str, worker: Agent | None, repo_root: str,
|
|
|
125
131
|
(which can include live usage) exceeds what the history rows add up to,
|
|
126
132
|
the remainder is an "unknown model" entry with no tokens, so the split
|
|
127
133
|
sums to ``cost_usd``."""
|
|
128
|
-
if kind
|
|
134
|
+
if kind not in ("remove", "cost") or worker is None:
|
|
129
135
|
return None
|
|
130
136
|
try:
|
|
131
137
|
from brindle import cost
|
|
@@ -146,10 +152,20 @@ def _by_model(kind: str, worker: Agent | None, repo_root: str,
|
|
|
146
152
|
slot = out.setdefault(cost.model_label(p), {"tokens": 0, "usd": 0.0})
|
|
147
153
|
slot["tokens"] += p.tokens
|
|
148
154
|
slot["usd"] += p.dollars or 0.0
|
|
149
|
-
|
|
150
|
-
if
|
|
151
|
-
|
|
152
|
-
|
|
155
|
+
total_usd = sum(b["usd"] for b in out.values())
|
|
156
|
+
if cost_usd is not None and total_usd > cost_usd > 0:
|
|
157
|
+
factor = cost_usd / total_usd
|
|
158
|
+
for b in out.values():
|
|
159
|
+
b["usd"] = round(b["usd"] * factor, 4)
|
|
160
|
+
diff = round(cost_usd - sum(b["usd"] for b in out.values()), 4)
|
|
161
|
+
if diff:
|
|
162
|
+
first = next(iter(out.values()))
|
|
163
|
+
first["usd"] = round(first["usd"] + diff, 4)
|
|
164
|
+
else:
|
|
165
|
+
rest = round((cost_usd or 0.0) - total_usd, 4)
|
|
166
|
+
if rest > 0:
|
|
167
|
+
slot = out.setdefault("unknown model", {"tokens": 0, "usd": 0.0})
|
|
168
|
+
slot["usd"] += rest
|
|
153
169
|
return out or None
|
|
154
170
|
except Exception:
|
|
155
171
|
log.debug("brindle: couldn't split %s by model for the event", worker.id, exc_info=True)
|
|
@@ -173,10 +189,59 @@ def emit(cfg: RepoConfig, kind: str, ws: Workspace, worker: Agent | None = None,
|
|
|
173
189
|
cost_usd=cost_usd,
|
|
174
190
|
by_model=_by_model(kind, worker, ws.repo_root, cost_usd),
|
|
175
191
|
))
|
|
192
|
+
if cost_usd and worker and kind in ("remove", "cost"):
|
|
193
|
+
try:
|
|
194
|
+
from brindle.db import DB
|
|
195
|
+
db = DB()
|
|
196
|
+
set_rep = getattr(db, "set_reported_spend", None)
|
|
197
|
+
get_rep = getattr(db, "get_reported_spend", None)
|
|
198
|
+
if callable(set_rep) and callable(get_rep):
|
|
199
|
+
old_reported = get_rep(worker.id) or 0.0
|
|
200
|
+
set_rep(worker.id, round(old_reported + cost_usd, 4))
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
176
203
|
except Exception:
|
|
177
204
|
log.exception("brindle: the events plugin failed on a %s event", kind)
|
|
178
205
|
|
|
179
206
|
|
|
207
|
+
def emit_periodic_cost(cfg: RepoConfig, repo_root: str) -> list[str]:
|
|
208
|
+
"""Check active agents (workers and supervisor sessions) in ``repo_root``,
|
|
209
|
+
compute their delta spend since last report, and emit a 'cost' event
|
|
210
|
+
if delta >= 0.01."""
|
|
211
|
+
from brindle import budget
|
|
212
|
+
from brindle.db import DB
|
|
213
|
+
from brindle.pro import license
|
|
214
|
+
|
|
215
|
+
done: list[str] = []
|
|
216
|
+
try:
|
|
217
|
+
if not license.has("org_budgets"):
|
|
218
|
+
return done
|
|
219
|
+
except Exception:
|
|
220
|
+
return done
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
db = DB()
|
|
224
|
+
for a in db.list_agents():
|
|
225
|
+
if a.dismissed_at is not None:
|
|
226
|
+
continue
|
|
227
|
+
ws = db.get_workspace(a.workspace_id)
|
|
228
|
+
if ws is None or ws.repo_root != repo_root:
|
|
229
|
+
continue
|
|
230
|
+
spent = budget.worker_spend(db, a, repo_root)
|
|
231
|
+
if spent is None:
|
|
232
|
+
continue
|
|
233
|
+
get_rep = getattr(db, "get_reported_spend", None)
|
|
234
|
+
reported = get_rep(a.id) if callable(get_rep) else 0.0
|
|
235
|
+
delta = round(spent - (reported or 0.0), 4)
|
|
236
|
+
if delta < 0.01:
|
|
237
|
+
continue
|
|
238
|
+
emit(cfg, "cost", ws, worker=a)
|
|
239
|
+
done.append(f"emitted periodic cost ${delta:.2f} for agent {a.id}")
|
|
240
|
+
except Exception:
|
|
241
|
+
log.exception("brindle: periodic cost check failed for %s", repo_root)
|
|
242
|
+
return done
|
|
243
|
+
|
|
244
|
+
|
|
180
245
|
def emit_denial(cfg: RepoConfig, repo_root: str, what: str, reason: str, *,
|
|
181
246
|
branch: str | None = None, profile: str | None = None,
|
|
182
247
|
provider: str | None = None, model: str | None = None,
|
|
@@ -194,5 +259,5 @@ def emit_denial(cfg: RepoConfig, repo_root: str, what: str, reason: str, *,
|
|
|
194
259
|
log.exception("brindle: the events plugin failed on a deny_%s event", what)
|
|
195
260
|
|
|
196
261
|
|
|
197
|
-
__all__ = ["GROUP", "KINDS", "Event", "EventsPlugin", "emit", "emit_denial", "plugin",
|
|
262
|
+
__all__ = ["GROUP", "KINDS", "Event", "EventsPlugin", "emit", "emit_denial", "emit_periodic_cost", "plugin",
|
|
198
263
|
"plugins_for"]
|