M3.2: the action classifier
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:
1 parent
5044af371b
commit
089af0c391
15 files changed
+1023
-71
No files matched your search
@@ -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 ""
|
||||
|
||||
Reference in new issue
Block a user