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
6 changes: 6 additions & 0 deletions coworker/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ def build_engine(
# Persona-carried skill folders (OPE-58): the bundle's skills/ dir joins the loader so
# its skills are readable by load_skill, not just listed by the filter.
extra_skill_dirs: Optional[list[str | Path]] = None,
default_approval_ttl_seconds: Optional[float] = None,
) -> TurnEngine:
ws = Path(workspace).expanduser().resolve() if workspace else None
if agent.requires_folder and ws is None:
Expand Down Expand Up @@ -548,6 +549,11 @@ def context_provider() -> str:
tool_requester=tool_requester,
team_approver=team_approver,
items_approver=items_approver,
default_approval_ttl_seconds=(
default_approval_ttl_seconds
if default_approval_ttl_seconds is not None
else config.inbox_approval_ttl_seconds
),
)
engine.executor = executor # type: ignore[attr-defined]
engine.todo = todo # type: ignore[attr-defined]
Expand Down
3 changes: 3 additions & 0 deletions coworker/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ class Config:
cloud_relay_ws_url: str = (
"wss://l4z1paxb83.execute-api.us-east-1.amazonaws.com/ocw-connect"
)
# Default TTL (seconds) for unattended/parked approval items. None means no expiry.
inbox_approval_ttl_seconds: Optional[float] = None


_FIELDS = {
Expand All @@ -92,6 +94,7 @@ class Config:
"cloud_client_id",
"cloud_audience",
"cloud_relay_ws_url",
"inbox_approval_ttl_seconds",
}

# These fields change what consequential actions can run without a prompt, so the normal
Expand Down
34 changes: 31 additions & 3 deletions coworker/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class ApprovalOutcome(str, Enum):
# nothing persisted. EXTERNAL-risk tools only (validated server-side).
THIS_RUN = "this_run"
DENY = "deny"
EXPIRED = "expired"


def _readonly_ok(arguments: dict) -> bool:
Expand All @@ -78,6 +79,8 @@ class PermissionRequest:
# registration) — carried on the request so a PARKED approval shows the same
# destination evidence as the live card (§35 parity). None for non-MCP tools.
mcp_destination: Optional[dict] = None
expires_at: Optional[str] = None
ttl_seconds: Optional[float] = None


Approver = Callable[[PermissionRequest], Awaitable[ApprovalOutcome]]
Expand Down Expand Up @@ -123,12 +126,14 @@ def __init__(
# Called (thread-safe, best-effort) when the user stops the turn — e.g. the
# executor's kill for a running shell command.
interrupt_hooks: Optional[list[Callable[[], None]]] = None,
default_approval_ttl_seconds: Optional[float] = None,
) -> None:
self.provider = provider
self.registry = registry
self.permissions = permissions
self.model = model
self.approver = approver or _deny_all
self.default_approval_ttl_seconds = default_approval_ttl_seconds
self.max_iterations = max_iterations
self.model_settings = dict(model_settings or {})
self.messages: list[dict[str, Any]] = list(messages or [])
Expand Down Expand Up @@ -1131,6 +1136,7 @@ async def _authorize(self, tool_call: ToolCall) -> "AsyncIterator[Event | bool]"
)
allowed = decision.allowed
reason = decision.reason
outcome: Optional[ApprovalOutcome] = None

# OPE-114 §1: running something the agent DOWNLOADED this session is the classic
# fetch-then-execute chain, and there is no quiet legitimate version of it — so it
Expand Down Expand Up @@ -1352,11 +1358,30 @@ async def _authorize(self, tool_call: ToolCall) -> "AsyncIterator[Event | bool]"
if spec
else None
),
ttl_seconds=self.default_approval_ttl_seconds,
)
),
interrupted=ApprovalOutcome.DENY,
)
if outcome is ApprovalOutcome.DENY:
if outcome is ApprovalOutcome.EXPIRED:
allowed, reason = (
False,
"approval request expired (TTL elapsed)",
)
self._approval_origins[tool_call.id] = {
"origin": "timeout",
"grant": "expired",
**({"note": unsure_note} if unsure_note else {}),
}
self._audit(
tool_call,
stage="approval_resolved",
call_id=tool_call.id,
status="expired",
approval=outcome.value,
reason=reason,
)
elif outcome is ApprovalOutcome.DENY:
allowed, reason = (
False,
"interrupted by user" if self._cancel.is_set() else "denied by user",
Expand Down Expand Up @@ -1421,11 +1446,14 @@ async def _authorize(self, tool_call: ToolCall) -> "AsyncIterator[Event | bool]"
**({"approval_grant": origin["grant"]} if origin.get("grant") else {}),
}
self.messages.append(err_msg)
finish_status = (
"expired" if outcome is ApprovalOutcome.EXPIRED else "denied"
)
yield Event(
EventType.TOOL_FINISHED,
{"name": tool_call.name, "status": "denied", "reason": reason},
{"name": tool_call.name, "status": finish_status, "reason": reason},
)
self._audit(tool_call, stage="finished", status="denied", reason=reason)
self._audit(tool_call, stage="finished", status=finish_status, reason=reason)
yield False
return

Expand Down
Loading