M3.2: the action classifier
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m20s
CI / e2e (pull_request) Successful in 12m31s

What the fast path calls unknown is judged by an independent model call
on the item's classifier role (L4), before it runs.

- nisse_dev.loop.classifier: it sees the action and the agreed scope
  (project, repository, issue number, the restatement the operator said
  go to), built in code; never the transcript, the issue's text, tool
  output or the driver's reasoning. Its verdict is a tool call: safe
  runs; in_scope runs and is reported in the thread; needs_discussion
  asks, as a dangerous action does. An error, no verdict or an unknown
  one fails closed. Verdicts are cached per item, agreed revision and
  action. A helper is refused what needs discussion.
- action_verdict gains source, model and the classifier's exact input;
  details shows the source. An allow after the classifier asked is an
  override. scripts/classifier-evals.py exports the judgements from a
  store, read-only; tests/integration/classifier_cases.yaml is the seed
  set, run per model with --capability-models.
- The fast path's delete rule: deleting on the build VM is contained;
  only what breaks the host (system and home directories) stays
  dangerous, since in tests the build VM is the test machine.
- The classifier role is the local model in the example config.
- The property walk gains a classifier answering at random, junk and
  failures included; dangerous_only_when_allowed now covers its
  verdicts (checked against a mutant that runs them all).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
dongarandClaude Opus 5.5 committed 2026-10-08 12:09:38 +00:00
1 parent 5044af371b
commit 089af0c391
15 files changed
+1023 -71

No files matched your search

+95 -16
View File
@@ -25,7 +25,7 @@ from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from nisse_core.actions import DANGEROUS, REFUSED, Action, Verdict
from nisse_core.actions import DANGEROUS, REFUSED, UNKNOWN, Action, Verdict
from nisse_dev.cancel import Cancel
from nisse_dev.canonical import (
ActionVerdict,
@@ -45,6 +45,7 @@ from nisse_dev.git import GitError
from nisse_dev.gitea import GiteaClient, GiteaError, Issue
from nisse_dev.llm import LLMError, RoleMap
from nisse_dev.loop.actions import classify
from nisse_dev.loop.classifier import IN_SCOPE, ActionClassifier, Scope
from nisse_dev.loop.driver import ASK_ALLOW, Driver, RunOutcome, Screening
from nisse_dev.loop.help import ASK_EXPERT, DELEGATE, Helpers
from nisse_dev.loop.items import (
@@ -135,8 +136,8 @@ Only the operator stops or closes an item (their stop and abandon commands):
never say you have stopped or closed anything yourself.
Commands that reach beyond this repository and its build VM (pushing, other
hosts, uploads, publishing, deleting outside the workspace) are put to the
operator before they run; if they allow one, run it again exactly as before.
hosts, uploads, publishing) or could break the host are put to the operator
before they run; if they allow one, run it again exactly as before.
Never change branches yourself.
Before each group of tool calls, say in a sentence what you're doing and why:
@@ -271,6 +272,7 @@ class LoopAgent:
# the projects configured now that this agent doesn't know yet.
reload: Callable[[set[str]], Sequence[Project]] | None = None,
helpers: Helpers | None = None,
classifier: ActionClassifier | None = None,
clock: Callable[[], float] = time.time,
intake_interval_s: float = INTAKE_INTERVAL_S,
) -> None:
@@ -293,6 +295,10 @@ class LoopAgent:
self._idle = general_idle_s
self._reload = reload
self._helpers = helpers
# L4: judges what the fast path doesn't know; without one, it runs.
self._classifier = classifier
# Verdicts by (item, agreed revision, action): judged once (M3.2).
self._judged: dict[tuple[str, int, str], tuple[Verdict, tuple[str, str]]] = {}
self._clock = clock
self._interval = intake_interval_s
# thread root -> revision on screen there, read on the transport's
@@ -1539,16 +1545,21 @@ class LoopAgent:
# -- the fast path (P4, D6) -------------------------------------------------
def _screen(self, item_id: str, root: str, call: ToolCall) -> Screening:
"""The driver's execution call, judged before it runs: dangerous
ones run only when the operator allowed exactly this one."""
"""The driver's execution call, judged before it runs (P4): by the
fast path's rules, then the classifier for what they don't know
(L4). A dangerous one runs only when the operator allowed exactly
this one, or the classifier found it plainly in scope."""
item = self._store.work_item(item_id)
assert item is not None
loop = loop_of(item)
action = Action(call.tool, dict(call.arguments))
verdict = classify(action, root)
key = action.key()
verdict, judged = self._judge(item, "driver", call, action, root)
if verdict.effect == REFUSED:
decision, screening = "refused", Screening("refuse", verdict.reason)
elif judged is not None and verdict.rule == IN_SCOPE:
decision, screening = "ran", Screening("run")
self._in_scope(item, action, verdict.reason)
elif verdict.effect == DANGEROUS and loop.allowed == key:
decision, screening = "allowed", Screening("run")
# Once: a second identical call is asked about again (L3).
@@ -1561,34 +1572,95 @@ class LoopAgent:
)
elif verdict.effect == DANGEROUS:
decision = "asked"
screening = Screening(
"ask", self._ask_text(item, action, verdict.reason), key
why = (
verdict.reason
if judged is None
else f"the classifier says: {verdict.reason}"
)
screening = Screening("ask", self._ask_text(item, action, why), key)
else:
decision, screening = "ran", Screening("run")
self._verdict(item_id, "driver", call, verdict, decision)
self._verdict(item_id, "driver", call, verdict, decision, judged)
return screening
def _screen_helper(self, item_id: str, root: str, call: ToolCall) -> str | None:
"""A helper's execution call: a helper can't ask the operator, so a
dangerous one is refused with what to do instead."""
verdict = classify(Action(call.tool, dict(call.arguments)), root)
item = self._store.work_item(item_id)
assert item is not None
action = Action(call.tool, dict(call.arguments))
verdict, judged = self._judge(item, "helper", call, action, root)
if verdict.effect == REFUSED:
self._verdict(item_id, "helper", call, verdict, "refused")
self._verdict(item_id, "helper", call, verdict, "refused", judged)
return verdict.reason
if judged is not None and verdict.rule == IN_SCOPE:
self._verdict(item_id, "helper", call, verdict, "ran", judged)
self._in_scope(item, action, verdict.reason)
return None
if verdict.effect == DANGEROUS:
self._verdict(item_id, "helper", call, verdict, "refused")
self._verdict(item_id, "helper", call, verdict, "refused", judged)
return (
f"{verdict.reason}, which needs the operator's agreement; only the "
"driver can ask for it, so hand back and say what you need"
)
self._verdict(item_id, "helper", call, verdict, "ran")
self._verdict(item_id, "helper", call, verdict, "ran", judged)
return None
def _judge(
self, item: WorkItem, actor: str, call: ToolCall, action: Action, root: str
) -> tuple[Verdict, tuple[str, str] | None]:
"""The fast path's verdict, or for what it doesn't know, the
classifier's, with (model, what it was shown)."""
verdict = classify(action, root)
if verdict.effect != UNKNOWN or self._classifier is None:
return verdict, None
self._verdict(item.id, actor, call, verdict, "classified")
loop = loop_of(item)
cached = (item.id, loop.rev, action.key())
if cached not in self._judged:
scope = Scope(
project=item.scope or "",
repo=self._at(item).repo,
issue=loop.issue,
agreed=self._agreed(item),
)
model = self._roles_of(item).resolve("classifier")
judged, by = self._classifier.judge(action, scope, root, model=model)
self._judged[cached] = (
judged,
(by, self._classifier.input_for(action, scope, root)),
)
judged, seen = self._judged[cached]
return judged, seen
def _agreed(self, item: WorkItem) -> str:
"""The restatement the operator said go to: the latest one put up."""
events = self._store.transcript(item.id, "driver").read()
plans = [e.event.plan for e in events if isinstance(e.event, PlanSubmitted)]
return plans[-1] if plans else item.title
def _in_scope(self, item: WorkItem, action: Action, reason: str) -> None:
"""P3: dangerous but in scope runs, and is reported."""
what = (
action.arguments.get("command") or f"{action.tool} {dict(action.arguments)}"
)
self._transport.post(
f"Ran on {self._label(item)}, judged in scope: `{what}` ({reason})",
thread=loop_of(item).thread,
)
def _verdict(
self, item_id: str, actor: str, call: ToolCall, verdict: Verdict, decision: str
self,
item_id: str,
actor: str,
call: ToolCall,
verdict: Verdict,
decision: str,
judged: tuple[str, str] | None = None,
) -> None:
# L4, L7: every verdict logged, for the operator and the evaluation set.
model, shown = judged or ("", "")
source = "classifier" if judged is not None else "fast_path"
self._store.transcript(item_id, actor).append(
ActionVerdict(
call_id=call.call_id,
@@ -1597,12 +1669,17 @@ class LoopAgent:
rule=verdict.rule,
reason=verdict.reason,
decision=decision,
source=source,
model=model,
input=shown,
)
)
self._oplog.emit(
"action.verdict",
cause=f"{actor} called {call.tool}",
work_item=item_id,
source=source,
model=model,
effect=verdict.effect,
rule=verdict.rule,
decision=decision,
@@ -1743,9 +1820,11 @@ class LoopAgent:
order = ("harmless", "contained", "unknown", "dangerous", "refused")
said = ", ".join(f"{counts[e]} {e}" for e in order if e in counts)
notable = [
f"- {v.decision}: {v.tool} ({v.rule}: {v.reason})"
f"- {v.decision}{' by the classifier' if v.source == 'classifier' else ''}"
f": {v.tool} ({v.rule}: {v.reason})"
for v in verdicts
if v.decision != "ran"
if v.decision not in ("ran", "classified")
or (v.source == "classifier" and v.rule == IN_SCOPE)
]
return f"\n\nVerdicts: {said}." + (
"\n" + "\n".join(notable[-DETAILS:]) if notable else ""