M3.1: action classes and the fast path
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m10s
CI / e2e (pull_request) Successful in 10m30s

Every execution tool call, the driver's and a helper's, gets a verdict
from code before it runs (P3, P4, D6).

- nisse_core.actions, nisse-core's first code: Action and Verdict, and
  reading shell commands statically, segment by segment (cd, wrappers,
  sh -c). What can't be read that way is unknown; a line is as bad as
  its worst command.
- nisse_dev.loop.actions, the rule table: reading, checks and tests are
  harmless; workspace edits, local git and installs on the build VM are
  contained; pushing, other hosts, uploads, publishing, the forge's own
  tools, deleting outside the workspace and wrecking the VM are
  dangerous; changing branches by hand is refused (the harness owns
  branches) with a pointer to the milestone tools.
- A dangerous call isn't run. The item waits on the operator with a
  question rendered from the action, and `allow` lets exactly that
  action run once. A helper can't ask, so it is refused and told to
  hand back. Unknown actions run, logged, until the classifier (M3.2).
- Every verdict is an action_verdict event, which no model sees, and an
  oplog line; `details` shows the counts and every action not simply
  run.
- The property walk gains dangerous_only_when_allowed (checked against
  a mutant that doesn't use the allow up); the slice e2e checks every
  call was judged and none needed an allow.

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 04:32:13 +00:00
1 parent d0fde2f136
commit ea4376eaa1
16 files changed
+1400 -17

No files matched your search

+179 -5
View File
@@ -16,6 +16,7 @@ run (L1).
"""
import contextlib
import json
import re
import threading
import time
@@ -24,12 +25,15 @@ 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_dev.cancel import Cancel
from nisse_dev.canonical import (
ActionVerdict,
DelegationCompleted,
DelegationStarted,
Fact,
PlanSubmitted,
StoredEvent,
ToolCall,
ToolResult,
UserMessage,
@@ -40,7 +44,8 @@ from nisse_dev.expert import BudgetExhausted, ExpertDesk
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.driver import Driver, RunOutcome
from nisse_dev.loop.actions import classify
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 (
ANSWER,
@@ -84,9 +89,9 @@ _GO = frozenset({"go", "approve"})
_ISSUE = r"(?:([a-z0-9][a-z0-9-]*)#|#)?(\d+)"
_NEXT = re.compile(rf"(?:do|work on) {_ISSUE} (?:next|first|now)")
# A command that names its item applies to it, wherever it is said (L5).
_NAMED = re.compile(r"(go|approve|abandon|stop) (?:([a-z0-9][a-z0-9-]*))?#(\d+)")
_NAMED = re.compile(r"(go|approve|abandon|stop|allow) (?:([a-z0-9][a-z0-9-]*))?#(\d+)")
# At top level these need an item; without one they are ambiguous (L5).
_NEEDS_ITEM = frozenset({"go", "approve", "abandon"})
_NEEDS_ITEM = frozenset({"go", "approve", "abandon", "allow"})
# Near-misses for abandon: asked about, never taken for it or for a reply.
_NEAR_ABANDON = frozenset(
{
@@ -129,6 +134,11 @@ asking is better than guessing.
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.
Never change branches yourself.
Before each group of tool calls, say in a sentence what you're doing and why:
the operator sees it as your progress.
@@ -937,6 +947,33 @@ class LoopAgent:
),
cause="the operator asked a question",
)
elif command == "allow":
if item.position == WAITING and loop.pending_action:
action = json.loads(loop.pending_action)
self._fact(
item,
f"the operator ({msg.sender}) allowed this once: "
f"{action['tool']} {json.dumps(action['arguments'])}. Run it "
"again exactly as before.",
)
save(
self._store,
item.id,
READY,
replace(
loop,
wait="",
note="",
allowed=loop.pending_action,
pending_action="",
),
cause=f"allowed by {msg.sender}",
)
else:
self._transport.post(
f"Nothing on {self._label(item)} is waiting for `allow`.",
thread=loop.thread,
)
elif command == "stop":
# Stopping a run took effect on arrival; a stop is never an answer.
if not loop.note.startswith("stopped ("):
@@ -1457,6 +1494,7 @@ class LoopAgent:
on_turn=lambda said: self._progress(
f"{item.id}:helper", loop.thread, said, prefix="helper: "
),
screen=lambda call: self._screen_helper(item.id, str(workspace.root), call),
)
git = milestones.git
before = git.head_sha() # where the milestone is, uncommitted work aside
@@ -1498,6 +1536,91 @@ class LoopAgent:
)
return said, False
# -- 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."""
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()
if verdict.effect == REFUSED:
decision, screening = "refused", Screening("refuse", 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).
save(
self._store,
item_id,
item.position,
replace(loop, allowed=""),
cause="allowed action ran",
)
elif verdict.effect == DANGEROUS:
decision = "asked"
screening = Screening(
"ask", self._ask_text(item, action, verdict.reason), key
)
else:
decision, screening = "ran", Screening("run")
self._verdict(item_id, "driver", call, verdict, decision)
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)
if verdict.effect == REFUSED:
self._verdict(item_id, "helper", call, verdict, "refused")
return verdict.reason
if verdict.effect == DANGEROUS:
self._verdict(item_id, "helper", call, verdict, "refused")
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")
return None
def _verdict(
self, item_id: str, actor: str, call: ToolCall, verdict: Verdict, decision: str
) -> None:
# L4, L7: every verdict logged, for the operator and the evaluation set.
self._store.transcript(item_id, actor).append(
ActionVerdict(
call_id=call.call_id,
tool=call.tool,
effect=verdict.effect,
rule=verdict.rule,
reason=verdict.reason,
decision=decision,
)
)
self._oplog.emit(
"action.verdict",
cause=f"{actor} called {call.tool}",
work_item=item_id,
effect=verdict.effect,
rule=verdict.rule,
decision=decision,
)
def _ask_text(self, item: WorkItem, action: Action, reason: str) -> str:
"""Rendered from the action itself, never from a model's account of
it (L3, L11)."""
what = (
f"run `{action.arguments.get('command', '')}`"
if action.tool == "run_command"
else f"call {action.tool} {dict(action.arguments)}"
)
return (
f"{self._label(item)} wants to {what}: {reason}. Say `allow` to let "
"exactly this run once, or tell me what to do instead."
)
def _ask_expert(self, item_id: str, question: str) -> tuple[str, bool]:
item = self._store.work_item(item_id)
assert item is not None
@@ -1601,8 +1724,31 @@ class LoopAgent:
lines.append(f"- {who}{call.tool}({str(target)[:80]}) → {outcome}")
if not lines:
return f"No tool calls yet on {self._label(item)}."
return f"The last tool calls on {self._label(item)}:\n" + "\n".join(
lines[-DETAILS:]
return (
f"The last tool calls on {self._label(item)}:\n"
+ "\n".join(lines[-DETAILS:])
+ self._verdict_summary(events)
)
@staticmethod
def _verdict_summary(events: Sequence[StoredEvent]) -> str:
"""The fast path's verdicts on the item (P4): counts, and every
action that wasn't simply run."""
verdicts = [e.event for e in events if isinstance(e.event, ActionVerdict)]
if not verdicts:
return ""
counts: dict[str, int] = {}
for v in verdicts:
counts[v.effect] = counts.get(v.effect, 0) + 1
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})"
for v in verdicts
if v.decision != "ran"
]
return f"\n\nVerdicts: {said}." + (
"\n" + "\n".join(notable[-DETAILS:]) if notable else ""
)
def _design_problem(self, item_id: str) -> str | None:
@@ -1783,6 +1929,11 @@ class LoopAgent:
},
model=roles.resolve("driver"),
on_turn=lambda said: self._progress(item.id, loop.thread, said),
screen=(
(lambda call: self._screen(item.id, str(workspace.root), call))
if workspace
else None
),
)
finally:
self._running = False
@@ -1794,6 +1945,29 @@ class LoopAgent:
assert item is not None
loop = loop_of(item)
args = outcome.arguments or {}
# An allow covers one run; a pending ask survives only a run that
# goes back to waiting on it (a question, or a reply to a remark).
keeps = loop.question or (
outcome.kind == "wait" and outcome.tool == "reply" and bool(loop.back_to)
)
loop = replace(
loop, allowed="", pending_action=loop.pending_action if keeps else ""
)
if outcome.kind == "wait" and outcome.tool == ASK_ALLOW:
question = str(args.get("question", ""))
self._fact(item, f"asked the operator: {question}")
self._transport.post(question, thread=loop.thread)
self._wait(
item,
replace(
loop,
wait=ANSWER,
note=question,
pending_action=str(args.get("key", "")),
),
None,
)
return
if loop.question:
# Back to the wait the question interrupted, as it was (L5).
if outcome.kind == "wait" and outcome.tool == "reply":