Skip to content

M2 PR6: decideForAgent reads cloud cache before local store#31

Merged
thomas-supervisor merged 1 commit intom1-pr3-cloud-clifrom
m2-pr6-decide-uses-cloud-cache
May 5, 2026
Merged

M2 PR6: decideForAgent reads cloud cache before local store#31
thomas-supervisor merged 1 commit intom1-pr3-cloud-clifrom
m2-pr6-decide-uses-cloud-cache

Conversation

@thomas-supervisor
Copy link
Copy Markdown
Collaborator

Stacked on #30 (m1-pr3-cloud-cli). Re-target to `main` once #30 merges.

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:

  1. ~/.thomas/cloud-cache.json (set by `thomas cloud sync`; populated from `/v1/sync`)
  2. ~/.thomas/policies.json (set by `thomas policy set`)
  3. route fallback (`thomas route ...`)

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:

  • cloud static binding overrides a present local policy
  • cloud policy binding evaluates the cascade against today's spend
  • no cloud binding for this agent → falls back to local policy
  • no cloud cache + no local policy → fallbackTarget wins
  • disabled cloud policy → falls through to local
  • bundle binding → highest-priority leg used (v1 stub)
  • bundle binding with missing target → falls through to local

263 / 263 tests pass, build 186 KB.

Out of scope (deliberate)

  • Real bundle balancing (per-leg cap accounting)
  • Background sync loop (today, `thomas cloud sync` is manual; the cache only refreshes when the user runs that)
  • Failover-to enforcement when the policy came from cloud (already works — `decision.policy.failoverTo` is forwarded; no change needed)
  • Telemetry uplink (`runs.jsonl` → cloud) — needs the cloud's runs API (M3)

🤖 Generated with Claude Code

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>
@thomas-supervisor thomas-supervisor merged commit d24bf16 into m1-pr3-cloud-cli May 5, 2026
2 checks passed
@thomas-supervisor thomas-supervisor deleted the m2-pr6-decide-uses-cloud-cache branch May 5, 2026 10:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant