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
132 changes: 129 additions & 3 deletions core/agent_runtime/goal_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@

import threading
from dataclasses import dataclass
from typing import Any, Protocol
from typing import TYPE_CHECKING, Any, Protocol

if TYPE_CHECKING:
from core.agent_runtime.goal_verifier import (
GoalCompletionRequest,
GoalCompletionVerifier,
GoalVerdict,
)


GOAL_TOOL_NAMES = frozenset({"get_goal", "update_goal"})
Expand Down Expand Up @@ -45,20 +52,67 @@ def update_goal(
reason: str | None,
) -> dict[str, Any]: ...

def completion_request(
self,
context: GoalRuntimeContext,
*,
reason: str | None,
) -> GoalCompletionRequest:
"""Build the claim a configured verifier judges.

Only consulted while a completion verifier is configured; carries the
real objective and this Turn's existing evidence references.
"""
...


class GoalRuntimeRouter:
"""Route Goal tools only while one attributed Turn owns the runtime."""
"""Route Goal tools only while one attributed Turn owns the runtime.

When a completion verifier is configured (see
:meth:`configure_completion_verifier`), a ``complete`` request is judged
exactly once, asynchronously, before any durable update. A rejection is
returned to the model as data — with the verifier's justification — and
the goal is not updated. After ``max_rejections`` rejected claims the
router moves the goal to ``blocked`` itself, so a model that keeps
re-claiming completion cannot burn Turns forever.
"""

def __init__(self) -> None:
self._lock = threading.Lock()
self._handler: GoalRuntimeHandler | None = None
self._context: GoalRuntimeContext | None = None
self._terminal_requested = False
self._verifier: GoalCompletionVerifier | None = None
self._max_rejections = 3
# Rejected completion claims per goal, so a model cannot burn Turns
# retrying the same claim; cleared on success or auto-block.
self._rejections: dict[str, int] = {}

def configure(self, handler: GoalRuntimeHandler) -> None:
with self._lock:
self._handler = handler

def configure_completion_verifier(
self,
verifier: GoalCompletionVerifier,
*,
max_rejections: int = 3,
) -> None:
"""Arm the single asynchronous completion gate.

There is deliberately no way to pass a pre-computed verdict into
:meth:`request`; every ``complete`` claim goes through ``verifier``
exactly once.
"""

if max_rejections < 1:
raise ValueError("max_rejections must be positive")
with self._lock:
self._verifier = verifier
self._max_rejections = max_rejections
self._rejections.clear()

def activate(self, context: GoalRuntimeContext) -> None:
with self._lock:
if self._context is not None and self._context.turn_id != context.turn_id:
Expand All @@ -79,13 +133,33 @@ def read(self) -> dict[str, Any]:
handler, context = self._snapshot()
return handler.read_goal(context)

def request(
async def request(
self,
*,
status: str,
reason: str | None,
) -> dict[str, Any]:
"""Record one terminal decision, verifying a ``complete`` claim once."""

handler, context = self._snapshot()
with self._lock:
verifier = self._verifier

if status == "complete" and verifier is not None:
from core.agent_runtime.goal_verifier import GoalVerdict

try:
verdict = await verifier(
handler.completion_request(context, reason=reason)
)
except Exception as exc: # noqa: BLE001 - fail closed, report to the model
verdict = GoalVerdict(
confirmed=False,
justification=f"Completion verification failed: {exc}",
)
if not verdict.confirmed:
return self._reject_completion(handler, context, verdict)

result = handler.update_goal(
context,
status=status,
Expand All @@ -95,8 +169,60 @@ def request(
if self._context != context:
raise GoalRuntimeError("Goal Turn changed while recording the decision")
self._terminal_requested = True
self._rejections.pop(context.goal_id, None)
return result

def _reject_completion(
self,
handler: GoalRuntimeHandler,
context: GoalRuntimeContext,
verdict: GoalVerdict,
) -> dict[str, Any]:
"""Return a rejection to the model; auto-block after N rejections."""

with self._lock:
if self._context != context:
raise GoalRuntimeError("Goal Turn changed while recording the decision")
count = self._rejections.get(context.goal_id, 0) + 1
self._rejections[context.goal_id] = count
max_rejections = self._max_rejections
exhausted = count >= max_rejections

rejection: dict[str, Any] = {
"goalId": context.goal_id,
"updated": False,
"verdict": {
"confirmed": False,
"justification": verdict.justification,
},
"rejections": count,
"maxRejections": max_rejections,
}
if not exhausted:
return rejection

# The claim is exhausted: move the Goal to blocked (a terminal
# decision the model asked for by never producing evidence) instead
# of letting it burn Turns on the same rejected completion claim.
result = handler.update_goal(
context,
status="blocked",
reason=verdict.justification,
)
with self._lock:
if self._context != context:
raise GoalRuntimeError("Goal Turn changed while recording the decision")
self._terminal_requested = True
self._rejections.pop(context.goal_id, None)
return {
**result,
"updated": True,
"verdict": rejection["verdict"],
"rejections": count,
"maxRejections": max_rejections,
"autoBlocked": True,
}

def visible_tool_names(self, names: tuple[str, ...]) -> tuple[str, ...]:
"""Narrow tools dynamically without ever adding a capability."""

Expand Down
132 changes: 132 additions & 0 deletions core/agent_runtime/goal_verifier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""Independent verification of a Goal completion claim.

The verifier answers one question: does this Turn's evidence support the
model's claim that the Goal is complete? It is consulted exactly once per
completion request, asynchronously, inside the goal runtime — there is no
synchronous side door and no caller-supplied "already verified" flag.

Rejections are data, not exceptions. A rejected claim returns its
justification to the model so the model can either produce the missing
evidence or declare the Goal blocked; a verifier that raises or cannot be
asked fails closed (rejects). After a configured number of rejections the
goal runtime moves the Goal to ``blocked`` rather than letting the model
burn Turns retrying the same claim.
"""

from __future__ import annotations

from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Protocol


@dataclass(frozen=True, slots=True)
class GoalEvidenceItem:
"""One Turn evidence item, reduced to what a verifier judges."""

kind: str
status: str
summary: str


@dataclass(frozen=True, slots=True)
class GoalCompletionRequest:
"""The claim under review: the real objective plus Turn evidence."""

objective: str
reason: str
evidence: tuple[GoalEvidenceItem, ...]


@dataclass(frozen=True, slots=True)
class GoalVerdict:
"""The verifier's answer. ``justification`` always travels to the model."""

confirmed: bool
justification: str


class GoalCompletionVerifier(Protocol):
"""An async callable that judges one completion claim."""

def __call__(self, request: GoalCompletionRequest) -> Awaitable[GoalVerdict]: ...


class RuleBasedVerifier:
"""Fail-closed structural checks over the objective, reason, and evidence.

This is the built-in verifier behind the ``rule`` setting. It does not
judge whether the work is *good*; it judges whether the claim is
*supported*: a completion claim with no objective, no stated reason, or
no recorded Turn evidence is rejected with a justification the model can
act on.
"""

async def __call__(self, request: GoalCompletionRequest) -> GoalVerdict:
if not request.objective.strip():
return GoalVerdict(
confirmed=False,
justification="The Goal has no objective to verify the completion claim against.",
)
if not request.reason.strip():
return GoalVerdict(
confirmed=False,
justification="The completion claim states no reason. Explain what was done before marking the Goal complete.",
)
if not request.evidence:
return GoalVerdict(
confirmed=False,
justification=(
"The Turn recorded no evidence (tool calls, file changes, or test "
"results) to support completion. Do the work first, then claim completion."
),
)
failed = [
item
for item in request.evidence
if item.status.strip().lower() in {"failed", "error"}
]
if failed:
return GoalVerdict(
confirmed=False,
justification=(
f"{len(failed)} of {len(request.evidence)} evidence item(s) failed "
f"(latest: {failed[-1].summary!r}). Resolve the failures before completing."
),
)
return GoalVerdict(
confirmed=True,
justification=(
f"{len(request.evidence)} Turn evidence item(s) support the claim."
),
)


def model_backed_verifier(
ask: Callable[[GoalCompletionRequest], Awaitable[str]],
) -> GoalCompletionVerifier:
"""Adapt a one-shot text answerer into a fail-closed verifier.

``ask`` returns the model's answer; anything other than an affirmative
answer rejects, and an ``ask`` that raises rejects too (fail closed).
"""

async def verify(request: GoalCompletionRequest) -> GoalVerdict:
try:
answer = (await ask(request)).strip().lower()
except Exception as exc: # noqa: BLE001 - fail closed, report to the model
return GoalVerdict(
confirmed=False,
justification=f"The completion verifier failed: {exc}",
)
confirmed = answer.startswith("yes")
return GoalVerdict(
confirmed=confirmed,
justification=(
"The model verifier confirmed the evidence supports completion."
if confirmed
else "The model verifier rejected the completion claim."
),
)

return verify
9 changes: 9 additions & 0 deletions core/agent_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,15 @@ def build_agent_session(
mode_override=permission_mode_override,
profile_override=execution_security_profile,
)
if goal_runtime is not None:
goal_cfg = getattr(active_runtime.config, "goal", None)
if goal_cfg is not None and goal_cfg.completion_verifier == "rule":
from core.agent_runtime.goal_verifier import RuleBasedVerifier

goal_runtime.configure_completion_verifier(
RuleBasedVerifier(),
max_rejections=goal_cfg.max_rejections,
)
engine = build_permission_engine(
security_cfg,
cwd=workspace,
Expand Down
Loading
Loading