[eric] agents: convention fixes on merge surface (public cross-file helpers, pydantic approval memory, resolve_ask @typechecked)

This commit is contained in:
ciregenz
2026-06-24 22:36:17 -07:00
parent e221cb1c49
commit 6561dd6743
8 changed files with 49 additions and 48 deletions
@@ -101,7 +101,7 @@ def build_effective_tool_lists(
effective_disallowed.append(wt_name)
# Claude's internal Cron* scheduler is denied in favour of the visible native
# one; withhold it from the SDK so the model doesn't even reach for it.
for bt in path_gate.p_CLAUDE_INTERNAL_SCHEDULER_TOOLS:
for bt in path_gate.CLAUDE_INTERNAL_SCHEDULER_TOOLS:
if bt not in effective_disallowed:
effective_disallowed.append(bt)
return effective_allowed, effective_disallowed
@@ -23,9 +23,9 @@ from backend.apps.agents.manager.prompt.prompt_context import (
)
from backend.apps.agents.manager.streaming.HookContext import HookContext
from backend.apps.agents.manager.permissions.workflow_approval import (
p_is_claude_schedule_skill,
p_note_tool_used,
p_resolve_ask,
is_claude_schedule_skill,
note_tool_used,
resolve_ask,
)
logger = logging.getLogger(__name__)
@@ -35,8 +35,8 @@ logger = logging.getLogger(__name__)
async def can_use_tool(
ctx: HookContext, tool_name: str, input_data: object, context: object
) -> Union[PermissionResultAllow, PermissionResultDeny]:
if p_is_claude_schedule_skill(tool_name, input_data):
p_note_tool_used(ctx.session_id, tool_name, False)
if is_claude_schedule_skill(tool_name, input_data):
note_tool_used(ctx.session_id, tool_name, False)
return PermissionResultDeny(
message="Use the openswarm-schedule MCP tools instead of Claude's internal schedule skill."
)
@@ -46,13 +46,13 @@ async def can_use_tool(
effective_policy(tool_name, ctx.builtin_perms, ctx.policy_defaults), tool_name, input_data
)
if policy == "always_allow":
p_note_tool_used(ctx.session_id, tool_name, True)
note_tool_used(ctx.session_id, tool_name, True)
return PermissionResultAllow(updated_input=input_data)
if policy == "deny":
p_note_tool_used(ctx.session_id, tool_name, False)
note_tool_used(ctx.session_id, tool_name, False)
return PermissionResultDeny(message="Tool denied by permission policy")
decision = await p_resolve_ask(ctx, tool_name, input_data, sensitive_pattern)
decision = await resolve_ask(ctx, tool_name, input_data, sensitive_pattern)
if decision.behavior == "allow":
return PermissionResultAllow(
updated_input=decision.updated_input if decision.updated_input is not None else input_data
@@ -136,8 +136,8 @@ async def pre_tool_hook(ctx: HookContext, input_data: dict, tool_use_id: Optiona
if tool_name and tool_name != "AskUserQuestion":
tool_input = input_data.get("tool_input", {})
if p_is_claude_schedule_skill(tool_name, tool_input):
p_note_tool_used(ctx.session_id, tool_name, False)
if is_claude_schedule_skill(tool_name, tool_input):
note_tool_used(ctx.session_id, tool_name, False)
return {
"hookSpecificOutput": {
"hookEventName": hook_event,
@@ -150,10 +150,10 @@ async def pre_tool_hook(ctx: HookContext, input_data: dict, tool_use_id: Optiona
)
if policy == "always_allow":
p_note_tool_used(ctx.session_id, tool_name, True)
note_tool_used(ctx.session_id, tool_name, True)
if policy == "deny":
p_note_tool_used(ctx.session_id, tool_name, False)
note_tool_used(ctx.session_id, tool_name, False)
return {
"hookSpecificOutput": {
"hookEventName": hook_event,
@@ -163,7 +163,7 @@ async def pre_tool_hook(ctx: HookContext, input_data: dict, tool_use_id: Optiona
}
if policy == "ask":
decision = await p_resolve_ask(ctx, tool_name, tool_input, sensitive_pattern)
decision = await resolve_ask(ctx, tool_name, tool_input, sensitive_pattern)
if decision.behavior == "allow":
if tool_use_id:
@@ -170,7 +170,7 @@ p_SCHEDULE_GATED = {
"mcp__openswarm-schedule__DeleteScheduledWorkflow",
"mcp__openswarm-schedule__PauseAllWorkflows",
}
p_CLAUDE_INTERNAL_SCHEDULER_TOOLS = ("CronCreate", "CronList", "CronDelete")
CLAUDE_INTERNAL_SCHEDULER_TOOLS = ("CronCreate", "CronList", "CronDelete")
@typechecked
@@ -182,7 +182,7 @@ def maybe_override_policy(policy: str, tool_name: str, tool_input: object) -> Tu
future writes to it pass through silently."""
if tool_name == "Bash" and looks_like_os_scheduling(tool_input):
return "ask", None
if tool_name in p_CLAUDE_INTERNAL_SCHEDULER_TOOLS:
if tool_name in CLAUDE_INTERNAL_SCHEDULER_TOOLS:
return "deny", None
# Committing or mutating a native recurring schedule is the in-app twin of the
# crontab gate above: real, user-visible, hard-to-undo, so it goes through
@@ -10,6 +10,7 @@ agent_manager re-exports the setters for the executor's convenience.
import logging
from typing import Callable, Dict, Optional
from pydantic import BaseModel, ConfigDict
from typeguard import typechecked
from backend.apps.agents.manager.permissions.ApprovalDecision import ApprovalDecision
@@ -19,23 +20,18 @@ from backend.apps.agents.manager.streaming.HookContext import HookContext
logger = logging.getLogger(__name__)
class WorkflowApprovalMemory:
class WorkflowApprovalMemory(BaseModel):
"""A workflow run's approval context, pushed in by the executor."""
def __init__(
self,
decisions: Dict[str, str],
step_usage: Dict[str, Dict[str, bool]],
remember: Optional[Callable[[str, str], None]],
ask_timeout: float,
) -> None:
self.decisions = decisions # workflow-level: tool -> "allow"/"deny"
self.step_usage = step_usage # per-step record: step_id -> {tool: approved}
self.remember = remember # persist a workflow-level decision to disk
self.ask_timeout = ask_timeout
# The executor bumps this as it advances steps so the gate can record
# which tools each step touched. None on test runs that don't thread it.
self.current_step_id: Optional[str] = None
model_config = ConfigDict(validate_assignment=True)
decisions: Dict[str, str] # workflow-level: tool -> "allow"/"deny"
step_usage: Dict[str, Dict[str, bool]] # per-step record: step_id -> {tool: approved}
remember: Optional[Callable[[str, str], None]] # persist a workflow-level decision to disk
ask_timeout: float
# The executor bumps this as it advances steps so the gate can record which
# tools each step touched. None on test runs that don't thread it.
current_step_id: Optional[str] = None
p_approval_memory: Dict[str, WorkflowApprovalMemory] = {}
@@ -51,7 +47,7 @@ def set_workflow_approval_memory(
ask_timeout: float,
) -> None:
p_approval_memory[session_id] = WorkflowApprovalMemory(
decisions, step_usage, remember, ask_timeout
decisions=decisions, step_usage=step_usage, remember=remember, ask_timeout=ask_timeout
)
@@ -74,14 +70,14 @@ def get_workflow_step_usage(session_id: str) -> Dict[str, Dict[str, bool]]:
@typechecked
def p_is_claude_schedule_skill(tool_name: str, tool_input: object) -> bool:
def is_claude_schedule_skill(tool_name: str, tool_input: object) -> bool:
if tool_name != "Skill" or not isinstance(tool_input, dict):
return False
return str(tool_input.get("skill") or "").strip().lower() == "schedule"
@typechecked
def p_note_tool_used(session_id: str, tool_name: str, approved: bool) -> None:
def note_tool_used(session_id: str, tool_name: str, approved: bool) -> None:
# Record which tools each step touched (in-memory; the executor/test path
# persists step_usage once at run end). Captures every tool the gate sees so
# a step's tool set is complete, not only the ones that prompted.
@@ -91,7 +87,8 @@ def p_note_tool_used(session_id: str, tool_name: str, approved: bool) -> None:
mem.step_usage.setdefault(mem.current_step_id, {})[tool_name] = approved
async def p_resolve_ask(
@typechecked
async def resolve_ask(
ctx: HookContext, tool_name: str, tool_input: object, sensitive_pattern: Optional[str]
) -> ApprovalDecision:
"""Resolve an 'ask' policy. On a workflow run, reuse a remembered decision
@@ -114,10 +111,10 @@ async def p_resolve_ask(
return ApprovalDecision(behavior="deny", message="Denied by a remembered workflow permission")
prior = mem.decisions.get(tool_name)
if prior == "allow":
p_note_tool_used(ctx.session_id, tool_name, True)
note_tool_used(ctx.session_id, tool_name, True)
return ApprovalDecision(behavior="allow")
if prior == "deny":
p_note_tool_used(ctx.session_id, tool_name, False)
note_tool_used(ctx.session_id, tool_name, False)
return ApprovalDecision(behavior="deny", message="Denied by a remembered workflow permission")
timeout = mem.ask_timeout if mem is not None else 600.0
decision = await request_user_approval(
@@ -127,7 +124,7 @@ async def p_resolve_ask(
if rememberable and decision.behavior in ("allow", "deny"):
behavior = decision.behavior
mem.decisions[tool_name] = behavior
p_note_tool_used(ctx.session_id, tool_name, behavior == "allow")
note_tool_used(ctx.session_id, tool_name, behavior == "allow")
if mem.remember:
try:
mem.remember(tool_name, behavior)
@@ -34,7 +34,7 @@ def wrap_platform_note(body: str) -> str:
return f"{PLATFORM_NOTE_OPEN}\n{PLATFORM_NOTE_PREAMBLE}\n{body}\n{PLATFORM_NOTE_CLOSE}"
_SENTINEL_TAG_RE = re.compile(r"</?openswarm_(?:platform_note|session_recap)\b[^>]*>")
P_SENTINEL_TAG_RE = re.compile(r"</?openswarm_(?:platform_note|session_recap)\b[^>]*>")
def strip_forged_sentinels(text: str) -> str:
@@ -42,7 +42,7 @@ def strip_forged_sentinels(text: str) -> str:
user input) so attacker-supplied content can't pose as trusted platform context."""
if "openswarm_platform_note" not in text and "openswarm_session_recap" not in text:
return text
return _SENTINEL_TAG_RE.sub(lambda m: m.group(0).replace("<", "&lt;").replace(">", "&gt;"), text)
return P_SENTINEL_TAG_RE.sub(lambda m: m.group(0).replace("<", "&lt;").replace(">", "&gt;"), text)
def p_recap_tool_call_line(content: object) -> str: