Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ new version heading in the same commit.

## [Unreleased]

## [0.460.0] - 2026-10-12
### Changed
- **Live bets per goal is now an operator setting, not a constant.** The cap (4) exists so a loop cannot
run more experiments than a human can attribute, but it cuts both ways: a goal that is regressing in a
lever with no free slot is held back by the cap rather than protected by it. It is now
`settings.betsLiveCap`, settable from the goal room's **Bets** header and bounded by a hard ceiling of 8;
`BetStore` resolves it per `create`, so a change takes effect with no restart.
**For admins:** the Bets header on a goal now shows "N/M live" and lets an owner or admin raise the
limit on how many bets one goal may run at once. [Open Goals](#/goals)
### Added
- **`bet_list` reports the slot count** (`cap` + `live`), so an agent knows whether it may open a bet
instead of discovering the answer as a refused `bet_propose`.

## [0.459.1] - 2026-10-12
### Fixed
- **The "DataForSEO (v2 tools)" connector now authenticates.** The v2 server reads its login from
Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -562,8 +562,10 @@ Key modules:
Judged off the scheduler tick by `src/edge/bet-review.ts` (spawn-free, one owner card per bet guarded on
the `bet.judged` audit event, then the wake queue tells the agent to write its lesson). Agent tools
`bet_propose`/`bet_list`/`bet_update`/`asset_record`/`asset_measure`; read side `GET /api/bets?goal=`.
Caps: 4 live bets per goal, 60 assets, and re-lengthening a window re-stamps the judge date off the
ORIGINAL start. See `docs/bets-plan.md`.
Caps: 4 live bets per goal (the DEFAULT — `settings.betsLiveCap` raises it to `MAX_LIVE_BETS_CEILING`=8,
set from the goal room's Bets header; the cap cuts both ways, and a goal regressing in a lever with no
free slot is held back by it, not protected), 60 assets, and re-lengthening a window re-stamps the judge
date off the ORIGINAL start. See `docs/bets-plan.md`.
- `src/edge/dreaming.ts` — the **self-learning ("Dreaming")** engine: a periodic deterministic pass that
reflects on recent episodes + outcomes + friction, **compounds** them into `settings: dreaming_state`,
emits a living KB page + a tenant-shared memory Insight, and **closes the loop** — distilled guidance is
Expand Down
2 changes: 1 addition & 1 deletion docs/agent-mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ scope/parity properties, but not the cost.
| `host_propose` | `POST /api/hosts/propose` | `HostStore.propose` + messages | W | drafts an INACTIVE, credential-less org Host connection (`proposed=1, enabled=0` — excluded from every grant set until published) + posts a `host.proposed` inbox card to owner/admins; audited `host.proposed`. Human publishes via `POST /api/hosts/:id/publish` (owner/admin, flips it active) or dismisses via `DELETE /api/hosts/:id`. The agent CANNOT set a credential — the admin attaches the vault ref |
| `policy_propose` | `POST /api/agent/policy/propose` | `TerminalManager.proposePolicy` → `applyProposal` + messages | W | proposes a **TIGHTEN-ONLY** change to the governance ruleset — `tighten` an existing rule to a stricter outcome, `reorder` a conditional rule above the unconditional allow rules (fixes a first-match hole), or `add` a new `ask`/`never` guardrail. `applyProposal` (`src/governance/policy.ts`) refuses anything that would LOOSEN a guardrail (checked by construction **and** an exhaustive monotonicity sweep over the ruleset's arg space), touch a hard-deny `never`, change the default, or add an `allow` — so an invalid proposal is rejected up front with a reason. A valid proposal posts a `policy.proposal` inbox card (owner-addressed) carrying the delta + a before→after `preview`; **applies nothing** until an **owner** approves via `POST /api/policy/proposals/:id/approve` (re-validated against the current doc; then `AgentOS.applyPolicyDocument` snapshots to `policy_revisions`, writes the override, hot-reloads) or rejects via `…/reject`. Deduped + capped (≤10 open/agent). Agents see the raw `rules` via `GET /api/agent/policy` (`list_capabilities`) to craft the delta. Audited `policy.proposed` / `policy.proposal.approved` / `policy.proposal.rejected`; every applied edit (console, always-approve, approved proposal) is revertable via `POST /api/policy/revisions/:rev/revert` (owner) |
| `goal_measure` | `POST /api/goals/measure` | `GoalStore.addReading` + `metricStatus` | W | records one measured READING of a goal's metric — the OUTCOME record, as against `goal_events` (activity). This is the one goal write agents get besides proposing: they may report the number, they may not move the goalposts (metric/target/status stay human-owned). Every reading stores WHO took it (`agent:<id>`), so a number reported by the same agent that did the work is visibly self-reported. Refuses a non-finite value and a goal with no metric set. The reading also lands on the goal timeline, so a diligently-measured goal is never reported "stuck" by the activity sweep. Returns the recomputed verdict (`measuring` / `flat` / `regressing` / `achieved` / `unmeasured` / `new`). Audited `goal.measured` |
| `bet_propose` | `POST /api/agent/bets/propose` | `BetStore.create` | W | opens a BET on a goal — one falsifiable attempt at its number: hypothesis, lever, `expectedLift`, `baseline`, `windowDays` (7–60, clamped). Max **4 live bets per goal** (`MAX_LIVE_BETS`) so a loop cannot run more experiments than it can attribute. `start:false` leaves it `proposed` for a human. Audited `bet.created` |
| `bet_propose` | `POST /api/agent/bets/propose` | `BetStore.create` | W | opens a BET on a goal — one falsifiable attempt at its number: hypothesis, lever, `expectedLift`, `baseline`, `windowDays` (7–60, clamped). Live bets per goal are capped (**4 by default**, `MAX_LIVE_BETS`; an owner may raise it to 8 in the goal room's Bets header) so a loop cannot run more experiments than it can attribute — `bet_list` returns `cap` + `live` so the slot count is known before a propose is refused. `start:false` leaves it `proposed` for a human. Audited `bet.created` |
| `bet_list` | `POST /api/agent/bets/list` | `BetStore.list` + `assets` | R | bets with their assets and measurements in one call — read before proposing (slots are capped) and before measuring. `state` takes one state or `live` |
| `bet_update` | `POST /api/agent/bets/update` | `BetStore.update` | W | the agent's half of the judgement: the `lesson` and the final state (`kept`/`expanded`/`killed`), or edits to a running bet's hypothesis/baseline/expected lift/window. A terminal state **requires a lesson** (the next bet is chosen from these). ⚠ `observedLift`/`verdict`/`verdictNote` are NOT in the input — only the judging sweep writes them, so an agent cannot grade its own experiment; a kill on a `met` bet is allowed and both numbers land in `bet.updated`. Re-lengthening a window re-stamps the judge date off the ORIGINAL start, so judgement cannot be postponed indefinitely |
| `asset_record` | `POST /api/agent/bets/asset` | `BetStore.addAsset` | W | records a thing the bet shipped (page/post/listing/link) by URL — the attribution join. Idempotent per (bet, url); ≤60 per bet. An unrecorded asset cannot count for the bet, and a bet with no assets is judged `no_signal`, never failed. Audited `bet.asset.recorded` |
Expand Down
2 changes: 1 addition & 1 deletion docs/bets-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ publishing problem, not evidence against the hypothesis. Only then does `met` ap

| Thing | Limit | Why |
|---|---|---|
| live bets per goal | 4 (`MAX_LIVE_BETS`) | past this a loop runs more experiments than it can attribute — the trial's operator hit exactly this ceiling |
| live bets per goal | 4 by default (`MAX_LIVE_BETS`), raisable to 8 (`MAX_LIVE_BETS_CEILING`) via the `bets_live_cap` setting | past this a loop runs more experiments than it can attribute — the trial's operator hit exactly this ceiling. Tunable because it cuts both ways: on 2026-10-12 expresstech's goal was regressing in a page family its agent had no slot left to bet on, so the cap was holding the loop back rather than protecting it. Set from the goal room's Bets header (`PUT /api/settings/bets`, owner/admin, audited `settings.betsCap.updated`); `bet_list` reports `cap`/`live` so an agent knows its slots without being refused |
| window | 7–60 days, clamped | shorter cannot show a content/search effect; longer is not a bet |
| assets per bet | 60 | enough for a page family, few enough that "measure them all" stays cheap |
| re-lengthening a window | judge date re-stamped off the **original** start | otherwise a bet postpones its own judgement forever |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-os",
"version": "0.459.1",
"version": "0.460.0",
"description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.",
"license": "MIT",
"type": "commonjs",
Expand Down
32 changes: 31 additions & 1 deletion scripts/bets-test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const DAY = 86_400_000;
const { TenantRegistry } = require(path.join(ROOT, 'dist/tenant-registry.js'));
const { createHttpServer } = require(path.join(ROOT, 'dist/server.js'));
const { reviewBets } = require(path.join(ROOT, 'dist/edge/bet-review.js'));
const { MAX_LIVE_BETS, MET_FRACTION } = require(path.join(ROOT, 'dist/state/bets.js'));
const { MAX_LIVE_BETS, MAX_LIVE_BETS_CEILING, MET_FRACTION } = require(path.join(ROOT, 'dist/state/bets.js'));
const registry = new TenantRegistry(ROOT, 0, path.join(ROOT, 'config/agent-os.config.json'));
registry.bootAll();
const { os: aos, tm } = registry.default();
Expand Down Expand Up @@ -193,6 +193,36 @@ const DAY = 86_400_000;
assert(board.counts && board.counts.kept === 1, 'and a count per state', board.counts);
assert(board.canEdit === true, 'an owner is told they may edit', board.canEdit);

console.log('\n\x1b[1m11b) The live-bet cap is an operator number, not a constant\x1b[0m');
// The cap cuts both ways: expresstech (2026-10-12) had four running bets while its goal regressed in a
// page family with no slot left, so the cap was holding the loop back. Raising it must change what the
// STORE enforces, not just what a settings page displays.
const setCap = (value) => fetch(`${base}/api/settings/bets`, { method: 'PUT', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ value }) }).then((r) => r.json().then((j) => ({ status: r.status, ...j })));
const anonCap = await fetch(`${base}/api/settings/bets`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value: 6 }) });
assert(anonCap.status === 401, 'no cookie cannot change the cap', anonCap.status);
const raised = await setCap(6);
assert(raised.ok && raised.resolved === 6, 'an owner raises it to 6', raised);
assert(aos.bets.maxLiveBets() === 6, 'and the STORE enforces the new number, with no restart', aos.bets.maxLiveBets());
while (aos.bets.liveCount(aos.tenant, goal.id) < 6) {
const r = await propose({ goalId: goal.id, title: `Extra ${aos.bets.liveCount(aos.tenant, goal.id)}`, expectedLift: 1, windowDays: 7 });
if (!r.ok) break;
}
assert(aos.bets.liveCount(aos.tenant, goal.id) === 6, `the ${MAX_LIVE_BETS}-bet default no longer refuses the 5th and 6th`, aos.bets.liveCount(aos.tenant, goal.id));
const over6 = await propose({ goalId: goal.id, title: 'One past the raised cap', expectedLift: 1 });
assert(/max 6/.test(over6.error || ''), 'the raised cap holds, and the refusal names the number in force', over6);
// Out of range is a refusal rather than a silent clamp — an operator who asks for 99 must be told the
// ceiling, not handed 8 and left believing they got 99.
const tooBig = await setCap(99);
assert(tooBig.status === 400 && new RegExp(String(MAX_LIVE_BETS_CEILING)).test(tooBig.error || ''), 'past the ceiling is a 400 naming the ceiling, not a clamp', tooBig);
assert(aos.bets.maxLiveBets() === 6, 'and the refused write left the cap alone', aos.bets.maxLiveBets());
// The agent learns its slot count from `bet_list` instead of from a refusal.
const listed = await call('/api/agent/bets/list', { goalId: goal.id });
assert(listed.cap === 6 && listed.live === 6, 'bet_list reports cap + live so the agent knows its slots', { cap: listed.cap, live: listed.live });
const boardCap = await (await fetch(`${base}/api/bets?goal=${goal.id}`, { headers: { cookie } })).json();
assert(boardCap.cap === 6 && boardCap.live === 6 && boardCap.capCeiling === MAX_LIVE_BETS_CEILING, 'and the board carries the cap for the header control', { cap: boardCap.cap, live: boardCap.live, ceiling: boardCap.capCeiling });
const cleared = await setCap(null);
assert(cleared.ok && cleared.value === null && cleared.resolved === MAX_LIVE_BETS, `clearing it returns to the ${MAX_LIVE_BETS}-bet default`, cleared);

console.log('\n\x1b[1m12) A human can decide on the board\x1b[0m');
// Clear the live slots this test has filled — the cap is real and case 10 left it at the ceiling.
for (const b of aos.bets.list(aos.tenant, { goalId: goal.id, state: 'live' })) {
Expand Down
25 changes: 25 additions & 0 deletions src/governance/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ const CHAT_ROUTER_KEY = 'chat_router_enabled'; // generic Slack/Discord `/agent`
const TASK_PROPOSALS_KEY = 'task_proposals'; // agent-filed board items wait for a human accept ('off' = straight to todo)
const ROUTER_CONFIG_KEY = 'router_config'; // auto-router tuning (JSON RouterConfig): enabled/minScore/margin/llm
const CHAT_IDLE_MIN_KEY = 'chat_idle_timeout_min'; // resident (warm) chat session idle-kill, minutes
const BETS_LIVE_CAP_KEY = 'bets_live_cap'; // live bets per goal override; unset → the default in src/state/bets.ts
const MAX_CONCURRENT_KEY = 'max_concurrent_sessions'; // whole-box concurrency cap override; unset → RAM-derived default, 0 → unlimited
const INTERACTIVE_IDLE_HOURS_KEY = 'interactive_idle_timeout_hours'; // auto-close a detached member session idle past this; unset → 48h, 0 → off
const UNATTENDED_MAX_HOURS_KEY = 'unattended_max_runtime_hours'; // hard runtime ceiling for a headless/unattended run (stuck-mid-turn backstop); unset → 24h, 0 → off
Expand Down Expand Up @@ -1148,6 +1149,30 @@ export class SettingsStore {
return this.chatIdleTimeoutMinutes();
}

// ── live bets per goal (docs/bets-plan.md) ───────────────────────────────────────
// How many bets one goal may have running at once. The cap exists so a loop cannot run more
// experiments than a human can act on, but it is an OPERATOR number: a goal that is regressing in a
// lever with no free slot is held back by the cap, not protected by it. Resolution (default and
// clamp) lives with the store — `resolveLiveBetCap` in src/state/bets.ts — so this returns the raw
// override only, exactly like the concurrency cap above.

/** The operator-set live-bet cap, or `null` when unset (→ `MAX_LIVE_BETS`). */
betsLiveCap(): number | null {
const raw = this.getRow(BETS_LIVE_CAP_KEY)?.value;
if (raw == null || raw.trim() === '') return null; // unset → the default
const n = Number(raw);
if (!Number.isFinite(n) || n < 1) return null; // garbage → treat as unset
return Math.floor(n);
}

/** Set (or clear, with `null`) the live-bet cap override. The store clamps to 1..ceiling. */
setBetsLiveCap(n: number | null, by?: string): number | null {
const v = Number(n);
if (n == null || !Number.isFinite(v) || v < 1) this.set(BETS_LIVE_CAP_KEY, '', by);
else this.set(BETS_LIVE_CAP_KEY, String(Math.floor(v)), by);
return this.betsLiveCap();
}

// ── whole-box concurrency cap (docs/concurrency-cap-plan.md) ─────────────────────
// The max number of live sessions the scheduler will let run at once. Every live session holds a
// tmux pane + a `claude` process (hundreds of MB), so enough of them swap/OOM the box. This is the
Expand Down
2 changes: 1 addition & 1 deletion src/kernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export class AgentOS {
this.runtimeAccounts = new RuntimeAccountStore(this.db);
// Goals are pure db-only structured state (no attachments/on-disk mirror) → the db alone.
this.goals = new GoalStore(this.db);
this.bets = new BetStore(this.db);
this.bets = new BetStore(this.db, () => this.settings.betsLiveCap());
this.feed = new FeedStore(this.db);
// In-flight video renders (async) — db-only control state, like tasks/goals.
this.videoJobs = new VideoJobStore(this.db);
Expand Down
13 changes: 10 additions & 3 deletions src/memory/memory-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1351,7 +1351,7 @@ const TOOLS = [
'`baseline` those things earn TODAY (0 for something new), and a `windowDays` long enough for the ' +
'effect to show (7-60). Then record what you ship with `asset_record` and its numbers with ' +
'`asset_measure`. At the end of the window the server computes the verdict FROM YOUR ASSETS — you ' +
'cannot grade your own bet, which is what makes a kept bet worth anything. Max 4 live bets per goal.',
'cannot grade your own bet, which is what makes a kept bet worth anything. Live bets per goal are ' + 'capped (4 by default, raisable by an owner) — `bet_list` reports the cap and how many you are using.',
inputSchema: {
type: 'object',
additionalProperties: false,
Expand Down Expand Up @@ -2949,8 +2949,15 @@ async function betList(args: Record<string, unknown>): Promise<string> {
limit: args.limit !== undefined ? Number(args.limit) : undefined,
});
const bets = (d.bets as Array<Record<string, unknown>>) ?? [];
if (!bets.length) return 'No bets match. (A goal with no bets is one nobody is attempting — open one with `bet_propose`.)';
return bets.map((b) => {
// Slot line: how many live bets this goal has against the cap, so the agent knows whether it may open
// one without discovering the answer as a refusal.
const cap = Number(d.cap ?? 0);
const live = d.live !== undefined ? Number(d.live) : undefined;
const slots = cap && live !== undefined
? `slots: ${live}/${cap} live${live >= cap ? ' — full, judge or kill one before proposing' : ` (${cap - live} free)`}\n\n`
: '';
if (!bets.length) return slots + 'No bets match. (A goal with no bets is one nobody is attempting — open one with `bet_propose`.)';
return slots + bets.map((b) => {
const assets = (b.assets as Array<Record<string, unknown>>) ?? [];
const measured = assets.filter((a) => a.value !== undefined);
const total = measured.reduce((s, a) => s + Number(a.value ?? 0), 0);
Expand Down
Loading
Loading