M2 PR6: decideForAgent reads cloud cache before local store#31
Merged
thomas-supervisor merged 1 commit intom1-pr3-cloud-clifrom May 5, 2026
Merged
Conversation
Bridges thomas-cloud → local thomas's policy decision pipeline. The proxy's
existing decide() flow stays unchanged structurally — we just resolve the
PolicyConfig from a wider source.
Resolution order on every request:
1. ~/.thomas/cloud-cache.json (set by `thomas cloud sync`)
2. ~/.thomas/policies.json (set by `thomas policy set`)
3. fallbackTarget (the route)
So a centrally-managed thomas-cloud policy automatically takes effect once
the user logs in and syncs; offline / not-logged-in users keep getting
their local policies — zero behavior change for the existing path.
Implementation:
src/cloud/policy-bridge.ts (new)
loadCloudPolicyForAgent(agentId): reads ~/.thomas/cloud-cache.json,
finds the matching agent_binding, translates it to a PolicyConfig the
rest of decide() understands. Three binding kinds:
- static → synthetic policy: primary=staticTarget, no cascade
- policy → translate the cloud PolicySpec wire shape (camelCase)
to local PolicyConfig (legacy field names)
- bundle → v1 stub: use the highest-priority leg as primary, no
cascade. Real bundle balancer (per-leg cap accounting +
drain order) lands in a follow-up PR.
Disabled policies / missing-target bundles → undefined, fall through.
src/policy/decide.ts
decideForAgent: cloud → local → fallback, first hit wins. Cloud
policies are loaded via the bridge above; local via the existing store.
src/policy/types.ts
PolicyDecision gains `policy` (the resolved PolicyConfig, for failoverTo
consumers that previously re-fetched) and `source: "cloud" | "local" | "none"`
for telemetry.
src/proxy/server.ts
Drops the second getPolicy() call; reads from decision.policy directly.
Tests:
- tests/cloud-decide.test.ts: 7 cases covering the three binding kinds
+ each fallback path (no binding for this agent / no cache / disabled
policy / missing bundle) + cloud-overrides-local on conflict.
- 263/263 pytests / vitest pass; build 186 KB.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Wires thomas-cloud's policy data into the local proxy's per-request decision pipeline. The change is structurally small — the existing `decide()` cascade stays — we just widen the policy source.
Resolution order
Every proxied request now resolves the policy in order:
First hit wins. Concretely: a centrally-managed thomas-cloud policy takes effect once the user logs in + syncs. Offline / not-logged-in users keep getting their local policies — zero behavior change for the existing flow.
Implementation
```
src/cloud/policy-bridge.ts (new)
loadCloudPolicyForAgent(agentId)
- reads ~/.thomas/cloud-cache.json (already maintained by PR3 sync)
- finds the agent_binding for this agent
- translates the cloud's wire shape (camelCase, providerId/triggerSpendDayUsd)
to local PolicyConfig (snake-case-ish, provider/triggerSpendDay)
- three binding kinds:
static → synthetic single-target policy (no cascade)
policy → translate PolicySpec straight through
bundle → v1 stub: use highest-priority leg as primary
(real bundle balancer = follow-up PR)
- disabled policies / missing target bundles → undefined → fall through
src/policy/decide.ts
decideForAgent: cloud → local → fallback. The decide() pure function
is unchanged; only the resolution of the input policy is widened.
src/policy/types.ts
PolicyDecision gains:
policy: PolicyConfig | null — the resolved config, so callers don't
re-fetch (proxy was doing exactly that)
source: "cloud" | "local" | "none" — for telemetry / debugging
src/proxy/server.ts
Drops the second getPolicy() round-trip; reads decision.policy.
```
Bundle handling — v1 stub, deliberate
`bundleAsPolicy()` uses leg[0] (highest priority) as the primary target with no cascade. This does not drain leg caps and rotate to leg[1] when leg[0]'s daily cap is hit. That's a non-trivial chunk of accounting (per-leg spend tracking, daily reset windows, atomic switch-over) that deserves its own PR with full test coverage. For users who haven't created bundles yet (i.e. all of them in v1), this is invisible.
Tests
`tests/cloud-decide.test.ts` — 7 new cases:
263 / 263 tests pass, build 186 KB.
Out of scope (deliberate)
🤖 Generated with Claude Code