Files
openswarm/backend/apps/agents/core/models.py
T
ciregenzandClaude Opus 5 0f2543db65 [eric] agents: a finished turn always leaves the user something to read
Every silent-stop fix so far was a detector for a shape somebody had already
found in the field, which is why the class kept coming back wearing a new hat.
turn_spoke.py moves the question down a tier: at the one exit every terminal
path passes through, ask whether anything readable appeared since the user last
spoke, and if not, say the honest line. Cause no longer has to be enumerated for
the user to be answered.

proactive_prune.py is the hermes trigger we were missing. Their own tests say
our bug out loud: on a large window, a percentage-of-window compaction check
almost never fires, so aged tool output rides in history and is re-sent verbatim
every turn. Measured here, our shaping cut 0.0% at every session size; with a
fixed 60K-token cost trigger it cuts 88% at 12 turns, 93% at 30, 94% at 60. The
prompt-cache contract is load-bearing rather than optional, because our prune is
a rebuild: it commits only when it reclaims enough to pay for the busted prefix,
then disarms until history has regrown a full runway.

Also: lane preflight now treats only 401/403 as a dead credential, since
testStatus=="unavailable" conflated a throttled lane with a revoked one and told
users to reconnect a merely rate-limited Gemini; and awaiting_reconnect is
cleared when the retry budget is spent, so a stale flag can no longer muzzle the
floor and end an ask in total silence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wtspwSFzZmjCx9UNPAorQ
2026-08-20 11:36:34 -07:00

186 lines
12 KiB
Python

from pydantic import BaseModel, Field
from typing import Optional, Literal, Any
from datetime import datetime
from uuid import uuid4
class AgentConfig(BaseModel):
name: str = ""
# First-turn prompt. Launch used to silently DROP this (pydantic ignores unknown fields), leaving the session claiming "running" forever with zero messages and no error, the ENG-131 ghost hang.
prompt: Optional[str] = None
model: str = "sonnet"
mode: str = "agent"
provider: str = "anthropic"
system_prompt: Optional[str] = None
# None means "whatever the mode allows". A list is an actual restriction and IS enforced at launch, so it must stay None unless the caller really means to narrow the surface.
allowed_tools: Optional[list[str]] = None
max_turns: Optional[int] = None
target_directory: Optional[str] = None
dashboard_id: Optional[str] = None
workflow_run_id: Optional[str] = None
workflow_edit_id: Optional[str] = None
# App cards the user picked to edit. When exactly one resolves, launch binds the chat's cwd to that app instead of seeding a new "Untitled App".
selected_app_output_ids: Optional[list[str]] = None
# Onboarding auto-launches an audit over the user's REAL files with nobody watching, so it runs
# read-only: Read/Grep/Glob + Write (its one report) allowed, Edit/Bash/NotebookEdit hard-blocked
# so "modify or delete an existing file" is unrepresentable, not just discouraged by the prompt.
read_only: bool = False
class ApprovalRequest(BaseModel):
id: str = Field(default_factory=lambda: uuid4().hex)
session_id: str
tool_name: str
tool_input: dict[str, Any]
created_at: datetime = Field(default_factory=datetime.now)
# Set when this approval was triggered by the sensitive-path override rather than the user's normal "ask" policy. Three correlated fields: - sensitive_pattern: the fnmatch pattern (canonical id; what we persist into the trusted allowlist if the user opts in). - sensitive_label: short human label (e.g. "SSH folder (~/.ssh)"). - sensitive_why: plain-English risk explanation; lets the modal justify itself to a non-developer. All three None for ordinary "ask" approvals.
sensitive_pattern: Optional[str] = None
sensitive_label: Optional[str] = None
sensitive_why: Optional[str] = None
class ApprovalResponse(BaseModel):
request_id: str
behavior: Literal["allow", "deny"]
message: Optional[str] = None
updated_input: Optional[dict[str, Any]] = None
# When the user checked "Always allow files like this" on a sensitive- path approval, the backend persists the matched fnmatch pattern (from ApprovalRequest.sensitive_pattern) to disk so future writes against the same pattern skip the modal.
trust_pattern: bool = False
# "Always approve" button: persist this tool's policy to always_allow so the same tool stops prompting (the catastrophic/sensitive guards still fire, so this can't blanket-approve an rm -rf or a sensitive-path write).
set_always_allow: bool = False
class Message(BaseModel):
id: str = Field(default_factory=lambda: uuid4().hex)
role: Literal["user", "assistant", "tool_call", "tool_result", "system", "thinking"]
content: Any # str or list of content blocks
timestamp: datetime = Field(default_factory=datetime.now)
branch_id: str = "main"
parent_id: Optional[str] = None
context_paths: Optional[list[dict]] = None
attached_skills: Optional[list[dict]] = None
forced_tools: Optional[list[str]] = None
images: Optional[list[dict]] = None
hidden: bool = False
# Frontend-generated id for optimistic-bubble dedup against the server echo.
client_message_id: Optional[str] = None
# Wall-clock ms producing this message's content; for thinking, content_block_start -> stop. Lets reloaded bubbles show "Thought for Ns".
elapsed_ms: Optional[int] = None
# Approx output tokens; thinking uses char/3.6 to match the live UI's count. Display only.
tokens: Optional[int] = None
# Drives the "N tools used" segment on the thinking pill.
tool_count: Optional[int] = None
# Combined input + output + children tokens for the turn (overloaded name).
input_tokens: Optional[int] = None
class MessageBranch(BaseModel):
id: str = Field(default_factory=lambda: uuid4().hex)
parent_branch_id: Optional[str] = None
fork_point_message_id: Optional[str] = None
created_at: datetime = Field(default_factory=datetime.now)
class ToolGroupMeta(BaseModel):
id: str
name: str
svg: str = ""
is_refined: bool = False
class AgentSession(BaseModel):
id: str = Field(default_factory=lambda: uuid4().hex)
name: str
status: Literal["running", "waiting_approval", "completed", "error", "stopped"] = "running"
provider: str = "anthropic"
model: str = "sonnet"
mode: str = "agent"
sdk_session_id: Optional[str] = None
system_prompt: Optional[str] = None
allowed_tools: list[str] = Field(default_factory=list)
# Hard-block the mutation/exec tools for this session (onboarding's unattended audit); see AgentConfig.read_only.
read_only: bool = False
max_turns: Optional[int] = None
cwd: Optional[str] = None
# Resolved at session start so resume reattaches to the same repo even after the user cd's elsewhere.
repo_url: Optional[str] = None
branch: Optional[str] = None
created_at: datetime = Field(default_factory=datetime.now)
closed_at: Optional[datetime] = None
# Wall-clock of the first stream event so resumed sessions can show "first response at HH:MM" without rescan.
first_response_at: Optional[datetime] = None
# HITL approval log: {tool, behavior, decision_ms} per entry.
approval_decisions: list[dict] = Field(default_factory=list)
cost_usd: float = 0.0
tokens: dict[str, int] = Field(default_factory=lambda: {"input": 0, "output": 0})
# Total ms in status="running", accumulated across turns/resume; powers session-close "agent active time".
agent_active_ms: int = 0
# Per-model wall-clock ms; updated on model switch or close.
time_per_model: dict[str, int] = Field(default_factory=dict)
# Per-tool latency: { tool_name: { count, total_ms, max_ms } }.
tool_latencies: dict[str, dict] = Field(default_factory=dict)
browser_domains: list[str] = Field(default_factory=list)
messages: list[Message] = Field(default_factory=list)
pending_approvals: list[ApprovalRequest] = Field(default_factory=list)
branches: dict[str, "MessageBranch"] = Field(default_factory=lambda: {"main": MessageBranch(id="main")})
active_branch_id: str = "main"
tool_group_meta: dict[str, "ToolGroupMeta"] = Field(default_factory=dict)
dashboard_id: Optional[str] = None
browser_id: Optional[str] = None
parent_session_id: Optional[str] = None
# Set when this session IS a workflow run's agent; the run renders in the Workflows monitor card, so the canvas suppresses the duplicate standalone agent card.
workflow_run_id: Optional[str] = None
# Set when this session IS a workflow's embedded edit/compose chat; it lives in the Workflows hub window, so the canvas suppresses its standalone card and docks its browser below the hub.
workflow_edit_id: Optional[str] = None
workflow_test_state: Optional[Literal["running", "complete", "error"]] = None
# Browser memory signals, drive the subtle "remembered/learned" card chip so the user feels the agent getting smarter without lifting a finger.
memory_recalled: bool = False
memory_learned: bool = False
needs_fork: bool = False
# Stronger than needs_fork: drop resume= and replay history into a fresh sdk_session_id; fork_session alone won't re-read mcp_servers.
needs_fresh_session: bool = False
# Auto-continue: agent loop dispatches a hidden turn at end-of-loop using pending_continuation_prompt. Race-free vs background tasks.
pending_continuation: bool = False
pending_continuation_prompt: Optional[str] = None
# The final silent-quit nudge runs with ZERO tools, so "do not call any more tools" stops being a request the model can decline (ENG-291).
pending_continuation_toolless: bool = False
# Silent-quit nudges spent since the user's last real message; hard-capped so an agent that keeps ending empty can't loop.
empty_finish_nudges: int = 0
# Lifetime silent-quit count for the session; unlike the per-user-message nudge counter this never resets, so a REPEAT quit is distinguishable from a first (Haik's storm sessions logged 130+ quits each).
empty_finish_total: int = 0
# One transparent expired-token retry per user ask; the second failure earns the honest banner (ENG-294).
auth_retry_used: bool = False
# Tool-call count at the last nudge: a re-nudge is only earned by NEW tool work since then.
empty_finish_progress_mark: int = 0
# One honest "stopped without a report" line per exhausted nudge budget; resets with the budget.
empty_finish_surfaced: bool = False
# Content-policy self-heal (one shot per session): the provider blocked a recap-bearing turn, so the next send omits the recap.
policy_retry_used: bool = False
suppress_recap_once: bool = False
# Consecutive dirty deaths this session was MID-TURN for; the crash auto-resume breaker (hermes #30719 pairing: auto-resume must never outrun its circuit breaker).
crash_interrupt_count: int = 0
# Outage rounds spent on this ask: the in-turn ladder covers only 335s, and the work is checkpointed, so a longer drop is waited out rather than ending the task.
# True when the preflight found the router had already given up on this lane, so an auth failure this turn is a dead credential, not a rotation window worth waiting out.
# Input-token level history must regrow past before another proactive prune may commit; a rebuild busts the prompt cache, so one per runway, never one per turn.
proactive_prune_rearm_tokens: int = 0
lane_credential_dead: bool = False
reconnect_attempts: int = 0
# True while a turn is parked waiting for the connection back; persisted so a quit DURING the wait is still an owed turn at next boot.
awaiting_reconnect: bool = False
# Seconds the auto-continuation dispatcher sleeps before sending (codex rotation windows last 1-2 min; an instant retry lands inside the same window and burns the one-shot budget).
pending_continuation_delay_s: int = 0
# Memory prompt block frozen at first compose (prefix-cache discipline: mid-chat fact writes must
# not shift the prompt bytes). Excluded from persistence so a resumed session re-snapshots fresh.
memory_snapshot: Optional[str] = Field(default=None, exclude=True)
# Sanitized server names model has explicitly activated this session; _build_mcp_servers intersects connected MCPs with this. Non-bypassable; dispatch-layer gate.
active_mcps: list[str] = Field(default_factory=list)
# Heuristic preamble tokens (preset + tool defs + MCP descs + composed prompt); subtracted from displayed input.
framework_overhead_tokens: int = 0
# Live ctx_used ratio triggering _maybe_compact at the next turn boundary; turn-based thresholds break under uneven workloads. Ratio of context_window, so 0.65 means 650K on a 1M-window model and 130K on a 200K-window model.
compact_threshold_pct: float = 0.65
# Absolute token ceiling so big-window models don't sit at 650K before marking; the marker fires at the TIGHTER of the pct or this cap, so it's never "just 65%".
compact_abs_ceiling_tokens: int = 180_000
compacted_through_msg_id: Optional[str] = None
# Aux-LLM distilled summary of the turns dropped by compaction, cached against the cutoff id it was built for; keeps the gist of old history on a rebuild instead of a hard drop.
compacted_summary: Optional[str] = None
compacted_summary_through: Optional[str] = None
# Hard pre-send guard at 0.90; past compaction we LRU-trim active_mcps, then surface the overflow card.
context_soft_cap_pct: float = 0.90
# Conservative default. Always overwritten at session creation, restore, and model-switch via apply_context_window in agent_manager so the real model cap is used instead. Don't bump this without re-checking the trim/guard logic.
context_window: int = 200_000
# Provider-agnostic thinking level (off/low/medium/high/auto), translated per-API in agent_manager; only affects reasoning-flagged models.
thinking_level: Literal["off", "low", "medium", "high", "auto"] = "auto"