Skip to content
Open
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@ TYPESAFE_API_KEY=
# FOREMAN_STEERING_ENABLED=true
# FOREMAN_MAX_STEERS_PER_WORKER=1
# FOREMAN_STEERING_GRACE_SECONDS=30

# Decision-policy responsibility configuration (baseline safety floor)
# FOREMAN_DECISION_THRESHOLD=0.70
# FOREMAN_ALWAYS_ABSTAIN=destructive,credentials
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ exec backend. Live steering into an active turn is only available with the Codex
backend; other backends degrade to stop/retry. See [Worker backends](docs/workers.md) for the
`Worker` protocol and how to add your own.

### Decision policy

The `supervision.decision-policy` responsibility lets `foreman serve` gate worker decisions
automatically: bounded decision models, a tighten-only abstention policy, and an audit trail
of every autonomous decision (`FOREMAN_DECIDED` events, rendered by `foreman inspect`).

## What Foreman watches

Each observation is compact and bounded. It contains:
Expand Down
55 changes: 41 additions & 14 deletions src/foreman/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import json
import os
import sys
from datetime import UTC
from datetime import UTC, datetime
from pathlib import Path
from typing import Annotated

Expand Down Expand Up @@ -413,26 +413,42 @@ def inspect_run(

store = RunStore(repo)
try:
state = store.load_state(run_id)
events = store.load_events(run_id)
except PersistenceError as error:
console.print(str(error))
raise typer.Exit(code=2) from error

finished = state.finished_at or state.updated_at
duration = max(0.0, (finished - state.started_at).total_seconds())
console.print(f"[bold]FOREMAN RUN: {state.run_id}[/bold]")
console.print("Job:")
console.print(state.job)
console.print(f"Duration: {duration_label(duration)}")
console.print(f"Workers: {len(state.workers)}")
console.print(f"Evaluations: {len(state.result_history)}")
if state.active_responsibility_ids:
console.print(f"Responsibilities: {', '.join(state.active_responsibility_ids)}")
console.print(f"Result: {state.status.value}")
try:
state = store.load_state(run_id)
except PersistenceError:
state = None

if state is not None:
finished = state.finished_at or state.updated_at
duration = max(0.0, (finished - state.started_at).total_seconds())
console.print(f"[bold]FOREMAN RUN: {state.run_id}[/bold]")
console.print("Job:")
console.print(state.job)
console.print(f"Duration: {duration_label(duration)}")
console.print(f"Workers: {len(state.workers)}")
console.print(f"Evaluations: {len(state.result_history)}")
if state.active_responsibility_ids:
console.print(f"Responsibilities: {', '.join(state.active_responsibility_ids)}")
console.print(f"Result: {state.status.value}")
started_at = state.started_at
else:
started_at = events[0].timestamp if events else datetime.now(UTC)
duration = (
max(0.0, (events[-1].timestamp - started_at).total_seconds())
if len(events) > 1
else 0.0
)
console.print(f"[bold]FOREMAN RUN: {run_id}[/bold]")
console.print(f"Duration: {duration_label(duration)}")
console.print(f"Events: {len(events)}")

for event in events:
offset = (event.timestamp - state.started_at).total_seconds()
offset = (event.timestamp - started_at).total_seconds()
prefix = elapsed_label(offset)
payload = event.payload
if event.event_type is EventType.FACTORY_STARTED:
Expand Down Expand Up @@ -479,6 +495,17 @@ def inspect_run(
console.print(f" {label:<16} {float(assessment[key]):.2f}")
elif event.event_type is EventType.FOREMAN_INTERVENED:
console.print(f" {payload.get('action')}: {payload.get('reason', '')}")
elif event.event_type is EventType.FOREMAN_DECIDED:
status = "answered" if payload.get("answered") else "abstained"
console.print(f"{prefix} Foreman decided: {status}")
if payload.get("question"):
q = str(payload.get("question")).replace("\n", " ")
console.print(f" question: {q[:80] + '...' if len(q) > 80 else q}")
console.print(f" choice: {payload.get('choice')}")
console.print(f" confidence: {float(payload.get('confidence', 0.0)):.2f}")
if payload.get("rationale"):
r = str(payload.get("rationale")).replace("\n", " ")
console.print(f" rationale: {r[:100] + '...' if len(r) > 100 else r}")
elif event.event_type in {EventType.WORKER_STEERED, EventType.WORKER_STEER_FAILED}:
label = "steered" if event.event_type is EventType.WORKER_STEERED else "steer failed"
console.print(f"{prefix} {payload.get('worker_id')} {label}")
Expand Down
29 changes: 29 additions & 0 deletions src/foreman/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

from pydantic import BaseModel, ConfigDict, Field

from foreman.models import AbstainCategory


def _environment_bool(value: str) -> bool:
normalized = value.strip().lower()
Expand All @@ -16,6 +18,24 @@ def _environment_bool(value: str) -> bool:
raise ValueError(f"invalid boolean environment value: {value!r}")


def _environment_abstain_categories(value: str) -> list[AbstainCategory]:
"""Parse a comma-separated FOREMAN_ALWAYS_ABSTAIN value."""

categories: list[AbstainCategory] = []
for raw in value.split(","):
name = raw.strip().lower()
if not name:
continue
try:
categories.append(AbstainCategory(name))
except ValueError as error:
valid = ", ".join(category.value for category in AbstainCategory)
raise ValueError(
f"invalid abstain category {raw!r}; expected one of: {valid}"
) from error
return categories


class FactoryConfig(BaseModel):
model_config = ConfigDict(extra="forbid")

Expand All @@ -35,6 +55,13 @@ class FactoryConfig(BaseModel):
steering_enabled: bool = True
max_steers_per_worker: int = Field(default=1, ge=0)
steering_grace_seconds: float = Field(default=30.0, ge=0.0)
decision_threshold: float = Field(default=0.70, ge=0.0, le=1.0)
always_abstain: list[AbstainCategory] = Field(
default_factory=lambda: [
AbstainCategory.DESTRUCTIVE,
AbstainCategory.CREDENTIALS,
]
)

diff_limit: int = Field(default=20_000, ge=100)
output_limit: int = Field(default=12_000, ge=100)
Expand Down Expand Up @@ -63,6 +90,8 @@ def from_environment(cls) -> FactoryConfig:
int,
),
"FOREMAN_CODEX_BACKEND": ("codex_backend", str),
"FOREMAN_DECISION_THRESHOLD": ("decision_threshold", float),
"FOREMAN_ALWAYS_ABSTAIN": ("always_abstain", _environment_abstain_categories),
"FOREMAN_STEERING_ENABLED": (
"steering_enabled",
_environment_bool,
Expand Down
79 changes: 79 additions & 0 deletions src/foreman/decision_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from __future__ import annotations

from collections.abc import Collection, Sequence
from dataclasses import dataclass

from foreman.config import FactoryConfig
from foreman.models import AbstainCategory, Decision, DecisionRequest


@dataclass(frozen=True, slots=True)
class EffectivePolicy:
"""The merged safety policy for one decision request.

The caller's request can only tighten the baseline: the effective
threshold is the maximum of the two, and the effective denylist is the
union of both. A caller can never loosen foreman's safety floor.
"""

threshold: float
denylist: frozenset[AbstainCategory]


@dataclass(frozen=True, slots=True)
class PolicyOutcome:
answered: bool
choice: str | None
rationale: str


def effective_policy(config: FactoryConfig, request: DecisionRequest) -> EffectivePolicy:
"""Merge the configured baseline with one request, tighten-only."""

requested = request.min_confidence if request.min_confidence is not None else 0.0
return EffectivePolicy(
threshold=max(config.decision_threshold, requested),
denylist=frozenset(config.always_abstain) | frozenset(request.extra_abstain_categories),
)


def apply_policy(
decision: Decision,
*,
threshold: float,
denylist: Collection[AbstainCategory],
options: Sequence[str],
) -> PolicyOutcome:
"""Decide whether the model's verdict may be returned as an answer.

Anything short of a confident, valid, denylist-clean verdict becomes an
abstention: the question goes back to the human, and nothing is
terminated or escalated.
"""

problems: list[str] = []
if decision.abstained:
problems.append("the foreman model abstained")
if decision.confidence < threshold:
problems.append(
f"confidence {decision.confidence:.2f} is below the {threshold:.2f} threshold"
)
if decision.choice is None or decision.choice not in options:
problems.append("the choice is not one of the offered options")
denied = set(decision.classification) & set(denylist)
if denied:
names = ", ".join(sorted(category.value for category in denied))
problems.append(f"the question is classified as {names}, which must abstain")
if problems:
return PolicyOutcome(
answered=False,
choice=None,
rationale=(
"Abstained: " + "; ".join(problems) + f". Model rationale: {decision.rationale}"
),
)
return PolicyOutcome(
answered=True,
choice=decision.choice,
rationale=decision.rationale,
)
11 changes: 10 additions & 1 deletion src/foreman/foreman/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from collections.abc import Sequence
from typing import Protocol

from foreman.models import ForemanResult
from foreman.models import Decision, DecisionRequest, ForemanResult
from foreman.observation import FactoryObservation
from foreman.responsibilities import Check

Expand All @@ -19,4 +19,13 @@ async def assess(
checks: Sequence[Check],
) -> ForemanResult: ...

async def decide(self, request: DecisionRequest) -> Decision:
"""Answer one multiple-choice question or abstain.

The returned Decision is the model's raw judgment; the decision
policy applies the configured confidence threshold and abstain
denylist before any answer reaches the caller.
"""
...

async def close(self) -> None: ...
Loading