Commit Graph

135 Commits

Author SHA1 Message Date
Vykos
4771d80eb2 Harden session endpoint owner scope (#1308) 2026-06-03 02:40:22 +09:00
lekt8
80de69ebb0 feat: document rrule in the manage_calendar tool schema (#1320) (#1324)
* feat: document rrule in the manage_calendar tool schema (#1320)

The create_event handler already persists `rrule` (a single event carrying an
iCalendar RRULE), but the manage_calendar tool schema didn't list it, so the
agent had no documented way to make a recurring event and took a roundabout
path. Add `rrule?` to the create_event field list with examples
(FREQ=WEEKLY;BYDAY=MO etc.) and an explicit note to create ONE event with the
rule rather than looping.

Covered by tests/test_calendar_rrule.py: do_manage_calendar create_event with an
rrule stores one event with that recurrence; without it, the event is single.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: restore SessionLocal via monkeypatch in #1320 rrule test (review)

Per review: the test patched core.database.SessionLocal at module import and
never restored it, which could leak the temp DB into later tests in the same
process. Move the patch into an autouse monkeypatch fixture so it is restored
after each test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 02:37:45 +09:00
Vykos
5ee30cc144 Scope skills usage by owner (#1312) 2026-06-03 02:27:43 +09:00
Vykos
e73545f64f Keep Bitwarden unlock password off argv (#1311) 2026-06-03 02:13:51 +09:00
Afonso Coutinho
a8395b4e4c fix: agent_input_token_budget wrongly treated as a secret and unsettable from chat (#1294)
* fix: don't classify agent_input_token_budget as a secret (token must be a suffix)

* test: agent_input_token_budget is settable from chat
2026-06-03 01:53:47 +09:00
lekt8
adde94e430 fix: closed document stays active & leaks into new chats (#1160) (#1238)
* fix: closed document no longer stays active and leaks into new chats (#1160)

Closing a document tab calls _detachDocFromSession: a doc with content is
PATCHed to session_id="" (unlinked, session_id -> NULL, is_active stays True),
an empty one is DELETEd. But the in-memory active-document pointer
(tool_implementations._active_document_id) was never cleared on either path.

The chat doc-injection last-resort looks up that pointer by id and injects it
when `not cand.session_id or cand.session_id == session`. An unlinked doc has
session_id NULL, so the stale pointer re-surfaced a closed document in later,
unrelated chats — the agent kept reading/suggesting edits to a doc the user
had closed.

Fix: add clear_active_document(doc_id) and call it when a document is unlinked
(PATCH session_id="") or deleted, so the pointer no longer resurrects a closed
document. clear_active_document only clears when the id matches (or no id), so a
different active doc is left untouched.

Covered by tests/test_active_document_clear.py (4 cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: add route-level regression for #1160 (detach/delete clears active doc)

Per review: prove the actual API path, not just the helper. Drives
PATCH /api/document/{id} (session_id="") and DELETE /api/document/{id}
through TestClient against a temp SQLite DB under real owner routing, and
asserts get_active_document() is cleared (and untouched when a different
document is closed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: make #1160 route regression hang-proof and dev-DB-independent

The route test could hang in other environments: it set DATABASE_URL at import
time, which is ignored if core.database was already imported, so it fell back to
the real dev DB and could contend for its locks (maintainer saw it hang, exit
124).

Rebind to a DEDICATED temporary SQLite engine (NullPool) and patch the document
route module's SessionLocal to it via an autouse fixture — so the test never
touches the dev DB and is independent of import order. Runs in ~0.3s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: drive #1160 route regression without TestClient (fixes local hang)

The route test used Starlette TestClient (middleware app + threadpool), which
hung in the maintainer's environment. Rework it to call the async route handlers
directly — extracted from the router — with a minimal fake request against a
temp-SQLite-patched SessionLocal. Same real coverage (handler + DB + owner
routing), but it completes reliably (~0.3s) with no TestClient/threadpool.

Verified the maintainer's exact batch now passes:
  pytest tests/test_document_close_clears_active_route.py \
         tests/test_active_document_clear.py \
         tests/test_document_tool_owner_scope.py  -> 14 passed

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 01:47:13 +09:00
lekt8
1507d140b8 feat: CalDAV write-back — push local event create/update/delete to the remote (#800) (#1282)
* feat: CalDAV write-back — push local event create/update/delete to the remote (#800)

CalDAV sync was pull-only (src/caldav_sync.py), so events created, edited, or
deleted in Odysseus on a CalDAV-backed calendar only changed local SQLite and
never reached the server — they silently vanished on the next pull and never
appeared on the user's phone (iCloud, etc.).

This adds the missing write half:
- src/caldav_writeback.py builds the VEVENT, re-discovers the remote calendar by
  the same URL-hash the local id was derived from (the remote URL isn't stored),
  and PUTs/DELETEs the event by UID via the caldav lib. The pure pieces
  (build_event_ical, find_remote_calendar, push_event) take inputs by argument so
  they unit-test against a fake client with no network.
- create/update/delete event handlers (routes/calendar_routes.py) call it
  best-effort for caldav-sourced calendars only: the local DB stays the source of
  truth, a remote failure is logged, never fatal, and local calendars are untouched.

Tests: tests/test_caldav_writeback.py (9, pure logic incl. iCal serialization,
hash discovery, create/update/delete orchestration) and
tests/test_caldav_writeback_route.py (3, route-level: a caldav calendar pushes,
a local one does not, delete pushes a delete). 12 passed.

Note: write-back re-discovers the remote calendar per write (the URL isn't
persisted locally); a follow-up could cache it. Live-iCloud verification needs a
real account — flagging for a maintainer pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: drive #800 route regression without TestClient (fixes local hang)

Same fix as the document route test: the CalDAV write-back route regression used
Starlette TestClient (middleware app + threadpool) which hung in the maintainer's
environment. Rework it to call the async create/delete calendar handlers directly
— extracted from the router — with a minimal fake request, temp-SQLite-patched
SessionLocal, and writeback_event stubbed to record calls. Same coverage (a
caldav calendar pushes, a local one does not, delete pushes a delete), completes
in ~0.3s with no TestClient.

Verified the maintainer's exact batch:
  pytest tests/test_caldav_writeback.py tests/test_caldav_writeback_route.py -> 12 passed

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 01:44:02 +09:00
Shreyas S Joshi
7504fedb17 fix: surface reasoning_content when content is empty (thinking models) (#1233)
Thinking models served via llama.cpp without --reasoning-format none
(e.g. Qwen3, DeepSeek-R1) route all tokens into reasoning_content and
return content="". Two call paths were silently broken:

- llm_call / llm_call_async (non-streaming): hard-keyed
  data["choices"][0]["message"]["content"] raises KeyError or returns
  empty string, discarding the entire response.

- stream_agent_loop end-of-round fallback: when full_response is empty
  but round_reasoning has content, the existing code replaced the
  response with the generic empty-response error message, discarding
  all reasoning tokens that were correctly accumulated during streaming.

Fix: in both non-streaming paths use msg.get("content") or
msg.get("reasoning_content") or "". In the streaming fallback, surface
round_reasoning as the answer before falling through to the error path.
2026-06-03 01:41:24 +09:00
Afonso Coutinho
257f7ee7b2 fix: manage_tasks create handles an explicit null prompt without crashing (#1290) 2026-06-03 01:40:21 +09:00
nickorlabs
c39d8db12a fix(agent): make context-budget hard_max configurable via agent_input_token_hard_max setting (#1273)
Completes the reviewer requirement from PR #1190 review that was carried
over but not implemented in #1230:

> "The hard max is a function-local constant. For this setting, the ceiling
>  should be configurable or at least represented as a named setting/default
>  with tests."
                                                                — review on #1190

#1230 shipped the adaptive auto-derivation but left `DEFAULT_HARD_MAX = 200_000`
as a hardcoded module constant in src/context_budget.py. Admins on premium
APIs with large context windows (kimi-k2 / minimax-m3 at 1M, etc.) can use
their full window today only by setting `agent_input_token_budget`
explicitly — which then takes them off the adaptive auto-path entirely.

## What this PR changes

- src/settings.py: register `agent_input_token_hard_max` in
  DEFAULT_SETTINGS, default 200_000 (matches `DEFAULT_HARD_MAX`). Inline
  comment documents the no-op semantics in the explicit branch.

- src/agent_loop.py: read the setting at the call site and pass it as the
  `hard_max` kwarg of `compute_input_token_budget`. Defensive parsing —
  missing / non-int / zero values fall back to `DEFAULT_HARD_MAX`, so a
  misconfig cannot silently zero the budget.

- src/tool_implementations.py: three friendly aliases for `manage_settings`:
  - "hard max" -> agent_input_token_hard_max
  - "token budget cap" -> agent_input_token_hard_max
  - "input budget cap" -> agent_input_token_hard_max
  Plus the existing "token budget" -> agent_input_token_budget keeps a
  matching shorter alias "input budget".

- tests/test_context_budget.py: 6 new tests on top of the existing 6:
  - hard_max raises the auto ceiling (1M ctx + raised cap -> 85% of ctx)
  - hard_max lowers the auto ceiling (128K ctx + 50K cap -> 50K)
  - hard_max has no effect on the explicit branch
  - DEFAULT_SETTINGS contains the new key
  - manage_settings aliases are registered
  - the live get_setting path returns the override value, and malformed
    values fall back per the agent_loop defensive parsing

12 passed in 0.04s. No changes to the pure helper signature or semantics;
#1230's behavior is the default when the new setting is unset.

## How it lets users drop the explicit override

Before this PR, on a 1M-context model:
  agent_input_token_budget = 900_000  (explicit)  -> 900K  [user override]
  agent_input_token_budget = <unset>  (auto)      -> 200K  [HARD_MAX]

After this PR, same model:
  agent_input_token_budget = <unset>
  agent_input_token_hard_max = 900_000
                                      -> min(1M * 0.85, 900K) = 850K  [auto, no override needed]

The explicit-override path keeps working unchanged for users who prefer it.
2026-06-03 01:36:57 +09:00
Afonso Coutinho
65751186bd fix: merging consecutive user messages corrupts multimodal (image) content (#1277)
* fix: preserve multimodal content blocks when merging consecutive user messages

* test: consecutive user-message merge keeps multimodal image blocks
2026-06-03 01:21:57 +09:00
Afonso Coutinho
83aa35b83e fix: owner-less document query passes bare False to SQLAlchemy filter() (#1281)
* fix: use SQL false() for owner-less document query (filter(False) raises in SQLAlchemy 2.x)

* test: owner-less document query doesn't pass a bare False to filter
2026-06-03 01:20:43 +09:00
Afonso Coutinho
a3b3dbafde fix: uploaded files with no extension become permanently unresolvable (#1275)
* fix: accept extensionless upload ids so files like Dockerfile resolve

* test: upload id validation accepts extensionless ids
2026-06-03 01:16:30 +09:00
Afonso Coutinho
f62d6ea3d7 fix: research query misclassifies 'whatsapp'/'however' as questions (#1247)
* fix: detect question words as whole words, not prefixes

* fix: same question-word prefix bug in the services search copy

* test: question-word detection rejects prefix lookalikes
2026-06-03 01:10:06 +09:00
Afonso Coutinho
311f226d44 fix: calendar check-in digest drops events 7-8 days out (#1249)
* fix: close 1-day gap in calendar digest windows (events ~7-8 days out)

* test: calendar digest windows are contiguous and cover 7-8 day events
2026-06-03 01:03:58 +09:00
red person
aa420e2060 Ignore stale duplicate upload rows (#1256) 2026-06-03 00:59:01 +09:00
Afonso Coutinho
a04553013d fix: Anthropic responses with multiple text blocks lose all but the first (#1255)
* fix: concatenate all Anthropic text blocks, not just the first

* test: Anthropic response parsing concatenates text blocks
2026-06-03 00:57:20 +09:00
Shreyas S Joshi
b29c200801 fix(mcp): invalidate tool prompt cache on connect/disconnect/error (#1235)
* fix(mcp): invalidate tool prompt cache on connect/disconnect/error

get_tool_descriptions_for_prompt cached its result keyed only on
(disabled_map, len(_tools)). If a server reconnects with the same
tool count (or transitions to error state), the cache was never
busted — the agent received stale tool descriptions for the new
connection state.

Add a _generation counter incremented on every structural change
(successful connect, disconnect, connection error) and include it in
the cache key.

* test(mcp): regression test for _generation cache invalidation
2026-06-03 00:49:29 +09:00
Afonso Coutinho
203c4d83df fix: search analytics crashes recording when the JSON file predates a counter (#1224)
* refactor: single _default_analytics() instead of duplicated default dicts

* fix: merge analytics defaults so an old/partial file doesn't KeyError on record

* test: analytics load merges defaults; record survives a partial file
2026-06-03 00:26:37 +09:00
lekt8
975fd42e32 fix: rank recency by UTC, not local time (#1116) (#1234)
src/search/ranking.py computed result age as `(datetime.now() - dt).days`, where
`dt` is parsed from a UTC-style published date with no timezone. Using local
`datetime.now()` skewed the age by the host's UTC offset (off-by-up-to-a-day near
boundaries), and was a latent crash: once neighbouring code becomes timezone-aware
the naive/aware subtraction raises TypeError (the landmine called out in #1116).

Recency is now measured against naive UTC. The scoring is also lifted out of the
rank_search_results closure into a module-level, time-injectable `recency_score`
so it's unit-testable, and `_utcnow_naive()` avoids `datetime.utcnow()` (removed in
Python 3.14).

Covered by tests/test_search_ranking_recency.py (5 cases); the existing
tests/test_search_ranking.py still passes.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:18:15 +09:00
lekt8
8c376d2b0e feat: adapt agent_input_token_budget to the model context window (#1170) (#1230)
The agent soft-trims input context to `agent_input_token_budget` (default 6000).
The old computation `min(context_length or budget, budget)` made the 6000 default
a hard ceiling for every model, so 128K/1M context models were silently capped at
6000 input tokens — now that num_ctx is sent correctly (#1056), this was the last
barrier to actually using a long context window.

This derives the default budget from the model's discovered context window
(~85%, capped at a generous hard max) while honouring an explicit user setting
exactly (clamped to the window). When the window is unknown it falls back to the
previous value, so behaviour is unchanged for that case.

- src/context_budget.py: pure `compute_input_token_budget()` (unit-testable)
- src/settings.py: `is_setting_overridden()` to tell an explicit user value from
  the merged default (load_settings merges DEFAULT_SETTINGS, so equality alone
  can't distinguish them)
- src/agent_loop.py: use the helper in the soft-trim path

Covered by tests/test_context_budget.py (6 cases).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:13:53 +09:00
lekt8
87babb58d5 fix: SSRF hardening for the custom embedding endpoint URL (#132) (#1206)
POST /api/embeddings/endpoint takes a user-supplied URL and immediately
makes an outbound httpx request to it with no validation. The admin gate
added earlier (PR #80) closed the unauthenticated-access part of #132; this
addresses the remaining request: validate the URL before fetching it.

Odysseus is local-first, so pointing the embedding endpoint at a loopback or
LAN server (local vLLM / llama.cpp / Ollama) is a normal setup — a blanket
private-IP block would break the primary use case. So the guard:

  - always rejects non-HTTP(S) schemes (file://, gopher://, ftp:// …),
  - always rejects the link-local range (169.254.0.0/16, incl. the cloud
    instance-metadata 169.254.169.254 exfil vector) plus multicast /
    reserved / unspecified, and IPv4-mapped-IPv6 forms of the above,
  - keeps loopback/LAN allowed by default, and
  - adds EMBEDDING_BLOCK_PRIVATE_IPS=true for full SSRF lockdown on exposed
    multi-tenant deployments.

Logic lives in src/url_safety.py (stdlib only, resolver injectable) so it is
unit-testable without real DNS; the route calls it before the health-check
request. Covered by tests/test_url_safety.py (8 cases).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:46:33 +09:00
lekt8
f2f437f4a8 feat: add /api/ready readiness probe (DB, data dir, local-first) (#1200)
/api/health is a liveness ping. This adds /api/ready as a readiness /
integrity self-check that returns 503 unless every critical subsystem is
whole, so an orchestrator (Docker/Compose/k8s) can gate traffic on real
readiness rather than mere process liveness:

  - database: opens a connection and runs SELECT 1
  - data_dir: confirms the data directory exists and is writable
  - local_first: reports whether storage stays on the host (informational;
    a remote database is a valid deployment, so it never fails readiness)

The check logic lives in src/readiness.py so it is unit-testable in
isolation; the route is a thin wrapper. Covered by tests/test_readiness.py.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:33:22 +09:00
Mayank Ukey
f96edfe5ca fix: deepseek-r1 on Ollama returns HTTP 400 when tool schemas are sent (#1169)
* fix: exclude deepseek from local tool-calling keyword list

deepseek-r1 on Ollama returns HTTP 400 when tool schemas are sent.
The cloud API (api.deepseek.com) is already caught by the _API_HOSTS
check, so the generic 'deepseek' keyword match was only causing false
positives for local Ollama-served models.

* fix: add model no-tools blocklist and regression tests for deepseek-r1

The previous fix removed 'deepseek' from the keyword allow-list, but
_is_api_model is still True for localhost endpoints because 'localhost'
appears in _API_HOSTS — so the keyword change had no effect for Ollama.

Proper fix: add an explicit _model_no_tools blocklist ('deepseek-r1')
that overrides the endpoint URL check. The endpoint's supports_tools DB
flag still takes priority either way (True forces tools on, False forces
them off), so users can override per-endpoint when needed.

Also refined the deepseek allow-list: 'deepseek-v' and 'deepseek-chat'
cover the cloud models (v2, v3, chat) that do support tools, without
matching deepseek-r1 variants.

13 regression tests cover:
- deepseek-r1 on localhost/docker: no tools (was HTTP 400)
- deepseek-v3/chat on api.deepseek.com: tools enabled (no regression)
- endpoint_supports=True/False overrides both lists
- qwen/llama on localhost: unaffected
2026-06-02 23:22:57 +09:00
Ernest Hysa
c12ae79c42 fix(tools): strict path confinement with sensitive-subpath deny list (#1072)
Rework read_file / write_file confinement after review feedback:

- Remove $HOME from default allow roots. Only project data/ and system
  temp dirs are allowed out of the box.
- Add a sensitive-subpath deny list (.ssh, .gnupg, shell rc files,
  .env, .netrc, SSH key filenames). Checked BEFORE allowlist so it
  blocks even when a broader root is configured.
- Add "tool_path_extra_roots" setting for opt-in broader access.
- Sensitive subpaths remain blocked regardless of configured roots.

Tests: 24 cases covering /etc/shadow, ~/.ssh/authorized_keys,
symlink into .ssh, traversal, shell rc files, key filenames,
extra roots, and dispatch-level end-to-end.
2026-06-02 23:13:30 +09:00
RosenTomov
37356d8e3e Discover LM Studio via host/port scanning and native-API fingerprint (#1126)
Scan port 1234 and any custom port from LM_STUDIO_URL, add the LM_STUDIO_URL host to the discovery sweep alongside the Ollama env vars, and tag each discovered endpoint with its provider by fingerprinting the native /api/v1/models response (entries carrying key + architecture). Documents LM_STUDIO_URL in .env.example.
2026-06-02 23:04:58 +09:00
Jordan Urbs
c0c1ceb36d Treat Venice as a tool-capable SOTA cloud provider (#1173)
Follow-up to the Venice provider PR. Wire api.venice.ai into the three
host allowlists so Venice behaves like the other paid OpenAI-compatible
clouds:

- agent_loop: add api.venice.ai to _API_HOSTS so the agent sends native
  OpenAI tool-call schemas (Venice supports function calling) instead of
  degrading to fenced-block parsing.
- teacher_escalation: add api.venice.ai to _SOTA_HOSTS so the escalation
  loop stays OFF for Venice (it's a paid top-tier API; no need to add
  teacher-model latency).
- webhook_routes: add venice to KNOWN_PROVIDERS so the sync chat webhook
  can auto-resolve base_url from provider=venice.

Tests: tests/test_venice_hosts.py pins tool-host matching + SOTA
classification for Venice; py_compile on touched modules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 23:03:46 +09:00
RosenTomov
a493fb49b0 Use LM Studio-reported vision capability for image passthrough (#1130)
Read a model's capabilities.vision flag from LM Studio's native /api/v1/models so vision finetunes whose names lack a vision keyword still receive images, falling back to the name heuristic when the endpoint doesn't report it. The probe is short-TTL cached and restricted to local/LAN hosts, so remote/cloud endpoints are never contacted.
2026-06-02 23:01:04 +09:00
ghreprimand
06a3468967 Surface deep research probe errors (#1086)
Co-authored-by: ghreprimand <203024559+ghreprimand@users.noreply.github.com>
2026-06-02 22:51:25 +09:00
Tatlatat
dc8a882f1f fix(rag): use a stable hash for document IDs so dedup survives restarts (#1098)
add_document() and add_documents_batch() derive the persistent ChromaDB
document id from Python's built-in hash():

    doc_id = f"doc_{hash(text) % 10**16}"

str hashing is randomized per process (PYTHONHASHSEED is on by default), so
the same document text gets a different doc_id on every restart. The dedup
check right after — self._collection.get(ids=[doc_id]) — therefore misses
on restart, and identical documents are re-embedded and re-added as
duplicates each time the app restarts, bloating the vector store and
skewing retrieval.

Derive the id from a stable hashlib.sha256 of the text via a shared
_generate_doc_id() helper, used by both add paths so they agree.

tests/test_rag_vector_id_stability.py runs _generate_doc_id in subprocesses
under PYTHONHASHSEED=0/1/random and asserts the id is identical across all
of them (and differs for different text). Fails before this change.
2026-06-02 22:42:23 +09:00
pewdiepie-archdaemon
ff93a6c63b Polish email and cookbook flows 2026-06-02 22:42:07 +09:00
Afonso Coutinho
2e2da2aefe fix: extract_statistics drops large numbers and trailing % signs (#1153)
* fix: extract_statistics misses comma-less numbers and drops trailing %

* fix: same extract_statistics number/percent bug in services copy

* test: extract_statistics captures full numbers and percent signs
2026-06-02 22:35:30 +09:00
Afonso Coutinho
2b2943a7b7 fix: extract_quotes accepts mismatched opening/closing quotes (#1113)
* fix: only extract quotes whose closing quote matches the opening one

* fix: same mismatched-quote bug in the services search copy

* test: extract_quotes requires matching open/close quotes
2026-06-02 22:34:52 +09:00
ghreprimand
c075abce5d Search: consolidate core and provider implementations
Co-authored-by: ghreprimand <203024559+ghreprimand@users.noreply.github.com>
2026-06-02 21:02:26 +09:00
Robin Fröhlich
3c6ae3713e Models: add Z.AI coding endpoint and GLM vision detection 2026-06-02 20:59:17 +09:00
SurprisedDuck
934bca9e48 Providers: omit temperature for OpenAI reasoning models
* fix: omit temperature for OpenAI reasoning models (o1/o3/o4/gpt-5)

These models only accept the default temperature; sending any explicit
value (even 0.0) returns HTTP 400 "Only the default (1) value is
supported". This broke two paths:

- Endpoint probing in _probe_single_model hardcodes temperature: 0.0, so
  a perfectly valid o3/gpt-5 endpoint is reported as failing in the
  Model Endpoints health check.
- Chat/stream payloads send temperature unconditionally, so a non-default
  temperature preset 400s on these models.

The code already special-cases the same model family for
max_completion_tokens, so this adds a sibling _restricts_temperature()
helper and omits the field for those models, letting the API use its
required default. gpt-4.5 is intentionally excluded (not a reasoning
model; accepts temperature normally).

Adds tests/test_llm_core_temperature.py covering the predicate and the
synchronous payload builder.

* fix: also omit temperature for reasoning models on the direct-POST paths

The first commit only covered llm_call/llm_call_async/stream_llm and the
endpoint probe. Email auto-summary, urgency-less spam classification, the
email reply-summary endpoint, and gallery vision tagging build their
OpenAI payloads inline and POST them directly (requests/httpx), bypassing
llm_core — so a reasoning model configured there would still 400 on the
temperature field. These sites already branch on _uses_max_completion_tokens,
so they're the same class; added the matching _restricts_temperature guard.

gallery_routes also gains the max_completion_tokens branch it was missing,
so gpt-5 vision tagging works end to end.

Note: email_pollers urgency scoring goes through llm_call_async and was
already covered.
2026-06-02 20:58:33 +09:00
Nikita Rozanov
119075f368 Research: add configurable run timeout
Surfaces the research_run_timeout_seconds setting (added in #783) in
Settings → Research as a "Max Time" field, and lets 0 disable the
wall-clock cap entirely for long deep-research runs.

- settings.py: document that 0 disables the cap; default stays 1800s.
- research_handler.py: resolve 0 (or negative) to no timeout
  (asyncio.wait_for timeout=None); other values stay bounded to
  [60, 86400] as before.
- index.html / settings.js: "Max Time" input bound to
  research_run_timeout_seconds, validated to {0} ∪ [60, 86400], with
  copy making explicit that 0 = no limit (unbounded model/API cost).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:57:57 +09:00
Tushar-Projects
c3228f8b59 Background tasks: respect active session model fallback 2026-06-02 20:57:42 +09:00
LittleLlama
c85da91964 Tasks: ship email boundary task paused by default
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-02 20:53:02 +09:00
Leo
6c15dc7d33 Chat metrics: surface backend generation speed
* Chat metrics: show backend's true generation t/s, not tokens÷wall-clock

The per-message tokens/sec read low and felt wrong because it was computed as
output_tokens / total_duration, where total_duration is wall-clock including
prefill, tool calls, and network — not pure decode time. llama.cpp already
reports the correct gen speed in its stream (timings.predicted_per_second), but
it was being dropped.

- llm_core.py: when parsing the OpenAI-compatible usage chunk, also read the
  sibling `timings` block llama.cpp includes — pass predicted_per_second through
  as gen_tps and prompt_per_second as prefill_tps on the usage event.
- agent_loop.py: capture backend_gen_tps/backend_prefill_tps from usage events;
  in _compute_final_metrics prefer backend_gen_tps over the wall-clock division
  when present (fall back to computed for cloud APIs that omit timings). Tag the
  result with tps_source ("backend" vs "computed") and surface prefill_tps.

Result: the displayed t/s now matches the model's real decode speed and is
stable regardless of prompt length (a long prefill no longer deflates it).

Checks: py_compile passes; verified extraction against a real llama.cpp final
chunk (gen 79 t/s surfaced vs the deflated wall-clock figure shown before).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Chat metrics: surface true t/s on the direct-chat path too

Follow-up to the gen-tps work: the non-agent direct-chat stream path in
chat_routes turned the raw `usage` event straight into a metrics event but only
copied token counts — it never set tokens_per_second or response_time. So simple
(non-tool) replies showed "Speed: n/a" / "Time: undefineds" and the chip fell
back to a bare token count ("27 tok") instead of t/s.

Map the usage event's gen_tps (llama.cpp timings.predicted_per_second, added in
the prior commit) into tokens_per_second here too, tag tps_source=backend, and
set response_time from wall-clock for the stats popup.

Checks: py_compile passes; verified llama.cpp emits usage+timings on the final
stream chunk (gen ~90 t/s) that this path consumes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Tests: backend gen/prefill t/s passthrough and preference

Cover the two pieces of the true-t/s metric so it can be reviewed on its own:
- stream_llm surfaces llama.cpp's timings.predicted_per_second /
  prompt_per_second as gen_tps / prefill_tps on the usage event (captured
  llama.cpp final-chunk fixture), and omits them when the backend reports no
  timings.
- _compute_final_metrics prefers backend_gen_tps over output/wall-clock,
  tags tps_source ("backend" vs "computed"), and surfaces prefill_tps.

Reuses the fake-client stream harness from test_llm_core_streaming.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:52:08 +09:00
Ernest Hysa
064c1ace91 Uploads: write uploads index atomically
* fix(upload): atomic-rename writes for uploads.json + .bak recovery

UploadHandler.save_upload does a read-modify-write of uploads.json via
two open(..., 'w') + json.dump blocks, with no lock, no temp+rename, and
no recovery. N concurrent inserts lost N-1 entries (last writer wins
after the read snapshot is taken); a SIGKILL/SIGTERM mid-json.dump
truncated the file and the bare 'except Exception: logger.warning(...)'
recovery path returned {}, silently dropping every prior upload.

The handler now serialises the RMW under a per-instance threading.Lock
and writes through _atomic_write_json, which writes to a tempfile in
the same directory, fsyncs, snapshots the previous live to .bak, and
renames the temp onto the target via os.replace. os.replace is atomic
on POSIX, so a reader sees either the old or the new state, never a
half-written file. _load_upload_index tries the live file first, then
falls back to the .bak sibling if the live is corrupt.

Cross-process safety is still on the deployer: gunicorn workers on
the same uploads dir will race the lock, and the atomic-rename is the
kernel-level guarantee that prevents torn reads. If multi-worker
writes are expected, fcntl.flock around the rename is a follow-up;
single-worker and async deployments are correct as-is.

* fix(upload): reload uploads.json inside _index_lock on dedupe path

The duplicate-detection branch in save_upload() was reading uploads.json
*before* taking _index_lock, then writing that stale snapshot under the
lock. A duplicate upload racing with a new-entry insert could clobber
the new entry because the duplicate's snapshot predated the insert.

The new-entry branch already reloaded inside the lock; the duplicate
branch now does the same. It also re-resolves the storage key inside
the lock, because a concurrent insert can have changed the dict's keys.

If the entry has been cleaned up between the outer read and the inner
write, the function falls through to the fresh-insert path instead of
silently writing a stale row.

Boundary note: the _index_lock serialises writers within a single
Python process. Cross-process / multi-worker deployments still need
flock or a database; the inline comment is updated to make this
explicit. The atomic-rename write keeps the on-disk state consistent
but does not serialise writers across processes.

Tests:
- Existing concurrent-insert and partial-write-recovery tests still pass.
- New test_atomic_write_primitives_present_in_production_code asserts
  the production module has at least two 'with self._index_lock:' blocks
  (regression net for this fix).
- New smoke tests: normal upload, duplicate detection, info lookup
  after a backup-recovery scenario.
2026-06-02 20:51:39 +09:00
mechramc
8e87d3002b Tasks: clean up queued cancellation state 2026-06-02 20:51:21 +09:00
SurprisedDuck
f975279b26 Notes: parse natural-language due dates on update
The 'add' action runs due_date through parse_due_for_user (natural
language like 'tomorrow at 9am', plus user-tz anchoring for naive ISO),
but 'update' stored the raw value verbatim. A reminder edited with
natural language was saved as an unparseable literal the frontend's
new Date() can't read, so it never fired. Route update's due_date
through the same parser as add.
2026-06-02 20:51:16 +09:00
Tatlatat
7f97ab3032 Topics: hydrate session history before analysis
analyze_topics() iterates session_manager.sessions and reads
session_data.get("history", []) directly. But SessionManager.load_sessions
seeds sessions metadata-only with empty history — messages are loaded
lazily, only when get_session(session_id) is called. So analyze_topics saw
empty history for every session that hadn't been individually opened this
process lifetime and reported total_topics: 0, even when the database held
plenty of matching messages.

Hydrate each candidate session via session_manager.get_session(session_id)
(the existing lazy-load path) before reading its history, after the
owner/archived filters so skipped sessions aren't loaded. Falls back to the
raw cached history when the manager has no get_session (test stubs).

tests/test_topic_analyzer.py: new test_topic_analyzer_hydrates_sessions
seeds a real SQLite DB with a session + message, runs the real
SessionManager (asserting cached history starts empty), then asserts
analyze_topics finds the topic. Fails before this change. The existing
keyword tests now pass an explicit owner to satisfy the owner-required
early return.
2026-06-02 20:44:27 +09:00
SurprisedDuck
d73c0a13f4 YouTube: enforce comment fetch timeout while waiting
asyncio.wait_for wrapped create_subprocess_exec, which returns as soon
as the child is spawned, so the timeout never bounded the actual work.
yt-dlp could hang indefinitely on proc.communicate() and the
except asyncio.TimeoutError branch was unreachable. Bind the wait to
communicate() and kill/reap the child if it overruns.
2026-06-02 20:44:24 +09:00
Tatlatat
e084dc993e Chat: merge consecutive user messages for strict providers
After a non-native tool round, the agent appends tool results as a {role:
'user'} message next to the user's original 'user' prompt, producing two
consecutive 'user' messages. Strict provider APIs (Anthropic/Claude) reject
consecutive same-role messages, so the follow-up generation request fails
silently — search returns sources, then nothing is generated.

_sanitize_llm_messages now merges consecutive 'user' messages (joining their
content). Only user/user is merged; normal chat and agent/tool turns already
alternate and are untouched.

Scoped down per maintainer review: the agent_loop 'output' source-extraction
change is already on main (#898/#901) and the broad-mocking web-sources test
was dropped. Added a focused test that runs consecutive-user messages through
the real _build_anthropic_payload and asserts the payload alternates correctly.
2026-06-02 20:44:13 +09:00
Tatlatat
dac64f20d9 Text: strip dangling think blocks after visible text
`strip_think` removes a dangling (unclosed) `<think>` block via
`_THINK_OPEN_RE`, but that pattern was anchored to the start of the string
(`^\s*<think>`). An unclosed `<think>` (or `<thinking>`) opener that
appears *after* any leading output was therefore only half-handled: the
stray tag itself was removed by `_THINK_TAG_RE`, but the reasoning content
following it leaked straight to the user.

  strip_think("Hello! <think> I am thinking.")       # -> "Hello! I am thinking."  (leak)
  strip_think("Sure.\n<think>\nLet me reconsider...") # -> leaks the reasoning

`strip_think` feeds user-facing output across research, email replies,
notes, and scheduled tasks, so this leaks chain-of-thought to end users.

Un-anchor `_THINK_OPEN_RE` so a dangling opener anywhere strips from the
opener to end of string, consistent with the existing start-of-string
behavior. Content before the opener, closed `<think>...</think>` blocks,
and tag-free text are all preserved.

tests/test_strip_think.py covers the mid-text leak (fails before this
change), start-anchored unclosed, closed blocks, no-tag passthrough,
content-before-opener, and mixed closed+unclosed. Full existing think
suite still passes.
2026-06-02 20:36:37 +09:00
SurprisedDuck
62f06ab740 Docs: respect path boundary when clearing exclusions
add_directory cleared exclusions with a raw path.startswith(directory)
test, which also matched sibling directories sharing a name prefix —
adding /docs would silently un-exclude files under /docs2. Match the
directory itself or paths under it (directory + os.sep) instead.
2026-06-02 20:35:44 +09:00
SurprisedDuck
78747b56ca Documents: strip PDF marker without corrupting text
_process_pdf prepends "\n\n[PDF content]:" to extracted text, and two
call sites in document_routes.py stripped it with .lstrip("\n[PDF content]:").
str.lstrip(chars) treats its argument as a *set of characters*, so it keeps
eating into the page text that follows the marker — e.g. a body starting
with "to the board" loses its leading "to" because 't'/'o' are in the
marker's character set. Replace both sites with a shared
strip_pdf_content_marker() helper that uses str.removeprefix.
2026-06-02 20:35:27 +09:00
SurprisedDuck
4307cac966 Research: report empty search provider results clearly
Deep Research surfaced 'Error: unknown error' whenever every search
provider returned an empty result set without raising (e.g. SearXNG is
reachable but all its engines fail internally). _last_search_error was
only set on exceptions, so the empty-but-no-exception path left it unset
and the caller fell back to 'unknown error'.

Record an actionable reason on that path naming the providers that were
tried, so users can tell it's a search-backend problem rather than a
model problem. The provider-raised path is unchanged.

Re: #344.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:34:25 +09:00