Skip to content

Commit c008c68

Browse files
bstruongclaude
andcommitted
chore: bulk safety commit — 12 unsplit fixes from sessions 1-6 + chat.js fix
This is a SINGLE UNSPLIT commit bundling 12 distinct, independently- developed fixes for durability only — it exists purely to move this work off of "uncommitted on disk" (fragile: one `git clean`, forced checkout, or disk issue away from losing sessions of debugging) and onto "committed and pushed" as fast as safely possible. This is explicitly NOT the reviewable 12-way PR split proposed in Phase 2 session 9's Part C. That split still needs to happen in a later session, working from this commit via: git reset --soft <this-commit>~1 which un-does exactly this commit, non-destructively, and restores the working tree to today's on-disk diff for interactive per-fix staging. Bundled in this commit: 1. TOOL_SECTIONS entries for 5 previously-unreachable email tools (search_emails, draft_email, draft_email_reply, ai_draft_email_reply, download_attachment) — src/agent_loop.py 2. _KEYWORD_HINTS entries for the same 5 tools — src/tool_index.py 3. IMAP search rewrite: term-AND matching instead of one literal phrase — mcp_servers/email_server.py 4. agent_email_confirm gate extended to archive_email/delete_email/ mark_email_read/bulk_email (previously only send_email/reply_to_email were gated) — mcp_servers/email_server.py, routes/email_routes.py, src/agent_loop.py (TOOL_SECTIONS prose) 5. Domain-classifier keyword-gate widened from 3 hardcoded names to general correspondence-verb patterns — src/agent_loop.py 6. list_emails MCP schema strictness: additionalProperties:false + owner/limit argument declarations — src/tool_schemas.py, mcp_servers/email_server.py 7. list_emails attention-query (unread_only/unresponded_only) max_results cap raised 20 -> 200 so busy inboxes aren't silently truncated — mcp_servers/email_server.py, src/tool_schemas.py, src/agent_loop.py 8. FUNCTION_TOOL_SCHEMAS entries for the same 5 tools, fixing native/ API-mode tool-calling reachability — src/tool_schemas.py 9. BUILTIN_TOOL_DESCRIPTIONS entries for the same 5 tools, fixing semantic-RAG reachability — src/tool_index.py 10. Stale comment correction in src/tool_security.py (folded in with arcahyadi#8) 11. _DOMAIN_TOOL_MAP["email"] now derived from the canonical tool_security.BUILTIN_EMAIL_TOOLS registry instead of a 5th hand-typed, drift-prone list — src/agent_loop.py 12. chat.js live-stream thinking-boundary fix: gemma4:e4b replies whose reasoning arrives as unflagged "Thinking Process:" text could get stuck permanently collapsed in the thinking box, never rendering the real answer — static/js/chat.js 7 new regression tests included (tests/test_*.py). Full narrative and per-fix session provenance: NOTES.md. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ce53e1f commit c008c68

16 files changed

Lines changed: 1859 additions & 31 deletions

NOTES.md

Lines changed: 424 additions & 0 deletions
Large diffs are not rendered by default.

SYNC.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@ Verified-not-assumed against the live repo/DB/git this session. Full detail live
2828
Endpoint row (data/app.db): id=a3c5a269, supports_tools=1, is_enabled=1.
2929
- `OLLAMA_CONTEXT_LENGTH=16384` set (host systemd override; verified). Required — /v1 path sends no
3030
num_ctx; multi-round compound prompts hit 8–13k tokens and truncate at Ollama's ~4096 default.
31-
- DEFAULT MODEL = **qwen3:8b** (settings.json; verified resolve_endpoint("default") -> qwen3:8b).
31+
- DEFAULT MODEL = **gemma4:e4b** (settings.json `default_model`/`research_model`; verified
32+
`resolve_endpoint("default")` -> gemma4:e4b). Switched from qwen3:8b (this doc's original default,
33+
see MODEL RESULTS below) in Phase 2 session 7, based on a cross-model benchmark (sessions 5-6:
34+
92% vs. 69% clean correctness across 13 task-instances, faster, lower VRAM) — see NOTES.md for the
35+
full evidence trail. qwen3:8b, llama3.1:8b, and granite4.1:8b (the other benchmark candidates) were
36+
removed from Ollama in session 8; only gemma4:e4b remains installed.
3237
- The four fixes that made native tool-calling work on local models:
3338
1. supports_tools=1 (data/app.db, not git) — else tools_sent=0. Re-apply via
3439
scripts/set_ollama_supports_tools.py after any data-volume reset.

mcp_servers/email_server.py

Lines changed: 236 additions & 15 deletions
Large diffs are not rendered by default.

routes/email_routes.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3709,6 +3709,167 @@ async def cancel_agent_draft(sid: str, owner: str = Depends(require_owner)):
37093709
logger.error(f"cancel_agent_draft {sid!r} failed: {e}")
37103710
return {"success": False, "error": "Mail operation failed"}
37113711

3712+
# ── Agent mutating-action confirm: list/approve/cancel ───────────────
3713+
# Same principle as the send-confirm block above, extended to the four
3714+
# non-send email tools that used to execute immediately against real
3715+
# IMAP with no confirmation gate: archive_email, delete_email,
3716+
# mark_email_read, bulk_email. When `agent_email_confirm` is on, the MCP
3717+
# email server (mcp_servers/email_server.py::_stash_pending_email_action)
3718+
# stages the request in pending_email_actions instead of touching IMAP.
3719+
# These endpoints run in the main app process (a separate process from
3720+
# the MCP server) and perform the actual mutation using the same IMAP
3721+
# helpers the human Archive/Delete/Mark-read buttons already use — the
3722+
# MCP server process never executes a gated action itself.
3723+
@router.get("/pending-actions")
3724+
async def list_pending_email_actions(owner: str = Depends(require_owner)):
3725+
import sqlite3
3726+
try:
3727+
conn = sqlite3.connect(SCHEDULED_DB)
3728+
conn.row_factory = sqlite3.Row
3729+
rows = conn.execute(
3730+
"""SELECT id, action, uid, uids, folder, permanent, read_flag,
3731+
bulk_action, all_unread, description, account_id, created_at
3732+
FROM pending_email_actions
3733+
WHERE status = 'agent_pending' AND owner = ?
3734+
ORDER BY created_at DESC""",
3735+
(owner or "",),
3736+
).fetchall()
3737+
conn.close()
3738+
return {"pending": [dict(r) for r in rows]}
3739+
except Exception as e:
3740+
logger.error(f"list_pending_email_actions failed: {e}")
3741+
return {"pending": [], "error": "Mail operation failed"}
3742+
3743+
def _execute_pending_email_action(row: dict, owner: str) -> dict:
3744+
"""Perform the real IMAP mutation for an approved pending action."""
3745+
action = row["action"]
3746+
folder = row["folder"] or "INBOX"
3747+
account_id = row["account_id"]
3748+
try:
3749+
if action == "archive":
3750+
with _imap(account_id, owner=owner) as conn:
3751+
conn.select(_q(folder))
3752+
if not _move_email_message(conn, row["uid"], "Archive", role="archive"):
3753+
return {"success": False, "error": "Email not found"}
3754+
_email_index_delete(owner, account_id, folder, row["uid"])
3755+
_invalidate_list_cache(account_id)
3756+
return {"success": True}
3757+
if action == "delete":
3758+
permanent = bool(row["permanent"])
3759+
with _imap(account_id, owner=owner) as conn:
3760+
conn.select(_q(folder))
3761+
if permanent:
3762+
if not _store_email_flag(conn, row["uid"], "\\Deleted", add=True):
3763+
return {"success": False, "error": "Email not found"}
3764+
conn.expunge()
3765+
else:
3766+
if not _move_email_message(conn, row["uid"], "Trash", role="trash"):
3767+
return {"success": False, "error": "Email not found"}
3768+
_email_index_delete(owner, account_id, folder, row["uid"])
3769+
_invalidate_list_cache(account_id, folder)
3770+
return {"success": True}
3771+
if action == "mark_email_read":
3772+
read = bool(row["read_flag"])
3773+
with _imap(account_id, owner=owner) as conn:
3774+
conn.select(_q(folder))
3775+
if not _store_email_flag(conn, row["uid"], "\\Seen", add=read):
3776+
return {"success": False, "error": "Email not found"}
3777+
_email_index_update_flags(owner, account_id, folder, row["uid"], "\\Seen", read)
3778+
_invalidate_list_cache(account_id)
3779+
return {"success": True}
3780+
if action == "bulk_email":
3781+
bulk_action = row["bulk_action"] or ""
3782+
permanent = bool(row["permanent"])
3783+
uids = json.loads(row["uids"]) if row["uids"] else []
3784+
changed = 0
3785+
with _imap(account_id, owner=owner) as conn:
3786+
conn.select(_q(folder))
3787+
if row["all_unread"]:
3788+
st, data = conn.uid("SEARCH", None, "UNSEEN")
3789+
if st == "OK" and data and data[0]:
3790+
uids = [u.decode() if isinstance(u, bytes) else str(u) for u in data[0].split()]
3791+
for uid in uids:
3792+
ok = False
3793+
if bulk_action == "mark_read":
3794+
ok = _store_email_flag(conn, uid, "\\Seen", add=True)
3795+
elif bulk_action == "mark_unread":
3796+
ok = _store_email_flag(conn, uid, "\\Seen", add=False)
3797+
elif bulk_action == "archive":
3798+
ok = _move_email_message(conn, uid, "Archive", role="archive")
3799+
elif bulk_action == "junk":
3800+
ok = _move_email_message(conn, uid, "Junk", role="junk")
3801+
elif bulk_action == "delete":
3802+
if permanent:
3803+
ok = _store_email_flag(conn, uid, "\\Deleted", add=True)
3804+
else:
3805+
ok = _move_email_message(conn, uid, "Trash", role="trash")
3806+
if not ok:
3807+
continue
3808+
changed += 1
3809+
if bulk_action in ("archive", "junk", "delete"):
3810+
_email_index_delete(owner, account_id, folder, uid)
3811+
else:
3812+
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", bulk_action == "mark_read")
3813+
if permanent and bulk_action == "delete":
3814+
conn.expunge()
3815+
_invalidate_list_cache(account_id)
3816+
return {"success": True, "changed": changed, "requested": len(uids)}
3817+
return {"success": False, "error": f"Unknown pending action type: {action!r}"}
3818+
except Exception as e:
3819+
logger.error(f"Failed to execute pending email action {row.get('id')!r} ({action}): {e}")
3820+
return {"success": False, "error": "Mail operation failed"}
3821+
3822+
@router.post("/pending-actions/{aid}/approve")
3823+
async def approve_email_action(aid: str, owner: str = Depends(require_owner)):
3824+
import sqlite3
3825+
try:
3826+
conn = sqlite3.connect(SCHEDULED_DB)
3827+
conn.row_factory = sqlite3.Row
3828+
row = conn.execute(
3829+
"SELECT * FROM pending_email_actions WHERE id = ? AND status = 'agent_pending' AND owner = ?",
3830+
(aid, owner or ""),
3831+
).fetchone()
3832+
conn.close()
3833+
if not row:
3834+
return {"success": False, "error": "Pending action not found or already handled"}
3835+
row = dict(row)
3836+
except Exception as e:
3837+
logger.error(f"approve_email_action {aid!r} lookup failed: {e}")
3838+
return {"success": False, "error": "Mail operation failed"}
3839+
result = _execute_pending_email_action(row, owner or "")
3840+
try:
3841+
new_status = "executed" if result.get("success") else "failed"
3842+
conn = sqlite3.connect(SCHEDULED_DB)
3843+
conn.execute(
3844+
"UPDATE pending_email_actions SET status = ?, result = ? WHERE id = ? AND owner = ?",
3845+
(new_status, json.dumps(result), aid, owner or ""),
3846+
)
3847+
conn.commit()
3848+
conn.close()
3849+
except Exception as e:
3850+
logger.error(f"approve_email_action {aid!r} status update failed: {e}")
3851+
return result
3852+
3853+
@router.delete("/pending-actions/{aid}")
3854+
async def cancel_email_action(aid: str, owner: str = Depends(require_owner)):
3855+
import sqlite3
3856+
try:
3857+
conn = sqlite3.connect(SCHEDULED_DB)
3858+
cur = conn.execute(
3859+
"""UPDATE pending_email_actions SET status = 'cancelled'
3860+
WHERE id = ? AND status = 'agent_pending' AND owner = ?""",
3861+
(aid, owner or ""),
3862+
)
3863+
conn.commit()
3864+
affected = cur.rowcount
3865+
conn.close()
3866+
if not affected:
3867+
return {"success": False, "error": "Pending action not found or already handled"}
3868+
return {"success": True}
3869+
except Exception as e:
3870+
logger.error(f"cancel_email_action {aid!r} failed: {e}")
3871+
return {"success": False, "error": "Mail operation failed"}
3872+
37123873
@router.get("/resolve-contact")
37133874
async def resolve_contact(name: str = Query(..., description="Name to search for"), owner: str = Depends(require_owner)):
37143875
"""Search Sent folder for a contact by name. Returns matching email addresses."""

0 commit comments

Comments
 (0)