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

+16
View File
@@ -82,6 +82,20 @@ class Fact:
text: str
@dataclass(frozen=True)
class ActionVerdict:
"""What the fast path made of a tool call before it ran (P4, L4): a
record for the log, the operator and the classifier's evaluation set,
never shown to a model."""
call_id: str
tool: str
effect: str # harmless | contained | unknown | dangerous | refused
rule: str
reason: str
decision: str # ran | refused | asked | allowed
@dataclass(frozen=True)
class Compaction:
summary: str
@@ -99,6 +113,7 @@ CanonicalEvent = (
| DelegationStarted
| DelegationCompleted
| Fact
| ActionVerdict
| Compaction
)
@@ -112,6 +127,7 @@ EVENT_TYPES: dict[str, type] = {
"delegation_started": DelegationStarted,
"delegation_completed": DelegationCompleted,
"fact": Fact,
"action_verdict": ActionVerdict,
"compaction": Compaction,
}
+13
View File
@@ -175,8 +175,11 @@ class Delegation:
cancel: Cancel | None = None,
reader: str = "",
on_turn: Callable[[str], None] | None = None,
screen: Callable[[ToolCall], str | None] | None = None,
) -> None:
self._client = client
# The fast path (P4): a reason not to run an execution call, or None.
self._screen = screen
self._store = store
self._workspace = workspace
# Who acts on the handback, from its capability profile (L9): never
@@ -492,6 +495,16 @@ class Delegation:
tokens=spent,
)
refused = self._screen(call) if self._screen else None
if refused is not None:
transcript.append(
ToolResult(
call_id=call.call_id,
result=f"not run: {refused}",
success=False,
)
)
continue
outcome = self._workspace.dispatch(call.tool, call.arguments)
if call.tool in MUTATING_TOOLS and outcome.success:
did_work = True
+5
View File
@@ -49,6 +49,11 @@ def _event_line(kind: str, payload: dict[str, Any], full: bool) -> str:
return f"PLAN ({payload.get('model', '')}, {len(plan)} chars): {_clip(plan, full)}"
case "fact":
return f"FACT: {_clip(payload['text'], full)}"
case "action_verdict":
return (
f" VERDICT {payload.get('effect', '')} ({payload.get('rule', '')}), "
f"{payload.get('decision', '')}: {payload.get('reason', '')}"
)
case "gate_opened":
return f"GATE {payload.get('gate', '')}: {payload.get('reason', '')}"
case _:
+359
View File
@@ -0,0 +1,359 @@
"""nisse-dev's fast-path rules (architecture D6, P4): what its tool calls
do, judged by code before anything runs.
- **Harmless:** reading the repository, running builds, tests and linters on
the build VM, querying.
- **Contained:** editing the workspace, local commits, installing on the build
VM and otherwise changing it (D3).
- **Dangerous:** anything reaching beyond the item's repository or build VM
(pushing, other hosts, uploads, publishing, the forge's own tools), deleting
outside the workspace, or wrecking the build VM itself. Discussed with the
operator first.
- **Refused:** changing branches by hand. The harness owns branches (D4), so
the model is told to use its milestone tools instead.
- **Unknown:** everything else, for the classifier (M3.2); until then it runs,
and is logged.
A rule never makes a command less severe than its worst part; what can't be
read statically is unknown (nisse_core.actions).
"""
import posixpath
from collections.abc import Callable
from nisse_core.actions import (
CONTAINED,
DANGEROUS,
HARMLESS,
REFUSED,
UNKNOWN,
Action,
Command,
Verdict,
classify_shell,
inside,
resolve,
worst,
)
READING_TOOLS = frozenset({"read_file", "list_dir"})
_READ = frozenset(
{
"ls", "cat", "head", "tail", "wc", "grep", "egrep", "fgrep", "rg", "echo",
"printf", "pwd", "which", "file", "stat", "diff", "sort", "uniq", "tree",
"du", "df", "true", "false", "test", "[", "awk", "cut", "tr", "jq",
"date", "uname", "whoami", "id", "basename", "dirname", "realpath",
"readlink", "less", "more", "nl", "column", "xxd", "od", "sha256sum",
"md5sum", "printenv", "type", "hostname", "ps", "free", "uptime",
"nproc", "seq", "sleep", "cmp", "comm", "env",
}
) # fmt: skip
_CHECKS = frozenset(
{
"pytest", "py.test", "ruff", "mypy", "pyright", "black", "isort",
"flake8", "pylint", "tox", "nox", "coverage", "eslint", "prettier",
"tsc", "jest", "vitest", "shellcheck", "gofmt",
}
) # fmt: skip
_CHANGES = frozenset(
{
"mkdir", "touch", "cp", "mv", "chmod", "chown", "ln", "tee", "truncate",
"patch", "tar", "unzip", "gzip", "gunzip", "zip", "kill", "pkill",
"systemctl", "service", "crontab", "useradd", "groupadd",
}
) # fmt: skip
_DELETES = frozenset({"rm", "rmdir", "unlink", "shred"})
_OTHER_HOSTS = frozenset(
{"ssh", "scp", "sftp", "telnet", "nc", "ncat", "netcat", "ftp", "mosh"}
)
_FORGE_TOOLS = frozenset({"gh", "tea", "hub", "glab", "twine"})
_WRECKS_THE_VM = frozenset(
{
"dd", "fdisk", "parted", "shutdown", "reboot", "halt", "poweroff",
"iptables", "ip6tables", "nft", "ufw", "mount", "umount",
}
) # fmt: skip
_PACKAGE_MANAGERS = frozenset({"apt", "apt-get", "dnf", "yum", "apk", "zypper"})
_TEST_TARGETS = frozenset({"", "test", "tests", "check", "lint", "build", "all", "fmt"})
_NPM_CHECKS = frozenset({"test", "lint", "build", "check", "typecheck", "format"})
_BRANCHES = (
"the harness owns branches: work stays on the milestone's branch, and "
"start_milestone and finish_milestone make and publish it"
)
def classify(action: Action, root: str) -> Verdict:
"""The fast path's verdict on one execution tool call in the workspace
at ``root``."""
if action.tool in READING_TOOLS:
return Verdict(HARMLESS, "read", "reads the workspace")
if action.tool == "write_file":
# Writes outside the workspace are refused by the workspace itself.
return Verdict(CONTAINED, "workspace-edit", "edits the workspace")
if action.tool == "run_command":
line = str(action.arguments.get("command", ""))
return classify_shell(line, root, lambda c: judge(c, root))
return Verdict(UNKNOWN, "no-rule", f"no rule for {action.tool}")
def judge(command: Command, root: str) -> Verdict:
"""One simple command, and what its redirections write."""
verdicts = [_program(command, root)]
for target in command.redirects:
if target not in ("/dev/null", "/dev/stdout", "/dev/stderr"):
verdicts.append(Verdict(CONTAINED, "writes-file", f"writes {target}"))
return worst(verdicts)
def _program(command: Command, root: str) -> Verdict:
argv = list(command.argv)
name = argv[0]
if "/" in name:
if name.startswith(("/usr/", "/bin/", "/sbin/")):
name = posixpath.basename(name) # /usr/bin/git is git
else:
return Verdict(UNKNOWN, "script", f"runs {name}")
args = argv[1:]
if name.startswith("mkfs"):
name = "dd"
if name in _RULES:
return _RULES[name](args, command, root)
if name in _READ:
return Verdict(HARMLESS, "read-only", f"{name} only reads")
if name in _CHECKS:
return Verdict(HARMLESS, "checks", f"{name} checks the code")
if name in _CHANGES:
return Verdict(CONTAINED, "changes-vm", f"{name} changes files on the build VM")
if name in _DELETES:
return _deletes(name, args, command, root)
if name in _OTHER_HOSTS:
return Verdict(DANGEROUS, "other-host", f"{name} reaches another host")
if name in _FORGE_TOOLS:
return Verdict(
DANGEROUS, "forge-tool", f"{name} acts on a forge or index directly"
)
if name in _WRECKS_THE_VM:
return Verdict(
DANGEROUS, "wrecks-vm", f"{name} can break the build VM or its network"
)
if name in _PACKAGE_MANAGERS:
return Verdict(CONTAINED, "install", "installs on the build VM")
return Verdict(UNKNOWN, "no-rule", f"no rule for {name}")
def _deletes(name: str, args: list[str], command: Command, root: str) -> Verdict:
targets = [a for a in args if not a.startswith("-")]
outside = [t for t in targets if not inside(resolve(t, command.cwd), root)]
if outside:
return Verdict(
DANGEROUS,
"deletes-outside",
f"{name} deletes outside the workspace: {', '.join(outside)}",
)
return Verdict(CONTAINED, "deletes-inside", f"{name} deletes in the workspace")
def _git(args: list[str], command: Command, root: str) -> Verdict:
while args and args[0] in ("-C", "-c"):
args = args[2:] # global options and their values
sub, rest = (args[0], args[1:]) if args else ("", [])
flags = [a for a in rest if a.startswith("-")]
names = [a for a in rest if not a.startswith("-")]
if sub in ("push", "send-email", "request-pull"):
return Verdict(DANGEROUS, "git-push", "pushes beyond the workspace")
if sub == "remote" and names and names[0] in (
"add", "set-url", "remove", "rm", "rename",
): # fmt: skip
return Verdict(DANGEROUS, "git-remote", "changes where the clone pushes")
if sub in ("switch", "merge", "rebase", "pull") or (
sub == "worktree" and names[:1] == ["add"]
):
return Verdict(REFUSED, "branches", _BRANCHES)
if sub == "checkout":
if "--" in rest or (names and all(n == "." for n in names)):
return Verdict(CONTAINED, "git-local", "restores files in the workspace")
return Verdict(REFUSED, "branches", _BRANCHES + "; git restore puts files back")
if sub == "branch":
listing = {"-a", "-r", "-v", "-vv", "--list", "-l", "--show-current"}
if not names and set(flags) <= listing:
return Verdict(HARMLESS, "git-read", "lists branches")
if flags and set(flags) & {"--contains", "--merged", "--no-merged"}:
return Verdict(HARMLESS, "git-read", "lists branches")
return Verdict(REFUSED, "branches", _BRANCHES)
if sub in (
"status", "diff", "log", "show", "blame", "rev-parse", "ls-files",
"grep", "describe", "shortlog", "reflog", "rev-list", "cat-file",
"ls-tree", "whatchanged", "version", "help",
): # fmt: skip
return Verdict(HARMLESS, "git-read", f"git {sub} only reads")
if sub == "config" and (not names or set(flags) & {"--get", "--list", "-l"}):
return Verdict(HARMLESS, "git-read", "reads git configuration")
if sub == "remote" and not names:
return Verdict(HARMLESS, "git-read", "lists remotes")
if sub == "tag" and not names:
return Verdict(HARMLESS, "git-read", "lists tags")
if sub == "stash" and names[:1] in (["list"], ["show"]):
return Verdict(HARMLESS, "git-read", "lists stashes")
if sub in (
"add", "commit", "restore", "rm", "mv", "reset", "clean", "stash",
"apply", "tag", "init", "fetch", "cherry-pick", "revert", "notes", "am",
"config",
): # fmt: skip
return Verdict(CONTAINED, "git-local", f"git {sub} changes the local clone")
return Verdict(UNKNOWN, "no-rule", f"no rule for git {sub}")
def _python(args: list[str], command: Command, root: str) -> Verdict:
if args[:1] in (["--version"], ["-V"]):
return Verdict(HARMLESS, "read-only", "prints the version")
if args[:1] == ["-m"] and len(args) > 1:
return judge(Command(tuple(args[1:]), command.cwd), root)
return Verdict(UNKNOWN, "script", "runs Python code")
def _runner(args: list[str], command: Command, root: str) -> Verdict:
"""uv, poetry and friends: `run X` is X; installing changes the VM."""
sub = args[0] if args else ""
if sub == "run" and len(args) > 1:
rest = args[1:]
while rest and rest[0].startswith("-"):
rest = rest[1:]
if rest:
return judge(Command(tuple(rest), command.cwd), root)
if sub == "publish":
return Verdict(DANGEROUS, "publish", "publishes a package")
if sub in ("pip", "add", "remove", "sync", "lock", "venv", "install", "update"):
return Verdict(CONTAINED, "install", "installs on the build VM")
if sub in ("--version", "version", "tree", "show"):
return Verdict(HARMLESS, "read-only", "only reads")
return Verdict(UNKNOWN, "no-rule", f"no rule for that {sub}")
def _pip(args: list[str], command: Command, root: str) -> Verdict:
sub = args[0] if args else ""
if sub in ("install", "uninstall", "download"):
return Verdict(CONTAINED, "install", "installs on the build VM")
if sub in ("list", "show", "freeze", "check", "--version"):
return Verdict(HARMLESS, "read-only", "only reads")
return Verdict(UNKNOWN, "no-rule", f"no rule for pip {sub}")
def _npm(args: list[str], command: Command, root: str) -> Verdict:
sub = args[0] if args else ""
if sub == "publish":
return Verdict(DANGEROUS, "publish", "publishes a package")
if sub in ("test", "t") or (sub == "run" and args[1:2] and args[1] in _NPM_CHECKS):
return Verdict(HARMLESS, "checks", "runs the project's checks")
if sub in ("install", "i", "ci", "add", "remove", "uninstall", "update"):
return Verdict(CONTAINED, "install", "installs on the build VM")
if sub in ("ls", "list", "view", "outdated", "--version"):
return Verdict(HARMLESS, "read-only", "only reads")
return Verdict(UNKNOWN, "no-rule", f"no rule for {sub or 'that'}")
def _make(args: list[str], command: Command, root: str) -> Verdict:
targets = [a for a in args if not a.startswith("-") and "=" not in a]
if all(t in _TEST_TARGETS for t in targets):
return Verdict(HARMLESS, "checks", "builds or checks the code")
return Verdict(UNKNOWN, "no-rule", f"make {' '.join(targets)}")
def _cargo_go(args: list[str], command: Command, root: str) -> Verdict:
sub = args[0] if args else ""
if sub == "publish":
return Verdict(DANGEROUS, "publish", "publishes a package")
if sub in ("test", "build", "check", "clippy", "fmt", "vet", "doc", "version"):
return Verdict(HARMLESS, "checks", "builds or checks the code")
if sub in ("add", "fetch", "get", "mod", "install", "update"):
return Verdict(CONTAINED, "install", "installs on the build VM")
return Verdict(UNKNOWN, "no-rule", f"no rule for {sub or 'that'}")
_UPLOADS = (
"-T", "--upload-file", "-d", "--data", "--data-raw", "--data-binary",
"--data-urlencode", "-F", "--form", "--json", "--post-data", "--post-file",
"--body-data", "--body-file",
) # fmt: skip
def _fetches(args: list[str], command: Command, root: str) -> Verdict:
"""curl and wget: sending data out is dangerous, fetching is not."""
for i, arg in enumerate(args):
if arg.startswith(_UPLOADS) or arg.startswith("--method"):
return Verdict(DANGEROUS, "upload", "sends data to another host")
method = args[i + 1].upper() if i + 1 < len(args) else ""
if arg in ("-X", "--request") and method not in ("GET", "HEAD"):
return Verdict(DANGEROUS, "upload", "sends data to another host")
return Verdict(CONTAINED, "download", "downloads to the build VM")
def _docker(args: list[str], command: Command, root: str) -> Verdict:
sub = args[0] if args else ""
if sub in ("push", "login"):
return Verdict(DANGEROUS, "publish", f"docker {sub} reaches a registry")
if sub in ("ps", "images", "inspect", "logs", "version", "info"):
return Verdict(HARMLESS, "read-only", "only reads")
if sub in ("build", "pull"):
return Verdict(CONTAINED, "changes-vm", "builds on the build VM")
return Verdict(UNKNOWN, "no-rule", f"no rule for docker {sub}")
def _rsync(args: list[str], command: Command, root: str) -> Verdict:
if any(":" in a and not a.startswith("-") for a in args):
return Verdict(DANGEROUS, "other-host", "rsync reaches another host")
return Verdict(CONTAINED, "changes-vm", "copies files on the build VM")
def _sed(args: list[str], command: Command, root: str) -> Verdict:
if any(a.startswith("-i") or a == "--in-place" for a in args):
return Verdict(CONTAINED, "changes-vm", "edits files in place")
return Verdict(HARMLESS, "read-only", "sed only reads")
def _find(args: list[str], command: Command, root: str) -> Verdict:
if set(args) & {"-delete", "-exec", "-execdir", "-ok", "-okdir"}:
return Verdict(UNKNOWN, "no-rule", "find runs or deletes things")
return Verdict(HARMLESS, "read-only", "find only reads")
def _interpreter(args: list[str], command: Command, root: str) -> Verdict:
if args[:1] in (["--version"], ["-v"], ["-V"]):
return Verdict(HARMLESS, "read-only", "prints the version")
return Verdict(UNKNOWN, "script", "runs a program")
def _dangerous_vm(args: list[str], command: Command, root: str) -> Verdict:
return Verdict(DANGEROUS, "wrecks-vm", "can break the build VM")
Rule = Callable[[list[str], Command, str], Verdict]
_RULES: dict[str, Rule] = {
"git": _git,
"python": _python,
"python3": _python,
"uv": _runner,
"poetry": _runner,
"pipenv": _runner,
"pip": _pip,
"pip3": _pip,
"npm": _npm,
"yarn": _npm,
"pnpm": _npm,
"make": _make,
"cargo": _cargo_go,
"go": _cargo_go,
"curl": _fetches,
"wget": _fetches,
"docker": _docker,
"rsync": _rsync,
"sed": _sed,
"find": _find,
"node": _interpreter,
"ruby": _interpreter,
"perl": _interpreter,
"dd": _dangerous_vm,
}
+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":
+54 -1
View File
@@ -36,6 +36,22 @@ class RunOutcome:
note: str = ""
# The waiting outcome a dangerous action ends a run with (P3): the harness
# puts it to the operator, rendered from the action itself.
ASK_ALLOW = "ask_allow"
@dataclass(frozen=True)
class Screening:
"""What the fast path says about an execution call before it runs:
``run`` it, ``refuse`` it (the result says why, and the run goes on), or
``ask`` the operator (the run ends, waiting)."""
decision: Literal["run", "refuse", "ask"]
text: str = "" # the call's result when not run; for ask, the question
key: str = "" # for ask: what an allow would bind to (L3)
class Driver:
def __init__(
self,
@@ -64,11 +80,14 @@ class Driver:
checks: Mapping[str, Callable[[dict[str, Any]], str | None]] | None = None,
model: str | None = None,
on_turn: Callable[[str], None] | None = None,
screen: Callable[[ToolCall], Screening] | None = None,
) -> RunOutcome:
"""``handlers`` answer control tools that don't end the run (e.g.
start_milestone): each returns a result and whether it succeeded.
``checks`` vet a waiting tool's arguments: a problem is the call's
failed result and the run goes on, so the model puts it right."""
failed result and the run goes on, so the model puts it right.
``screen`` judges each execution call before it reaches the build VM
(P4)."""
handlers = handlers or {}
checks = checks or {}
model = model or self._model # the work item's pinned driver (L9)
@@ -143,6 +162,40 @@ class Driver:
False,
)
else:
screening = screen(call) if screen else Screening("run")
if screening.decision == "ask":
transcript.append(
ToolResult(
call_id=call.call_id,
result="not run: it needs the operator's agreement, "
"and they've been asked; your turn has ended",
success=False,
)
)
skip_calls(
transcript,
calls[index + 1 :],
"not run: the turn had ended",
)
return RunOutcome(
"wait",
ASK_ALLOW,
{"question": screening.text, "key": screening.key},
)
if screening.decision == "refuse":
transcript.append(
ToolResult(
call_id=call.call_id,
result=f"not run: {screening.text}",
success=False,
)
)
skip_calls(
transcript,
calls[index + 1 :],
"not run: fix the call above",
)
break
outcome = workspace.dispatch(call.tool, call.arguments)
result, ok = outcome.result, outcome.success
transcript.append(
+5
View File
@@ -69,6 +69,11 @@ class Loop:
asked_on_pr: int = 0
asked_where: str = ""
asked_text: str = ""
# A dangerous action put to the operator (P3): what `allow` would let
# run, and what it did let run, once, in the next run (L3: bound to
# exactly that action, by its key).
pending_action: str = ""
allowed: str = ""
def to_data(self) -> dict[str, Any]:
return asdict(self)
+3
View File
@@ -20,6 +20,7 @@ ranges still line up with the log. Everything not the viewer's own becomes a
from dataclasses import replace
from nisse_dev.canonical import (
ActionVerdict,
AssistantText,
DelegationCompleted,
DelegationStarted,
@@ -86,6 +87,8 @@ def _narration(call: ToolCall, result: ToolResult | None) -> str:
def project(events: list[StoredEvent], viewer: str) -> list[StoredEvent]:
"""The events as ``viewer`` should be shown them, in log order."""
# Verdicts are records about calls, never part of any role's context.
events = [e for e in events if not isinstance(e.event, ActionVerdict)]
shown = _SHOWN.get(viewer)
if shown is None:
# A role with no view defined (the expert is stateless, the