Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b922311
fix(inbox): match approval keywords as whole words, not substrings
psssnikhil Jul 26, 2026
8cfd5b5
Gate workspace MCP config behind WorkspaceTrustStore.
HaoChiBao Jul 26, 2026
29adb8d
Polish workspace MCP trust gate: shared helper and tighter tests.
HaoChiBao Jul 26, 2026
ff86735
security: block loopback/private/metadata addresses in model-supplied…
Mr-Neutr0n Jul 28, 2026
028d42e
compaction: pure module + tests (OPE-27 1/4)
devikaverma Jul 29, 2026
f08a3c4
compaction: engine hook, failure policy, persistence (OPE-27 2/4)
devikaverma Jul 29, 2026
4fa8acf
compaction: Settings overrides + GUI divider (OPE-27 3/4)
devikaverma Jul 29, 2026
0bf9b87
compaction: repeated-compaction smoke through the manager (OPE-27 4/4)
devikaverma Jul 29, 2026
330010c
compaction: harden the smoke against per-turn event loops (OPE-27)
devikaverma Jul 29, 2026
b303823
Merge pull request #347 from andrewyng/review/ope-27
devikaverma Jul 30, 2026
f9f51c9
compaction: live progress signal + user-message cap
rohitprasad15 Jul 30, 2026
1e819e0
transcript: clamp long user messages with a more…/less… toggle
rohitprasad15 Jul 30, 2026
fe034c8
models: Kimi K3 via Together (1M window, vision); right-align the mor…
rohitprasad15 Jul 30, 2026
cca0421
Merge pull request #215 from HaoChiBao/security/workspace-mcp-trust-gate
rohitprasad15 Jul 30, 2026
5071451
Merge pull request #351 from andrewyng/rpCompactionPolish
rohitprasad15 Jul 30, 2026
6217dbc
mcp: global config wins on name clash with a trusted workspace
rohitprasad15 Jul 30, 2026
98445fe
Merge pull request #352 from andrewyng/rpMcpGlobalWins
rohitprasad15 Jul 30, 2026
38e1f03
Merge pull request #161 from psssnikhil/fix/inbox-reply-word-boundaries
rohitprasad15 Jul 30, 2026
7e69398
Merge pull request #290 from Mr-Neutr0n/security/block-ssrf-in-url-tools
rohitprasad15 Jul 30, 2026
e5c5699
security: block CGNAT range and guard browser_open_url
rohitprasad15 Jul 30, 2026
11d9f72
Merge pull request #353 from andrewyng/rpSsrfFollowup
rohitprasad15 Jul 30, 2026
25dc283
fix: stop artifact walk entering OS app-data dirs; context bar off by…
rohitprasad15 Jul 30, 2026
907752b
Merge pull request #354 from andrewyng/rpArtifactWalkAndContextBar
rohitprasad15 Jul 30, 2026
ae7256f
Prepare app release 0.1.7: version bump
rohitprasad15 Jul 30, 2026
bfabfaa
ci: build macOS Intel on macos-15-intel
rohitprasad15 Jul 30, 2026
e0cb129
Merge pull request #356 from andrewyng/rpMacIntelBuild
rohitprasad15 Jul 30, 2026
2f94626
Add SambaNova as an OpenAI-compatible provider
snova-kwasia Jul 30, 2026
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
8 changes: 5 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,13 @@ jobs:
fail-fast: false
matrix:
include:
# No Intel macOS target: macos-13 (the last Intel runner image) is deprecated and
# its queue waits run to hours, which blocks the release job. Intel Macs are
# 2020-and-earlier hardware — revisit only if beta users actually ask.
- os: macos-latest # Apple Silicon
slug: macos-arm64
# Intel macOS: macos-13 retired in Dec 2025; macos-15-intel replaced it and is
# the LAST x86_64 image Actions will offer (available until Aug 2027). Builds
# natively — the sidecar is a PyInstaller freeze, which cannot cross-compile.
- os: macos-15-intel
slug: macos-x64
- os: windows-latest
slug: windows
runs-on: ${{ matrix.os }}
Expand Down
561 changes: 561 additions & 0 deletions coworker/compaction.py

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions coworker/connectors/browser_automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

import aisuite as ai

from ..web.guard import check_url


def _meta(
name: str, *, approval: bool = False, capabilities: Optional[list[str]] = None
Expand Down Expand Up @@ -332,6 +334,12 @@ def browser_open_url(
) -> dict[str, Any]:
if not url.lower().startswith(("http://", "https://")):
return {"error": "url must start with http:// or https://"}
# Same address guard as web_fetch. This is approval gated, so it is defense in
# depth, not the primary control. It checks the initial model supplied URL only;
# redirects that the browser follows internally are not hop checked here.
blocked = check_url(url)
if blocked:
return {"error": blocked}
return _BROWSER.call(
"open_url",
lambda page: (
Expand Down
43 changes: 37 additions & 6 deletions coworker/connectors/integration_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import aisuite as ai

from ..secrets import SecretStore
from ..web.guard import get_checked
from .browser_automation import make_browser_automation_tools
from .email_tools import make_email_tools
from .tool_defs import approval_for_tool, connector_for_tool
Expand Down Expand Up @@ -305,15 +306,39 @@ def _gmail_is_hidden(


def _request(
method: str, url: str, *, headers=None, params=None, json=None, auth=None
method: str,
url: str,
*,
headers=None,
params=None,
json=None,
auth=None,
check_addresses: bool = False,
) -> dict[str, Any]:
"""HTTP for the connectors.

`check_addresses` is for URLs the *model* supplies (browser_read_url). It turns off
automatic redirects and walks the chain through the address guard instead, so a public
URL cannot 302 into loopback or the metadata endpoint. The vendor endpoints everything
else in this module calls are hardcoded, so they skip the guard and its DNS lookup.
"""
try:
import httpx

with httpx.Client(timeout=30.0, follow_redirects=True) as client:
resp = client.request(
method, url, headers=headers, params=params, json=json, auth=auth
)
with httpx.Client(
timeout=30.0, follow_redirects=not check_addresses
) as client:
if check_addresses:
if method.upper() != "GET":
return {"error": "address-checked requests must be GET"}
try:
resp = get_checked(client, url)
except PermissionError as exc:
return {"error": str(exc)}
else:
resp = client.request(
method, url, headers=headers, params=params, json=json, auth=auth
)
ctype = resp.headers.get("content-type", "")
data: Any = resp.json() if "json" in ctype.lower() else resp.text
if resp.status_code >= 400:
Expand Down Expand Up @@ -533,7 +558,13 @@ def make_integration_tools(
def browser_read_url(url: str, max_chars: int = 20000) -> dict[str, Any]:
if not url.lower().startswith(("http://", "https://")):
return {"error": "url must start with http:// or https://"}
out = _request("GET", url, headers={"User-Agent": "coworker/0.1 (+connector)"})
# Model-supplied URL: address-check every hop, same guard as web_fetch.
out = _request(
"GET",
url,
headers={"User-Agent": "coworker/0.1 (+connector)"},
check_addresses=True,
)
if "error" in out:
return out
data = out["data"]
Expand Down
13 changes: 10 additions & 3 deletions coworker/conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ def __init__(self, base_dir: str | Path) -> None:
"ALTER TABLE sessions ADD COLUMN auto_title TEXT",
"ALTER TABLE sessions ADD COLUMN renamed INTEGER DEFAULT 0",
"ALTER TABLE sessions ADD COLUMN grants TEXT",
"ALTER TABLE sessions ADD COLUMN compaction TEXT",
):
try:
self._conn.execute(ddl)
Expand Down Expand Up @@ -191,13 +192,14 @@ def save(self, record: SessionRecord) -> None:
title = record.title or title_from(record.messages)
self._conn.execute(
"""
INSERT INTO sessions (session_id, workspace, model, mode, title, agent, n_msgs, messages, extra_roots, grants, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, CURRENT_TIMESTAMP)
INSERT INTO sessions (session_id, workspace, model, mode, title, agent, n_msgs, messages, extra_roots, grants, compaction, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(session_id) DO UPDATE SET
workspace = excluded.workspace, model = excluded.model, mode = excluded.mode,
title = COALESCE(sessions.title, excluded.title), agent = excluded.agent,
n_msgs = excluded.n_msgs, messages = NULL, extra_roots = excluded.extra_roots,
grants = excluded.grants, updated_at = CURRENT_TIMESTAMP
grants = excluded.grants, compaction = excluded.compaction,
updated_at = CURRENT_TIMESTAMP
""",
(
sid,
Expand All @@ -209,6 +211,7 @@ def save(self, record: SessionRecord) -> None:
len(record.messages),
json.dumps(record.extra_roots or []),
json.dumps(record.grants or {}),
json.dumps(record.compaction or {}),
),
)
self._conn.commit()
Expand Down Expand Up @@ -241,6 +244,10 @@ def load(self, session_id: str) -> Optional[SessionRecord]:
row["extra_roots"] if "extra_roots" in row.keys() else None
),
grants=_load_grants(row["grants"] if "grants" in row.keys() else None),
# Auto-compaction state (OPE-27) — same defensive parse as grants.
compaction=_load_grants(
row["compaction"] if "compaction" in row.keys() else None
),
pinned=bool(row["pinned"]),
archived=bool(row["archived"]),
origin=row["origin"],
Expand Down
142 changes: 141 additions & 1 deletion coworker/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from enum import Enum
from typing import Any, AsyncIterator, Awaitable, Callable, Optional

from . import compaction as _compaction
from .events import Event, EventType
from .permissions import Mode, PermissionEngine
from .providers import AssistantTurn, ProviderClient, ToolCall
Expand Down Expand Up @@ -103,6 +104,14 @@ def __init__(
# (answerable inline in a live session or from the Inbox when unattended). None on surfaces
# that can't ask (the tool then no-ops).
self.question_asker = question_asker
# Auto-compaction (OPE-27) — set post-construction by the surface/manager so the
# constructor footprint stays put. `compaction_settings` is a live getter (Settings
# changes apply without a rebuild); `is_attended` gates the failure prompt (None →
# treat as unattended: never park a background run on internal bookkeeping).
self.compaction_state: Optional[_compaction.CompactionState] = None
self.compaction_settings: Optional[Callable[[], dict[str, Any]]] = None
self.is_attended: Optional[Callable[[], bool]] = None
self._last_context_tokens: Optional[int] = None
self.audit_context: dict[str, Any] = {}
if instructions and not (
self.messages and self.messages[0].get("role") == "system"
Expand Down Expand Up @@ -302,6 +311,18 @@ async def _loop(self) -> AsyncIterator[Event]:
return
iterations += 1

# Auto-compaction checkpoint (OPE-27): between tool turns and before a new
# turn's first call. Deliberately no "wrap up" warning to the model. The
# COMPACTING signal precedes the (multi-second) summarizer call so surfaces
# can show progress instead of a silent stall.
notice = None
if self._compaction_due():
yield Event(EventType.COMPACTING, {})
notice = await self._compact_now()
if notice:
self._append_notice("compacted", notice)
yield Event(EventType.COMPACTED, {"text": notice})

turn: Optional[AssistantTurn] = None
streamed: list[str] = []
streamed_reasoning: list[str] = []
Expand Down Expand Up @@ -329,6 +350,17 @@ def _partial_turn() -> AssistantTurn:
if chunk.turn is not None:
turn = chunk.turn
except Exception as exc: # provider failure
# A raw context-overflow 400 (compaction mispredicted, e.g. the estimate
# path) routes into the compaction policy instead of surfacing. The retry
# is progress-guarded: each pass moves the boundary forward or gives up,
# so a model that keeps overflowing still terminates in the error path.
if _compaction.is_context_overflow(exc) and not self._cancel.is_set():
yield Event(EventType.COMPACTING, {})
notice = await self._compact_now(force=True)
if notice:
self._append_notice("compacted", notice)
yield Event(EventType.COMPACTED, {"text": notice})
continue
# Same contract as the stop path below: the partial the user watched
# arrive survives the failure.
if streamed or streamed_reasoning:
Expand All @@ -352,6 +384,10 @@ def _partial_turn() -> AssistantTurn:
return
if turn is None:
turn = AssistantTurn()
if turn.usage is not None:
# The trigger signal: the prompt-side total that actually occupied the
# window on this round-trip (estimate fallback when never reported).
self._last_context_tokens = turn.usage.context_tokens

self.messages.append(_assistant_message(turn, model=self.model))
payload: dict[str, Any] = {
Expand Down Expand Up @@ -386,6 +422,104 @@ def _partial_turn() -> AssistantTurn:
if self._steering:
self._inject_steering()

# -- auto-compaction (OPE-27) ------------------------------------------------
def _compaction_config(self) -> dict[str, Any]:
cfg = dict(self.compaction_settings() or {}) if self.compaction_settings else {}
if not cfg.get("context_window"):
from .providers.matrix import model_context_windows

cfg["context_window"] = model_context_windows().get(self.model)
cfg.setdefault("threshold_pct", _compaction.DEFAULT_THRESHOLD_PCT)
cfg.setdefault("cap_tokens", _compaction.DEFAULT_CAP_TOKENS)
return cfg

def _compaction_due(self) -> bool:
"""The trigger check alone — cheap and side-effect free, so the loop can emit
the COMPACTING signal before committing to the (slow) summarizer call."""
cfg = self._compaction_config()
if cfg.get("enabled") is False:
return False
signal = self._last_context_tokens or _compaction.estimate_tokens(
self._outbound_messages()
)
return _compaction.should_compact(
signal,
cfg.get("context_window"),
threshold_pct=float(cfg["threshold_pct"]),
cap_tokens=int(cfg["cap_tokens"]),
)

async def _compact_now(self, *, force: bool = False) -> Optional[str]:
"""Run the compaction policy. Callers gate on `_compaction_due()` (or `force`,
the overflow path). Returns the user-facing notice text when the outbound view
changed, else None. Failure policy per spec: retry once (both modes); attended →
Retry / Trim prompt; unattended → auto-trim and continue (never park a run on
bookkeeping)."""
cfg = self._compaction_config()
pct = float(cfg["threshold_pct"])
cap = int(cfg["cap_tokens"])
window = cfg.get("context_window")
keep = int(
_compaction.KEEP_RECENT_FRACTION
* _compaction.trigger_tokens(window, threshold_pct=pct, cap_tokens=cap)
)
model = str(cfg.get("model") or "") or self.model

def _build() -> Optional[_compaction.CompactionState]:
return _compaction.build_state(
self.messages,
provider=self.provider,
model=model,
keep_tokens=keep,
prior=self.compaction_state,
)

state: Optional[_compaction.CompactionState] = None
failed = False
for _attempt in range(2): # first try + the unconditional single retry
try:
state = await asyncio.to_thread(_build)
failed = False
break
except Exception:
failed = True
if failed and self.question_asker is not None and self.is_attended and self.is_attended():
while True:
answer = await self._interruptible(
self.question_asker(
{
"question": (
"Context compaction failed — the summarizer couldn't "
"condense this session's history. How should I proceed?"
),
"options": ["Retry", "Trim oldest 10%"],
"allow_text": False,
"header": "Compaction",
},
None,
),
interrupted=None,
)
if not answer or answer.get("answer") != "Retry":
break
try:
state = await asyncio.to_thread(_build)
failed = False
break
except Exception:
continue
if state is not None:
self.compaction_state = state
self._last_context_tokens = None # stale once the outbound view shrank
return "Context compacted — earlier turns were summarized"
if failed or force:
trimmed = _compaction.trim_state(self.messages, prior=self.compaction_state)
if trimmed is not None:
self.compaction_state = trimmed
self._last_context_tokens = None
return "Context trimmed — oldest turns dropped (summary unavailable)"
return None

# -- helpers ----------------------------------------------------------------
async def _astream(self):
"""Bridge the provider's blocking stream generator to the async loop via a
Expand Down Expand Up @@ -893,13 +1027,19 @@ def _outbound_messages(self) -> list[dict[str, Any]]:
# one. Whole `notice` messages (error/interrupted/model-switch markers) are
# display-only too: dropped entirely.
_SIDECARS = ("source", "_display", "ts", "reasoning", "usage")
# Auto-compaction (OPE-27): everything before the boundary is represented by the
# compacted block. Outbound-only — the canonical history stays intact — and the
# block+tail are byte-stable between turns, so prompt caching keeps working.
source_messages = _compaction.apply_to_outbound(
self.messages, self.compaction_state
)
out = [
(
{k: v for k, v in msg.items() if k not in _SIDECARS}
if any(s in msg for s in _SIDECARS)
else msg
)
for msg in self.messages
for msg in source_messages
if msg.get("role") != "notice"
]
# PDF attachments (stored as `file` parts) are adapted to the ACTIVE model right
Expand Down
2 changes: 2 additions & 0 deletions coworker/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ class EventType(str, Enum):
TURN_END = "turn_end"
ERROR = "error"
INTERRUPTED = "interrupted"
COMPACTING = "compacting" # compaction started — surfaces show a transient signal
COMPACTED = "compacted" # outbound history was compacted (summary or trim)


@dataclass
Expand Down
7 changes: 5 additions & 2 deletions coworker/inbox_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
# to OpenWorker (2026-07-22); the legacy [ocw:…] spelling stays parseable so replies to
# messages sent before the rename still resolve.
_ID_TOKEN = re.compile(r"\[o(?:c)?w:([0-9a-f]{6,})\]")
# Whole words only — substring matching resolved "disallow" as allow and "note" as deny.
_ALLOW_WORDS = re.compile(r"\b(?:approve|approved|allow|allowed|yes)\b")
_DENY_WORDS = re.compile(r"\b(?:deny|denied|reject|rejected|no)\b")


@dataclass
Expand Down Expand Up @@ -130,9 +133,9 @@ def resolve_from_reply(
return None
item_id = m.group(1)
lowered = reply.lower()
if any(w in lowered for w in ("approve", "allow", "yes", "👍", "✅")):
if _ALLOW_WORDS.search(lowered) or "👍" in reply or "✅" in reply:
resolution = "allow"
elif any(w in lowered for w in ("deny", "reject", "no", "👎", "❌")):
elif _DENY_WORDS.search(lowered) or "👎" in reply or "❌" in reply:
resolution = "deny"
else:
resolution = _ID_TOKEN.sub("", reply).strip() # free-text answer to a question
Expand Down
Loading
Loading