Compare commits

...
Author SHA1 Message Date
Alexandre Teixeira 716e7f1e28 docs(style): improve roadmap and contributing Markdown layout 2026-11-11 16:56:17 +00:00
Alexandre Teixeira a130611340 docs: refresh roadmap and modularization contribution guidance 2026-11-11 16:42:22 +00:00
Léo 0276e15f6e docs(maintainers): define the feature proposal workflow (#7000)
The handbook says substantial features need Product Owner approval
before implementation, but not how a proposal gets there, what counts
as substantial, or what happens when no answer comes. Add the full
workflow: triage within 7 days, a decision brief, four product labels,
a lazy-consensus timeout that only applies to big-but-reversible work,
and definitions for every judgement call (small, big, reversible, new
direction, fits ROADMAP, objection, recorded decision).

The Feature Request form gets three optional fields for the brief, and
the PR template, CONTRIBUTING and triage point at the workflow.
2026-11-09 11:23:38 +01:00
34e4666f35 fix(agent): sanitize tool message predecessors to prevent provider errors (refs #6314) (#6476)
* fix(agent): normalize tool-call message order without dropping history

Restore the context compactor to the current dev implementation so the PR no longer removes unrelated helpers, then repair the predecessor contract on top of it.

The sanitizer now iterates one normalization pass to a fixpoint, so it validates each batch against its own effective output and a second sanitation run is a no-op. Assistant text turns immediately before a tool_calls message are merged into it, preserving completed call/result evidence when the transcript was trimmed mid-history. Batches that still lack a user or tool predecessor are stripped or dropped as before.

* fix(agent): preserve multimodal content when dropping invalid tool calls

---------

Co-authored-by: ShahabAhmed01 <192491541+ShahabAhmed01@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-09 02:07:22 +00:00
red person 1c15090949 fix(calendar): render multi-day events within their day cells (#6948) 2026-11-08 02:44:56 +00:00
PrinceandAlexandre Teixeira 37454f9d34 fix: recognize Conifer as a known provider endpoint (#6429)
* fix: recognize Conifer as a known provider endpoint

Recognize Conifer (api.conifer.build) in providers.js endpoint labels and map conifer.build to the openrouter curation sentinel in routes/model_routes.py so server-returned models are displayed as curated while preserving OpenAI wire compatibility.

Fixes #6205

* test(providers): remove trailing blank line

---------

Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-08 02:27:35 +00:00
Grzegorz ae03bb8363 fix(chat): show the timeout message instead of an empty bubble (#6764) (#6768)
_isAgent was a const inside the try block of handleChatSubmit, but the
catch block reads it to pick the timeout message, so a client timeout
threw a ReferenceError and left the reply bubble empty. Declare it next
to the other catch-visible variables and assign it where it is computed.
2026-11-08 02:02:37 +00:00
Yu-Wei ChangandAlexandre Teixeira e4cc8efa19 fix(search): keep reasoning out of web search queries and stop misrouting named targets (#7054)
* fix(llm): keep reasoning out of answers when thinking is off

Small-budget utility calls on reasoning models spend every token on
reasoning, and llm_call then returned reasoning_content as the answer. Web
search extraction sent that text to SearXNG as the query, which found
nothing, and session titles fell back as unusable.

Treat thinking_mode="off" as "only the answer": non-streaming calls now
return empty instead of substituting reasoning, matching the streaming path.
Callers that do not turn thinking off keep the existing fallback.

Turn thinking off for search query extraction, session titles without a
configured effort, scheduled task names, and deep research classification,
and strip inline think blocks from the extracted query.

* fix(routing): require computer task wording for named hosts

"on <word>" and "from <word>" counted as a named computer on their own, so
"Who is Jensen Huang from NVIDIA?" and "What happened on Monday?" routed to
shell tools and the workspace toolset.

A target now only counts next to wording that describes work on a machine,
such as "check disk usage on gpu-box" or "copy the logs from gpu-box". The
intent classifier and the agent loop's local computer check share the pattern.

* fix(chat): resolve follow-up references in web search queries

Query extraction only saw the latest message, so "Does he work with Lisa
Su?" lost who "he" was. Include the last four turns, capped per turn, and
instruct the model to use them only to resolve references. Incognito turns
do not use saved session history.

* test(llm): stop reasoning fallback tests hanging in isolation

The module replaced src.agent_tools and core.database with MagicMock before
importing agent_loop. strip_tool_blocks then returned a mock, and
_strip_think_blocks looped forever because a mock's find() never returns -1.
This hung whenever the file ran alone or first on a worker. Import the real
modules instead.

* test(search): remove extra blank line at EOF

---------

Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-06 02:11:20 +00:00
Yu-Wei Chang 1844b084bc refactor(cookbook): move endpoint registration out of the routes closure (#7031)
* refactor(cookbook): move endpoint registration out of the routes closure

_auto_register_llm_endpoint and _auto_register_image_endpoint use no
closure state and model_serve is their only caller, so they can live in a
plain module and be imported and tested directly. Bodies are unchanged
apart from indentation.

Tests now import the helpers instead of reading them from the route's
closure. That lookup also failed if the route stopped referencing a
helper, so add a test that pins which helper model_serve calls.

Part of #4071

* fix(cookbook): bound the OLLAMA_HOST port scan

The pattern rescanned the rest of the token from every OLLAMA_HOST=
occurrence, so a long serve command with repeated assignments took
quadratic time. model_serve runs this on the event loop, and a 192 KB
command stalled the whole app for about 9s.

Cap the host at 253 characters, the longest DNS name, so every valid host
still matches.

Part of #4071
2026-11-06 02:00:44 +00:00
39 changed files with 1642 additions and 628 deletions
@@ -14,6 +14,8 @@ body:
If your idea needs community input before it becomes a concrete proposal,
start a [discussion](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas) instead.
How a proposal is triaged and decided: [product and feature proposals](https://github.com/odysseus-dev/odysseus/blob/dev/docs/maintainers/product-and-features.md#feature-proposal-workflow).
- type: checkboxes
id: prerequisites
attributes:
@@ -79,6 +81,32 @@ body:
label: Prior Art / Related Issues
description: Link any related issues, discussions, or external references that informed this proposal.
- type: dropdown
id: scope
attributes:
label: Size and reversibility
description: Your best guess; the triager decides. Definitions are in the maintainer handbook (product-and-features.md).
multiple: true
options:
- "Small, reversible change within ROADMAP"
- "Big (more than one PR, or changes existing default behaviour)"
- "New direction (new area, multi-user, data leaving the machine, ...)"
- "Hard to undo (migration, deleted data, changed contract or permissions)"
- "Not sure"
- type: textarea
id: decision_questions
attributes:
label: Questions for the Product Owner
description: If this needs a product decision, the exact yes/no questions — one per independent part.
placeholder: "1. Should notes be shareable between accounts on one instance? (yes/no)"
- type: textarea
id: evidence
attributes:
label: Screenshots, recording or demo
description: For anything that renders, attach screenshots or a short recording. Link a demo branch if you have one.
- type: dropdown
id: willing_to_implement
attributes:
+3
View File
@@ -13,6 +13,9 @@
Private maintainer-preview PRs may instead use:
N/A — maintainer integration work
New features: the linked issue carries `product: approved` or
`product: maintainer decision` (docs/maintainers/product-and-features.md).
-->
Fixes #
+107 -63
View File
@@ -1,28 +1,50 @@
# Contributing to Odysseus
Thanks for helping. The project is moving quickly, so the best contributions are focused, easy to review, and easy to test.
Thanks for helping improve Odysseus. The best contributions are **focused, reviewable, and backed by meaningful validation**.
> [!IMPORTANT]
> **Public pull requests target `dev`, not `main`.** Search existing work before implementing, and follow the current [PR template](.github/pull_request_template.md). Runtime changes require relevant execution evidence, not just a green CI badge.
## At a glance
| Topic | What contributors should do |
| --- | --- |
| **Branch** | Target `dev`; `main` is the curated release branch |
| **Scope** | One coherent change per PR; coordinate larger modularization work through [#4071](https://github.com/odysseus-dev/odysseus/issues/4071) |
| **Validation** | Document commands, results, runtime checks, and anything not tested |
| **Visual changes** | Run the UI, match the existing visual language, and attach screenshots or clips |
| **Review** | Follow the [risk-based maintainer policy](docs/maintainers/changes-and-review.md) and applicable repository protections |
| **Security** | Report vulnerabilities privately using [SECURITY.md](SECURITY.md) |
**Jump to:** [Branches](#branch-model) · [Getting started](#before-you-start) · [Setup](#setup) · [Checks](#running-checks) · [Pull requests](#pull-requests) · [Modularization](#structural-changes-and-modularization) · [Visual changes](#style-and-visual-changes) · [Code conventions](#code-conventions) · [Issue reports](#issue-reports)
---
## Branch model
Odysseus has two branches:
| Branch | Purpose |
| --- | --- |
| **`dev`** | Default integration branch and target for every public PR. Work can be in flux. |
| **`main`** | Curated release branch. A [Release Maintainer](docs/maintainers/testing-and-releases.md#release-ownership) promotes a verified `dev` commit. |
- **`dev`** — where all PRs land. Things can be in flux here; the merge button gets used freely.
- **`main`** — what users run. Curated and tested by the release's [Release Maintainer](docs/maintainers/testing-and-releases.md#release-ownership). Fast-forwarded to a stable `dev` commit at each release.
If your PR accidentally targets `main`, use **Edit** on GitHub to change its base to `dev`. Changing the base does not itself require a rebase.
**Open your PR against `dev`, not `main`.** The GitHub "base" dropdown defaults to `dev`. If you opened a PR against `main` by accident, click "Edit" on the PR and change the base — no rebase needed.
End-users cloning the repo will land on `dev` by default. To run the curated/stable version: `git checkout main` after clone.
End users cloning the repository land on `dev` by default. To use the curated release branch, run `git checkout main` after cloning.
## Before You Start
- Search existing issues and pull requests before opening a new one.
- Prefer one bug fix or feature per pull request.
- Avoid broad rewrites, formatting-only changes, or moving many files unless the issue is specifically about structure.
- If you want to work on a large feature, open an issue first and describe the approach.
1. Search [open issues](https://github.com/odysseus-dev/odysseus/issues) and [PRs](https://github.com/odysseus-dev/odysseus/pulls) to avoid duplicate or conflicting work.
2. Prefer one bug fix, feature, or coherent refactor per PR. Avoid broad rewrites, formatting-only churn, and unrelated file moves.
3. For a feature, open a **Feature Request** first. Big, new-direction, or difficult-to-reverse changes require the documented [product decision process](docs/maintainers/product-and-features.md#feature-proposal-workflow).
4. For structural work, coordinate the intended module boundary with maintainers and the [modularization tracker #4071](https://github.com/odysseus-dev/odysseus/issues/4071).
> [!NOTE]
> **Coding agents:** If you use an LLM coding agent, open an issue describing the problem before submitting its PR. Bulk agent-generated submissions that ignore the project's contribution format or visual style may be closed without review, even when the underlying fix is correct.
## Setup
Docker is the recommended path for normal testing:
<details open>
<summary><strong>Docker (recommended for normal testing)</strong></summary>
```bash
git clone https://github.com/odysseus-dev/odysseus.git
@@ -31,7 +53,10 @@ cp .env.example .env
docker compose up -d --build
```
Manual development uses Python 3.11+:
</details>
<details>
<summary><strong>Manual Python development (Python 3.11+)</strong></summary>
```bash
python3 -m venv venv
@@ -40,11 +65,14 @@ pip install -r requirements.txt
python -m uvicorn app:app --host 127.0.0.1 --port 7000
```
Windows is not actively tested. Docker on Linux or a Linux/macOS manual install is the safer path for now.
</details>
> [!NOTE]
> Native Windows is not actively tested. Docker on Linux or a Linux/macOS manual installation is the safer path for now. Document the environment actually used for testing.
## Running Checks
Run the smallest relevant checks for your change:
Run relevant checks for the affected area. Examples from the existing contributor workflow:
```bash
python -m pytest
@@ -60,78 +88,94 @@ docker compose up -d --build
docker compose logs --tail=120 odysseus
```
Mention what you ran in the pull request description. If you could not run a check, say so.
> [!IMPORTANT]
> **Report what actually ran.** Include commands, results, and relevant manual/runtime verification in the PR description. If a check was not run, mark it as **not run** and explain the limitation. Passing syntax checks and unit tests do not automatically prove changed behavior works in the running application.
See the [testing and releases handbook](docs/maintainers/testing-and-releases.md) for risk-based validation and isolation requirements.
## Maintainer governance and review guidance
For repository roles, product decisions, risk-based approvals, issue triage, independent security reviews, CodeQL findings, and release handoffs, see the [Odysseus maintainer handbook](docs/maintainers/README.md). This complements the contributor requirements below and does not change the branch model or current PR template.
The [Odysseus maintainer handbook](docs/maintainers/README.md) is authoritative for repository roles, product decisions, risk-based approvals, issue triage, security review, CodeQL findings, and release handoffs.
> [!NOTE]
> The handbook complements this contributor guide. It does not override the branch model, live GitHub protection rules, or the PR template.
## Pull Requests
Good pull requests usually include:
Keep PRs **cohesive**. Small, focused changes are the default. A larger, coordinated module decomposition may be appropriate under the criteria below, but unrelated cleanup, formatting, refactors, and behavior changes must not be mixed.
- A short explanation of the bug or feature.
- The files or areas changed.
- Manual test steps or automated test results from running the actual app, not just the test suite.
- Screenshots or short recordings for UI changes.
- Links to related issues, for example `Fixes #123`.
| Include in the PR | Why it matters |
| --- | --- |
| **Summary and scope** | Explain the problem, intended behavior, and areas changed |
| **Linked issue** | Connect the PR to the existing report or approved work, for example `Fixes #123` or `Part of #4071` |
| **Validation evidence** | Show manual test steps or automated results that exercise the actual behavior |
| **UI evidence, when applicable** | Attach screenshots or short recordings from the running application |
| **Limitations** | Clearly identify tests or environments that were not exercised |
Please keep PRs small. Large PRs that mix unrelated cleanup, formatting, refactors, and behavior changes are much harder to review.
For exact required sections and attestations, follow the current [PR template](.github/pull_request_template.md).
> **Auto-generated PRs.** If you are running an LLM agent (Devin, Cursor, OpenHands, Claude Code, etc.) against this repo: please open an issue describing the problem first instead of opening a PR directly. Bulk agent-generated PRs that don't match the project's visual style or contribution format will be closed without review, even when the underlying fix is correct.
### Structural changes and modularization
**One coherent responsibility is the unit of review.** A larger PR can be appropriate when extracting that responsibility from an oversized module is safer or simpler than splitting the work into many conflicting or interdependent PRs.
Before implementation and review:
- **Coordinate:** Read existing issues/PRs and agree on the boundary with maintainers. Use [#4071](https://github.com/odysseus-dev/odysseus/issues/4071) for codebase modularization.
- **Constrain scope:** Keep one subsystem or responsibility per PR. Avoid unrelated features, formatting, and opportunistic cleanup.
- **Preserve contracts:** Maintain observable behavior, imports, public interfaces, persistence contracts, and runtime invariants unless an approved issue explicitly scopes a change.
- **Validate behavior:** Include targeted regression tests and runtime checks. Frontend work needs browser validation and screenshots or recordings when rendering may change.
- **Explain tradeoffs:** Describe extraction boundaries, compatibility, validation results, and known limitations in the PR.
> [!TIP]
> **Large does not mean broad.** There is no arbitrary line-count limit for a well-scoped extraction. Correctness, reviewability, and integration risk determine whether the scope is acceptable.
> [!CAUTION]
> **Temporary Harness V2 coordination boundary:** Unrelated changes to `src/agent_loop.py` and closely related execution, authorization, orchestration, and context contracts are temporarily frozen while Harness V2 is developed and validated. Coordinate such work with maintainers. Other modules remain open, and urgent bug or security reports are welcome.
These contributions remain subject to the [risk-based review policy](docs/maintainers/changes-and-review.md), current repository protections, and PR template.
## Style and visual changes
Odysseus has an intentional visual style. PRs that ignore it will be closed without merge, no matter how correct the underlying code is.
Odysseus has an intentional visual language. Changes that ignore it can be closed without merge even when their underlying code is correct.
Before submitting any change that affects what the app looks like — buttons, icons, fonts, colors, spacing, layout, CSS, HTML, SVG, or any `static/js/` module that draws to the DOM — please:
For any change affecting buttons, icons, typography, colors, spacing, layout, CSS, HTML, SVG, or JavaScript that renders into the DOM:
1. **Run the app locally** and view the change in a browser. Type-checks and unit tests are not enough.
2. **Attach a screenshot or short clip** of the change in the running app. Add a mobile screenshot too if the change affects mobile.
3. **Match the existing visual language.** Specifically:
- Reuse existing CSS variables (`--red`, `--fg`, `--bg`, `--card`, `--border`, …). Do not introduce new color values, font sizes, or spacing units.
- Reuse existing button, input, card, and border classes. Don't invent parallel styling for similar widgets.
- **No Unicode emoji in UI or code.** Use inline SVG (matching the monochrome icon style already in `static/index.html`) or plain text.
- Monospaced font (`Fira Code`) for primary UI text. Don't override.
- Dark theme is the default; any light-mode work goes through the existing theme system, not hard-coded.
4. **Don't add parallel components.** If a similar widget already exists in the app, extend it instead of writing a new one.
1. **Run the application locally** and inspect the change in a browser. Type checks and unit tests alone are insufficient.
2. **Attach a screenshot or short clip** from the running app, plus a mobile screenshot when mobile behavior changes.
3. **Reuse the existing design system:**
- Prefer existing CSS tokens (`--red`, `--fg`, `--bg`, `--card`, `--border`, etc.). Do not introduce parallel colors, type scales, or spacing systems.
- Reuse existing button, input, card, and border classes rather than creating duplicate patterns.
- Do **not** add Unicode emoji in UI or code. Use inline SVG consistent with `static/index.html` or plain text.
- Preserve the existing primary monospaced font (`Fira Code`).
- Keep dark mode as the default; route any light-mode work through the existing theme system.
4. **Extend existing components** instead of introducing a parallel component for the same purpose.
If you are unsure whether a change is "visual," it is. Default to attaching a screenshot.
> [!IMPORTANT]
> If you are unsure whether a change is visual, assume that it is and provide a screenshot. Visual evidence does not replace behavioral validation.
## Code conventions
Don't hardcode values that the project already exposes through a constant or a helper. Hardcoded literals drift out of sync, break on non-default deployments, and reintroduce bugs we've already fixed.
**Use existing constants and helpers.** Hardcoded duplicates drift, break non-default deployments, and can reintroduce previously fixed issues.
- **Filesystem paths:** never build writable paths from `Path(__file__)...` into the source tree, hardcode `/app/...`, or use a relative `"data/..."` string. Every persisted file and directory has a named constant in `src/constants.py` (for example `AUTH_FILE`, `USER_PREFS_FILE`, `SETTINGS_FILE`, `TTS_CACHE_DIR`, `CHROMA_DIR`). Import and use that named constant; do not re-derive the path locally with `os.path.join(DATA_DIR, "x.json")` or `DATA_DIR / "x.json"`. `DATA_DIR` is the single place that reads `ODYSSEUS_DATA_DIR`, so use it directly only for dynamic paths that have no fixed name (for example per-owner files). If a data file or directory has no constant yet, add one to `src/constants.py`. The source tree is read-only in Docker and `/app/...` does not exist on native runs; guard directory creation so an unwritable path degrades gracefully instead of crashing at import.
- **Internal API / loopback URLs:** don't hardcode `http://localhost:7000`. Use `internal_api_base()` from `src.constants` (it honors `ODYSSEUS_INTERNAL_BASE` / `APP_PORT`).
- **Ports, limits, model lists, and similar:** reuse the existing constant if one exists; if it doesn't and the value is used in more than one place, add a constant rather than copying the literal.
- **Filesystem paths:** Do not derive writable paths from `Path(__file__)...` into the source tree, hardcode `/app/...`, or use a relative `"data/..."` string. Persisted files and directories have named constants in `src/constants.py`, such as `AUTH_FILE`, `USER_PREFS_FILE`, `SETTINGS_FILE`, `TTS_CACHE_DIR`, and `CHROMA_DIR`. Import those names rather than deriving paths with `os.path.join(DATA_DIR, "x.json")` or `DATA_DIR / "x.json"`. `DATA_DIR` reads `ODYSSEUS_DATA_DIR` and is intended directly only for dynamic paths without fixed names, such as per-owner files. Add a constant when a fixed path does not yet have one. The source tree is read-only in Docker, and `/app/...` does not exist on native runs. Guard directory creation so unwritable paths degrade gracefully rather than crashing at import.
- **Internal API and loopback URLs:** Do not hardcode `http://localhost:7000`. Use `internal_api_base()` from `src.constants`, which honors `ODYSSEUS_INTERNAL_BASE` and `APP_PORT`.
- **Ports, limits, and model lists:** Reuse existing constants. When a value is shared by multiple locations and has no constant, add one in `src/constants.py` instead of duplicating a literal. `core/constants.py` is a compatibility re-export, not another source of truth.
If you need a value that has no constant or helper yet, add it to `src/constants.py` (the single source of truth for paths and config; `core/constants.py` only re-exports it for backward compatibility) and import it, rather than repeating a literal across files.
**Commits:** use [Conventional Commits](https://www.conventionalcommits.org), `type(scope): summary` (e.g. `fix(search): ...`, `feat(notes): ...`, `docs(contributing): ...`). Common types: `fix`, `feat`, `refactor`, `docs`, `test`, `chore`, `ci`. Keep the subject short and imperative; put the "why" in the body when it isn't obvious.
**Commits:** Use [Conventional Commits](https://www.conventionalcommits.org) with `type(scope): summary`, for example `fix(search): ...`, `feat(notes): ...`, or `docs(contributing): ...`. Common types include `fix`, `feat`, `refactor`, `docs`, `test`, `chore`, and `ci`. Keep the subject short and imperative, with the motivation in the body when useful.
## Issue Reports
For bugs, include:
| Bug reports should include | Model-serving reports should additionally include |
| --- | --- |
| Installation method (Docker, Python, WSL, etc.) | Backend (Ollama, vLLM, SGLang, llama.cpp, LM Studio, etc.) |
| OS, browser, and device when relevant | Model name |
| Exact reproduction steps | GPU/CPU and operating system |
| Expected versus actual behavior | Cookbook task logs or backend/server logs |
| Relevant logs, screenshots, or terminal output | Relevant backend configuration |
- Install method: Docker, manual Python, WSL, etc.
- OS, browser, and device if relevant.
- Exact steps to reproduce.
- Expected behavior and actual behavior.
- Logs, screenshots, or terminal output.
For model-serving issues, include:
- Backend: Ollama, vLLM, SGLang, llama.cpp, LM Studio, etc.
- Model name.
- GPU/CPU and operating system.
- Cookbook task logs or server logs.
Issues with only "help", "does not work", or a screenshot without context may be closed as not actionable.
Issues containing only "help", "does not work", or an unexplained screenshot may be closed as not actionable. Start with a reproducible problem or use [Discussions](https://github.com/odysseus-dev/odysseus/discussions) for setup questions.
## Security
Do not post secrets, API keys, private logs, personal documents, or public IPs in issues or pull requests.
For security reports, follow [SECURITY.md](SECURITY.md).
> [!CAUTION]
> **Do not publish secrets.** Keep API keys, sensitive logs, personal documents, and public IP addresses out of public issues and PRs. Report vulnerabilities privately through [SECURITY.md](SECURITY.md).
+104 -74
View File
@@ -1,87 +1,117 @@
# Roadmap / Help Wanted
Odysseus is on a voyage, but not home yet. It works great for me (lol), but this ship is moving fast and feedback/help would be appreciated! (I don't know what I'm doing, help).
Odysseus is an evolving, self-hosted AI workspace. This roadmap highlights where improvements and contributions are most useful, from reliability and maintainability to product capabilities.
If you see weird CSS, strange layout behavior, or a suspiciously murky corner of
the codebase, you are probably right to stay away.
> [!NOTE]
> **Direction, not a release schedule.** This page is not a complete issue backlog or a promise to deliver every proposal. Implementation scope and priority are coordinated through existing issues, pull requests, and the project's [feature proposal process](docs/maintainers/product-and-features.md).
## High Priority
## At a glance
- SQUASH BUGS
- Fresh install smoke tests on Linux, macOS, and Windows. Docker, native Python,
and WSL all need coverage.
| Area | Direction | Coordination |
| --- | --- | --- |
| **Stabilization** | Reliability, CI, fresh installs, security, and integrations | [Stabilization & Modularization milestone](https://github.com/odysseus-dev/odysseus/milestone/1) |
| **Modularization** | Clearer module ownership, safer changes, and contributor onboarding | [Canonical tracker #4071](https://github.com/odysseus-dev/odysseus/issues/4071) |
| **Agent runtime** | Active Harness V2 development, with a narrow temporary change freeze | [Coordination in #4071](https://github.com/odysseus-dev/odysseus/issues/4071) |
| **Product improvements** | Local models, research, frontend experience, and operations | [Issues](https://github.com/odysseus-dev/odysseus/issues) and [feature proposals](docs/maintainers/product-and-features.md) |
- Integration audit: do integrations even work? Confirm what works, what needs setup docs, and what should be removed or hidden.
- Cookbook reliability on other computers. This is probably the area most likely to need work across different machines, GPUs, drivers, shells, and Python environments.
- Cookbook SGLang support across platforms. Make sure SGLang setup/serve works
predictably on Linux, Windows/WSL, macOS where possible, Docker, and common
NVIDIA/AMD hardware paths.
- Deep Research model presets by hardware. Recommend approved model/parameter
profiles for small, medium, and large local setups so people with different
hardware can use Deep Research without guessing. Surface this either in Deep
Research settings or as a Cookbook scan/dropdown suggestion.
- Cookbook model scan/download ranking. Prioritize newer architectures and
better hardware-fit models instead of scoring everything almost the same.
Ranking should account for architecture age, quant format, VRAM/RAM fit,
backend support, vision/mmproj requirements, and likely serve reliability.
- Cookbook error feedback and logging. Failed downloads, dependency installs,
preflights, and serve jobs should show the actual command/output/error in the
UI, with copyable logs and clear next steps instead of just "crashed".
- Agent prompt/context bloat. Agent mode is too heavy for smaller local models:
tool schemas, skills, memory, documents, and instructions can eat the context
before the user request really starts. We need slimmer prompts, better tool
selection, smaller default tool sets, and clearer guidance for models with
4k/8k/16k context windows.
- Local model speculative decoding support. For Odysseus-tuned local models,
plan to ship or recommend a small same-tokenizer draft model when the serving
backend supports it. Early vLLM testing showed a generic `Qwen3-0.6B` draft
beside `Qwen3-8B` can materially reduce wall time, while an unsupported
DSpark conversion performed poorly. Treat this as a supported draft-model lane
first; keep MTP-specific packaging as future work only when the architecture
and runtime support are real. Judge this by time-to-success, tool correctness,
grammar, and unchanged target output, not tokens/sec alone.
- Skill/tool prompt-injection audit. User-editable skills, notes, documents,
fetched pages, and memories should be treated as untrusted data. Keep testing
whether models follow malicious instructions from those surfaces.
- Better degraded-state reporting for ChromaDB, SearXNG, email, ntfy, and provider probes.
- Email performance audit. Fetching, searching, opening, deleting, and sending
email can feel slow, especially over IMAP/SMTP providers with high latency.
Need someone who knows mail performance to profile the current flow, identify
whether the bottleneck is IMAP folder select/fetch, cache invalidation,
attachment/body loading, SMTP handshakes, or frontend refresh behavior, then
propose safer caching/prefetch/batching without breaking multi-account state.
- Provider setup/probing audit for Anthropic, Gemini, Groq, xAI, OpenRouter, OpenAI, and DeepSeek.
**Jump to:** [Stabilization](#stabilization-and-reliability) · [Modularization](#modularization-and-maintainability) · [AI and local models](#ai-local-models-and-research) · [Frontend](#frontend-and-user-experience) · [Backend](#backend-and-operations) · [Contributing](#contributing)
## Refactor Targets
- CSS cleanup. `static/style.css` basically Calypso's island atm.
- Tour core helper. The onboarding tours have too much copy-pasted scaffolding; promote a shared `tour-core.js` helper before adding more tours.
- Modal/window positioning cleanup. Some window controls have improved, but the
underlying popup/dropdown/fixed-position behavior is still too fragile.
- Mobile media override discoverability. A lot of "CSS did not move" bugs are mobile `@media` overrides of the same selector; comments or linting around desktop/mobile paired rules would help.
- Dead code pass for old routes, stale feature flags, and unused UI states.
---
## Frontend
## Stabilization and reliability
- Expand the Editor for quicker, more robust everyday use. Better file/document
handling, smoother window behavior, clearer save/export flows, stronger image
editing affordances, and fewer brittle edge cases.
- Better AI integration for Notes and Todos. Notes should be easier for the
agent to read, update, summarize, and turn into actions. Todos should be
assignable to an agent from the UI, possibly through a button, task action,
or dedicated skill/tool flow.
- Mobile gallery/editor polish. Easier to launch/download inpaint model or any missing pieces.
- Accessibility pass: keyboard navigation, focus states, contrast, reduced motion.
- Improve empty states and error messages on fresh installs.
- Tighten first-run setup, hints, and tours so they do not repeat or fight each other.
- Vendor CDN assets eventually for a more fully self-hosted/offline mode.
**Priority:** Make Odysseus dependable across installations, platforms, providers, and supported hardware.
## Backend
| Workstream | What needs improvement | Tracking |
| --- | --- | --- |
| **CI and validation** | Exact-revision validation, meaningful regression coverage, and release confidence | [#5932](https://github.com/odysseus-dev/odysseus/issues/5932), [#7109](https://github.com/odysseus-dev/odysseus/issues/7109) |
| **Test quality** | Deterministic tests, isolation, focused execution, and maintainable test structure | [#2523](https://github.com/odysseus-dev/odysseus/issues/2523), [testing standard](tests/TESTING_STANDARD.md) |
| **Fresh installations** | Smoke tests for Linux, macOS, Windows, WSL, and Docker; baseline startup and graceful degradation | [#3968](https://github.com/odysseus-dev/odysseus/issues/3968) |
| **Email integration** | Remaining Google OAuth regressions, account isolation, and cross-provider reliability | [#5637](https://github.com/odysseus-dev/odysseus/issues/5637) |
| **Storage integrity** | Upload payload growth, indexing, and attachment/file lifecycle | [#4889](https://github.com/odysseus-dev/odysseus/issues/4889) |
- More tests around endpoint probing and provider setup.
- Better task scheduler defaults and visibility.
- Backup/restore guide and helper flow for `data/`.
- Security hardening around admin-only tools and clear docs for their risk.
Additional reliability priorities:
## Not The Focus Right Now
- **Cookbook:** Improve model installation, dependency management, hardware detection, and model serving across operating systems, GPUs, drivers, shells, Python environments, and supported backends.
- **Security:** Keep strengthening authorization, administrative tools, agent tool execution, prompt-injection boundaries, filesystem access, and untrusted-content handling.
- **Integrations and degraded states:** Audit supported providers and optional services, including ChromaDB, SearXNG, email, and ntfy. Make failures observable and nonessential outages predictable.
I prob shouldnt add more themes.
> [!TIP]
> Start with an existing, reproducible issue. The [Stabilization & Modularization milestone](https://github.com/odysseus-dev/odysseus/milestone/1) is a curated work queue, not an invitation to implement every item in this roadmap without coordination.
## Modularization and maintainability
The goal is **clearer responsibility boundaries and safer development**, not smaller files for their own sake. Changes should preserve behavior, compatibility, and relevant runtime contracts.
| Area | Current foundation | Remaining opportunities |
| --- | --- | --- |
| **CSS** | The former `static/style.css` has been split into ordered files under `static/css/` | Improve ownership while preserving the cascade, themes, and responsive layouts |
| **Tools** | Substantial tool-domain extraction has landed | Clarify remaining ownership and preserve compatibility through the existing tool interfaces |
| **Frontend** | Existing feature modules remain in use | Decompose large document, gallery, settings, calendar, and other UI modules around coherent behavior |
| **Backend** | Domain-specific routes and services already exist | Identify safe boundaries in Cookbook routes, persistence, and other tightly coupled modules |
| **Tests** | Taxonomy, focused execution, and structural improvements exist | Align fixtures, helpers, and regression coverage with implementation boundaries |
| **UI infrastructure** | Targeted cleanup is underway | Consolidate tour scaffolding, modal/window positioning, responsive overrides, and obsolete paths |
**Canonical coordination:** [Codebase modularization and contributor onboarding, #4071](https://github.com/odysseus-dev/odysseus/issues/4071).
> [!IMPORTANT]
> **Cohesive work is more important than a small line count.** A larger PR may be appropriate for one well-defined decomposition when several smaller PRs would create avoidable conflicts or integration overhead. Coordinate the boundary, avoid unrelated work, and supply regression and runtime evidence. See [CONTRIBUTING.md](CONTRIBUTING.md#structural-changes-and-modularization).
### Agent runtime and Harness V2
Harness V2 is under active development.
> [!CAUTION]
> **Temporary agent-runtime freeze.** Unrelated changes to `src/agent_loop.py` and closely related execution, orchestration, authorization, and context contracts must be coordinated with maintainers while Harness V2 is developed and validated.
>
> This does **not** freeze unrelated application modules. Urgent bug reports and security issues remain welcome. No release date is implied.
## AI, local models, and research
These are **areas of product interest**, not commitments to a specific implementation or delivery order. Follow normal issue triage and product-decision requirements.
| Direction | Opportunity |
| --- | --- |
| **Smaller-model agents** | Reduce unnecessary prompt, tool-schema, skill, memory, and document context overhead; improve tool selection for constrained context windows |
| **Cookbook and SGLang** | Improve installation and serving across relevant hardware, operating systems, and deployment methods |
| **Model discovery** | Rank models by architecture, quantization, RAM/VRAM fit, backend support, multimodal requirements, and expected reliability |
| **Deep Research presets** | Explore suitable model and parameter configurations for small, medium, and large local systems |
| **Actionable diagnostics** | Expose command output, logs, and next steps for failed downloads, dependencies, preflights, and serving jobs |
| **Speculative decoding** | Investigate compatible draft-model configurations and evaluate end-to-end correctness and time to success |
| **Provider setup** | Improve endpoint discovery, capability checks, and setup for supported local and hosted providers |
## Frontend and user experience
- **Editors and files:** Improve document/file editing, window behavior, save/export flows, and image-editing reliability.
- **Notes and Todos:** Expand agent-assisted reading, updates, summarization, and turning information into tasks.
- **Mobile:** Improve gallery/editor usability, model setup, and responsive behavior.
- **Accessibility:** Strengthen keyboard navigation, focus states, contrast, and reduced-motion support.
- **First run:** Improve setup, hints, empty states, error messages, and onboarding tours without duplicate guidance.
- **Self-hosting:** Reduce unnecessary external asset dependencies where practical for offline operation.
## Backend and operations
- **Email performance:** Measure fetching, searching, body and attachment loading, caching, sending, and frontend refresh behavior before optimizing.
- **Provider reliability:** Expand regression coverage for setup, endpoint probing, and degraded-service behavior.
- **Task scheduling:** Improve defaults, reliability, and operational visibility.
- **Data recovery:** Improve backup and restore guidance and workflows for persistent data.
- **Administrative security:** Review privileged capabilities and sensitive integrations against their authorization boundaries.
## Not a current priority
- Additional themes without a demonstrated product need.
- Broad rewrites or framework migrations without clear, measurable value.
- Changes undertaken solely to reduce file counts.
- Uncoordinated changes to temporarily frozen agent-runtime contracts.
---
## Contributing
1. Read [CONTRIBUTING.md](CONTRIBUTING.md) and search [existing issues](https://github.com/odysseus-dev/odysseus/issues) and [PRs](https://github.com/odysseus-dev/odysseus/pulls).
2. Coordinate modularization through [#4071](https://github.com/odysseus-dev/odysseus/issues/4071) and active stabilization work through the [milestone](https://github.com/odysseus-dev/odysseus/milestone/1).
3. Use the repository's issue and PR templates and target **`dev`**.
4. Include relevant tests, runtime evidence, and screenshots or recordings for visual changes.
> [!NOTE]
> Product-direction or difficult-to-reverse changes follow the [feature proposal process](docs/maintainers/product-and-features.md). Report security vulnerabilities privately through [SECURITY.md](SECURITY.md), never in a public issue.
+2 -1
View File
@@ -9,6 +9,7 @@ This handbook records the project's maintainer governance and gives reviewers a
| Area | Working rule |
|---|---|
| Product direction | Pewds is the Product Owner |
| New feature | Feature Request issue; big, new-direction or hard-to-undo features need a recorded Product Owner decision ([workflow](product-and-features.md#feature-proposal-workflow)) |
| Ordinary, reversible maintainer PR | Self-merge allowed after appropriate validation and applicable repository checks |
| External contribution | Maintainer review required |
| High-risk change | At least one **independent, qualified** maintainer review |
@@ -22,7 +23,7 @@ These are governance rules, not guarantees that a GitHub permission or branch-pr
## Start here
- [Roles and access](roles-and-access.md): responsibility, permissions, and continuity.
- [Product and feature proposals](product-and-features.md): product approval versus technical ownership.
- [Product and feature proposals](product-and-features.md): product approval versus technical ownership, and the feature proposal workflow.
- [Changes and review](changes-and-review.md): review thresholds, self-merge, contributor corrections.
- [Practical review playbook](review-playbook.md): risk, provenance, CI, CodeQL, independent audit, and handoff evidence.
- [Triage](triage.md): turning incoming reports into actionable work.
+167 -13
View File
@@ -2,13 +2,15 @@
Pewds ([@pewdiepie-archdaemon](https://github.com/pewdiepie-archdaemon)) is the Product Owner for Odysseus and has final authority over product direction. Maintainers own technical feasibility, implementation, and safety.
This page has two parts: **which changes need product approval**, and **the workflow every feature proposal follows**, from issue to merged PR. The terms in bold are defined in [Definitions](#definitions); when a case is unclear, the definitions decide, not intuition.
## Which changes require product approval?
**Product Owner approval before implementation:** substantial new features, changes to product direction, and product changes that are hard to reverse.
**Product Owner approval before implementation:** features that are **big**, set a **new direction**, or are **hard to undo**.
**No separate product approval by default:** bug fixes, security fixes, maintenance, tests, CI, documentation, internal refactors, and small, reversible product changes within existing direction.
**No separate product approval by default:** bug fixes, security fixes, maintenance, tests, CI, documentation, internal refactors, and features that are **small**, **reversible**, and **fit ROADMAP**.
Maintainers may make those smaller product decisions. Pewds can ask for changes or a rollback; the maintainer owns safe rollback or recovery.
Maintainers may make those smaller product decisions. Pewds can ask for changes or a rollback; the maintainer who decided owns the rollback or recovery.
### Urgent security and reliability fixes
@@ -25,19 +27,171 @@ Product Owner proposal or implementation
A Product Owner proposal is already product-approved; it is not automatically technically approved or exempt from high-risk review.
## Community or maintainer initiated
## Feature proposal workflow
Community proposals start as a Feature Request issue. Check [ROADMAP.md](../../ROADMAP.md) and existing issues first; take early ideas to [Discussions](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas).
Every community or maintainer feature proposal follows these steps. The issue is the record at every step: labels show where a proposal stands, and comments hold the decisions.
```text
Feature proposal
-> Product Owner accepts / rejects, where approval is required
-> technical feasibility
-> plan and implementation
-> risk-based review and validation
```mermaid
flowchart TD
A["0. Idea (optional)<br/>Discussions › Ideas"] -.-> B["1. Feature Request issue"]
B --> C{"2. Triage<br/>within 7 days"}
C -- "duplicate / not actionable" --> X["Closed with a reason"]
C -- "small + reversible + fits ROADMAP" --> M["product: maintainer decision"]
C -- "big, new direction or hard to undo" --> N["product: needs decision"]
N --> D["3. Decision brief"]
D --> E{"4. Product Owner decides"}
E -- yes --> P["product: approved"]
E -- no --> R["product: declined<br/>closed as not planned"]
E -- "no answer in 14 days" --> L{"Only reason was size?"}
L -- yes --> M
L -- "no: new direction or hard to undo" --> N
M --> F["5. Implementation PRs<br/>Part of #N"]
P --> F
F --> G["6. Review and merge"]
G --> H["7. Close the issue"]
```
Record the decision in an issue, proposal, work item, or PR so future maintainers can discover it. Do not rely on a chat-only decision.
### Step 0 — Idea (optional)
Half-formed ideas go to [Discussions › Ideas](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas) to gauge interest. Nothing is decided there. A proposal is ready for step 1 when it is **actionable**.
### Step 1 — Feature Request issue
**Who:** the proposer.
Open a [Feature Request](https://github.com/odysseus-dev/odysseus/issues/new?template=feature_request.yml) after checking [ROADMAP.md](../../ROADMAP.md) and open issues. Besides the required fields, the form asks three optional questions that make triage faster:
- whether the proposer thinks it is big, a new direction, or hard to undo;
- the yes/no questions they want the Product Owner to answer;
- screenshots, a recording, or a demo branch.
A proposal with several independent parts may be one issue, provided each part is listed separately so it can be decided separately.
### Step 2 — Triage
**Who:** a **triager**. **When:** within 7 days of the issue being opened.
The triager does one of three things and records why in a comment:
| Outcome | When | Label | Next |
|---|---|---|---|
| Close | **Duplicate** or not **actionable** | — (closed as duplicate or not planned, with a link) | — |
| Maintainer decision | **Small** *and* **reversible** *and* **fits ROADMAP** | `product: maintainer decision` | Step 5 |
| Needs the Product Owner | **Big**, *or* **new direction**, *or* **hard to undo** | `product: needs decision` | Step 3 |
The triage comment names the ROADMAP line the proposal fits, or says that it fits none, and which of big / new direction / hard to undo applies. When the triager is unsure, the proposal goes to the Product Owner.
### Step 3 — Decision brief
**Who:** the proposer writes it; the triager checks it is complete before the clock starts.
The brief goes at the top of the issue body (edit the issue, or post it as the first comment and link it from the body) and contains:
1. **Summary:** what changes for users, in three to five lines.
2. **Questions:** the exact yes/no questions, one per independent part.
3. **Evidence:** screenshots or a recording for anything that renders; a demo branch if one exists.
4. **What cannot be undone:** migrations, deleted data, changed contracts, or "nothing".
5. **For big designs only:** architecture, migration and rollback, and the planned PR slices. A long design may live in a proposal document under `/design` and be linked from the brief.
When the brief is complete, the triager mentions the Product Owner on the issue. This is **day 0** for the timeout.
### Step 4 — Product Owner decision
**Who:** the Product Owner. **When:** on the issue at any time, or at the next **queue review**.
| Answer | Label | Recorded as |
|---|---|---|
| Yes, possibly with conditions | `product: approved` | A **recorded decision** listing any conditions |
| No | `product: declined` | A recorded decision; the issue is closed as not planned |
| A question | stays `product: needs decision` | The clock pauses until the proposer answers, then restarts at day 0 |
| Different answers per part | the label for the approved part(s) | The recorded decision lists each part and its answer |
A decision given outside GitHub (Matrix, a call) counts once a maintainer has recorded it on the issue. A declined feature can be maintained in a contributor's fork, and the issue can be reopened only with **materially new** information.
#### When there is no answer: lazy consensus
| Day | What happens |
|---|---|
| 0 | Brief complete; Product Owner mentioned on the issue |
| 7 | One reminder from a maintainer, and the issue is put on the next queue review |
| 14 | If there has been no **objection**: |
- If the **only** reason the proposal needed the Product Owner was that it is **big** — it is **reversible** and **fits ROADMAP** — a maintainer may decide. They record "decided by lazy consensus on YYYY-MM-DD", apply `product: maintainer decision`, and own any rollback Pewds later asks for.
- If it is a **new direction** or **hard to undo**, it keeps waiting. Silence never approves a one-way change or a change of direction. It is raised at each queue review.
- A proposal still waiting after **90 days** is closed as not planned with a comment saying it expired without a decision. It can be reopened by anyone with the same brief.
### Step 5 — Implementation
**Who:** the proposer or any contributor.
Feature PRs link the issue with `Part of #N` (or `Fixes #N` for the last slice) and stay within what was decided. A maintainer does not merge a feature PR whose issue carries neither `product: approved` nor `product: maintainer decision`, and checks any conditions in the recorded decision.
If the work grows beyond what was decided — a new part, or a part that becomes hard to undo — that part goes back to step 2.
A maintainer's own small, reversible feature that fits ROADMAP may skip the separate issue: the PR description states why it qualifies, and the PR is the record.
### Step 6 — Review and merge
Unchanged: [changes and review](changes-and-review.md). Product approval is not technical approval; high-risk changes still need an independent qualified review.
### Step 7 — Close
The issue is closed when the last PR lands. If the feature was a ROADMAP item, the same PR or a follow-up updates ROADMAP.md.
## Labels
| Label | Meaning | Applied by |
|---|---|---|
| `product: needs decision` | Waiting for the Product Owner | Triager |
| `product: approved` | The Product Owner said yes (see the recorded decision for scope and conditions) | Product Owner or the maintainer recording the decision |
| `product: maintainer decision` | Small, reversible and on ROADMAP, or decided by lazy consensus | Triager, or the maintainer applying lazy consensus |
| `product: declined` | The Product Owner said no | Product Owner or the maintainer recording the decision |
Only maintainers and the Product Owner set or change these labels. Exactly one of them is on a triaged feature issue at any time.
## Definitions
**Feature.** A change that adds or alters user-visible behavior, a setting, or a public contract (HTTP API, MCP tool, plugin interface, file format). Restoring documented or clearly intended behavior is a bug fix, not a feature.
**Small.** All of the following are true:
- it ships as one PR of ordinary size — as a guide, no more than about 600 changed lines excluding tests, generated files and lockfiles;
- it touches one area of the app;
- it adds an option or a capability without changing what existing users see or get by default;
- it adds no new dependency, service, or background process.
Anything else is **big**.
**Reversible.** Reverting the PR(s) fully restores the previous state for every user, with nothing left behind: no data migrated, rewritten or deleted; no stored format that other code or users now depend on; no public contract that integrations have started to use; no security boundary moved. If any of those is not true, the change is **hard to undo**.
**Hard to undo.** Not reversible. Typical cases: data migrations, deleting or purging data, new persisted formats, new or changed authentication, permission or sharing models, and removing or renaming a public API, MCP tool, setting or file location.
**New direction.** The change takes Odysseus somewhere it has not committed to. Any of:
- a new top-level area of the app (a new panel, tab or module);
- multi-user or collaboration features where the app was single-user;
- data or requests leaving the user's machine by default, including telemetry and new external services;
- dropping or adding a supported platform, install method or model backend;
- anything listed under "Not The Focus Right Now" in ROADMAP.md;
- reversing a previous Product Owner decision.
**Fits ROADMAP.** The triager can point to a specific ROADMAP.md item that the proposal implements or directly extends, and the proposal is not listed under "Not The Focus Right Now". "It is in the same area as an item" is not enough.
**Actionable.** The issue states a concrete problem and a concrete change in behavior, specific enough to write acceptance criteria from.
**Duplicate.** Same user-facing outcome as an open issue or open PR, or a request for a ROADMAP item that adds nothing to it. Close it with a link, and move any new information to the original. A proposal that offers to *implement* a ROADMAP item, with a concrete design, is not a duplicate: it is triaged normally and usually **fits ROADMAP**.
**Triager.** Any maintainer other than the proposer. A maintainer's own proposal is triaged by another maintainer.
**Objection.** A comment on the issue — or one recorded there from another channel — by the Product Owner or a maintainer, asking to stop, wait, or change the scope. A maintainer's objection stops lazy consensus for that proposal; it then waits for the Product Owner like a new direction does. Questions and suggestions that do not ask to stop are not objections.
**Recorded decision.** A comment on the issue that states the decision, who made it, the date, where it was made if not on GitHub, the parts it covers, and any conditions — together with the matching label change.
**Queue review.** Every 14 days, a maintainer brings the [`product: needs decision` list](https://github.com/odysseus-dev/odysseus/issues?q=is%3Aissue+is%3Aopen+label%3A%22product%3A+needs+decision%22) to the Product Owner and records each answer on its issue. The maintainer who runs it is agreed among maintainers and noted here: *to be decided*.
**Days.** Calendar days, counted from the timestamp of the event on the issue (label applied, brief completed, reminder posted).
**Materially new.** Information the original decision did not have: a different design, new evidence of demand, or a changed ROADMAP.
## Decision boundaries
@@ -45,4 +199,4 @@ The Product Owner decides **what belongs in Odysseus**. Maintainers decide **how
A rejected or out-of-scope feature can be maintained independently in a contributor's fork.
If the Product Owner is unavailable, work requiring prior product approval waits unless authority was explicitly delegated. Smaller decisions and urgent fixes follow the rules above.
If the Product Owner is unavailable, work that needs product approval follows the timeout above. It never proceeds on silence when it is a new direction or hard to undo. Smaller decisions and urgent fixes follow the rules above.
+2
View File
@@ -26,6 +26,8 @@ Record what is known and distinguish it from what remains unverified:
- Priority, ownership, and relevant domain expertise.
- Remaining unknowns, risks, and safe recovery or workaround when available.
Feature requests follow the [feature proposal workflow](product-and-features.md#feature-proposal-workflow): triage within 7 days, and a product label on every triaged feature issue.
Small and obvious fixes may go straight to a focused PR, consistent with [CONTRIBUTING.md](../../CONTRIBUTING.md) and [changes and review](changes-and-review.md).
## Practical priority language
+6 -6
View File
@@ -696,11 +696,10 @@ async def auto_name_session(session_manager, sess):
logger.info(f"Auto-named session {sess.id} deterministically (no authorized model endpoint): {title}")
return
# max_tokens big enough that reasoning models (Minimax M2,
# DeepSeek R1, QwQ, etc.) have headroom for <think>…</think>
# plus the actual title — 200 used to clip them mid-reasoning
# so strip_think left an empty string and no rename happened.
# Timeout matches: 60s gives slow local reasoners room to finish.
# A configured effort is the user's explicit choice. Otherwise thinking
# stays off, because reasoning would use up this small token budget and
# leave no title.
title_effort = effort_for_call(t_url, t_model, owner, sess)
try:
title = await llm_call_async(
t_url,
@@ -713,7 +712,8 @@ async def auto_name_session(session_manager, sess):
max_tokens=64,
headers=t_headers,
timeout=15,
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
reasoning_effort=title_effort,
thinking_mode=None if title_effort else "off",
)
except Exception as e:
logger.warning("[auto-name] non-spec title call failed for %s: %s", sess.id, e)
+1
View File
@@ -0,0 +1 @@
"""Cookbook route domain package (#4071)."""
+284
View File
@@ -0,0 +1,284 @@
"""Auto-register Cookbook-served models as model endpoints."""
import json
import logging
import uuid
from routes.cookbook_helpers import ServeRequest
logger = logging.getLogger(__name__)
def _auto_register_image_endpoint(
req: ServeRequest,
remote: str | None,
serve_session_id: str | None = None,
) -> str | None:
"""Register a diffusion model as an image endpoint so it appears in the model selector."""
import re
from core.database import SessionLocal, ModelEndpoint
from routes.model_routes import _normalize_model_ids
from src.settings import load_settings, save_settings
# Parse port from command (--port NNNN), default 8100 for diffusion_server
port_match = re.search(r'--port\s+(\d+)', req.cmd)
port = int(port_match.group(1)) if port_match else 8100
# Determine host
if remote:
# SSH alias — use as hostname (Tailscale resolves it later)
host = remote.split("@")[-1] if "@" in remote else remote
else:
host = "localhost"
base_url = f"http://{host}:{port}/v1"
# Friendly display name from repo_id
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
display_name = f"{short_name} (image)"
pinned_models = [req.repo_id] if req.repo_id else []
db = SessionLocal()
try:
# Check for existing endpoint with same base_url — update it
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
if existing:
existing.is_enabled = True
existing.model_type = "image"
# Follow the newly served model unless the user renamed the endpoint.
pinned_names = {f"{m.split('/')[-1]} (image)" for m in _normalize_model_ids(existing.pinned_models)}
if existing.name in pinned_names:
existing.name = display_name
existing.endpoint_kind = "local"
existing.model_refresh_mode = "manual"
existing.cookbook_serve_session_id = serve_session_id
if pinned_models:
existing.cached_models = json.dumps(pinned_models)
existing.pinned_models = json.dumps(pinned_models)
db.commit()
settings = load_settings()
if settings.get("image_gen_enabled") is not True:
settings["image_gen_enabled"] = True
save_settings(settings)
logger.info(f"Updated existing image endpoint: {base_url}")
return existing.id
ep_id = f"img-{uuid.uuid4().hex[:8]}"
ep = ModelEndpoint(
id=ep_id,
name=display_name,
base_url=base_url,
api_key=None,
is_enabled=True,
model_type="image",
endpoint_kind="local",
model_refresh_mode="manual",
cached_models=json.dumps(pinned_models) if pinned_models else None,
pinned_models=json.dumps(pinned_models) if pinned_models else None,
cookbook_serve_session_id=serve_session_id,
)
db.add(ep)
db.commit()
settings = load_settings()
settings["image_gen_enabled"] = True
if not settings.get("image_model"):
settings["image_model"] = req.repo_id
save_settings(settings)
logger.info(f"Auto-registered image endpoint: {display_name} @ {base_url}")
return ep_id
except Exception as e:
logger.error(f"Failed to auto-register image endpoint: {e}")
db.rollback()
return None
finally:
db.close()
def _auto_register_llm_endpoint(
req: ServeRequest,
remote: str | None,
serve_session_id: str | None = None,
) -> str | None:
"""Register a freshly-served LLM as a model endpoint so it appears in the
model picker without a manual /setup step — the text-model sibling of
_auto_register_image_endpoint.
Cookbook serve commands launch an OpenAI-compatible server (llama.cpp's
llama-server, vLLM, SGLang, or Ollama) on a known port. We point an
endpoint at that server's /v1; the picker auto-discovers the model id by
probing /v1/models and dims the endpoint until the server is reachable,
so registering immediately (before the server finishes loading) is safe.
"""
logger.info(
f"_auto_register_llm_endpoint: ENTRY repo_id={req.repo_id!r} "
f"remote={remote!r} cmd_prefix={req.cmd[:80]!r}"
)
import re
from core.database import SessionLocal, ModelEndpoint
from routes.model_routes import _normalize_model_ids
# Port: ordered fallbacks so we match whatever the user actually
# asked for, not a hardcoded default:
# 1. explicit `--port N` (vllm / sglang / llama-server)
# 2. `OLLAMA_HOST=host:port` (the way Ollama specifies its bind)
# 3. fallback by backend (11434 ollama / 8080 llama.cpp)
# Previously the OLLAMA_HOST form was silently ignored and we
# registered every Ollama endpoint at 11434 — even if the user
# set OLLAMA_HOST=0.0.0.0:11435 to avoid colliding with an
# existing systemd Ollama, the registered endpoint pointed at
# the OLD port and showed as offline.
port_match = re.search(r'--port\s+(\d+)', req.cmd)
# Host length is capped at the longest DNS name; unbounded, repeated
# assignments make this scan quadratic and stall the event loop.
ollama_host_match = re.search(r'OLLAMA_HOST=[^\s]{0,253}?:(\d+)', req.cmd)
if port_match:
port = int(port_match.group(1))
elif ollama_host_match:
port = int(ollama_host_match.group(1))
elif "ollama" in req.cmd:
port = 11434
else:
port = 8080 # llama.cpp's llama-server default — the Apple Silicon path
# Determine host. The cookbook tmux for `local=true` serves runs INSIDE
# the odysseus container — so the right URL for the in-container
# backend to reach it is `localhost`, NOT `host.docker.internal`
# (the latter points at the docker HOST, which doesn't have a server
# on that port). The previous host.docker.internal fallback only made
# sense for /setup-added external services like systemd Ollama on the
# host — and those go through manual setup, not this auto-register
# code path. For remote serves we still use the SSH host alias.
if remote:
host = remote.split("@")[-1] if "@" in remote else remote
elif re.search(r"\bdocker\s+exec\s+(?:ollama-rocm|ollama-test)\b", req.cmd or ""):
host = "host.docker.internal"
else:
host = "localhost"
base_url = f"http://{host}:{port}/v1"
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
display_name = short_name or "Local model"
is_mlx_deepseek_v4 = (
"mlx_lm.server" in (req.cmd or "")
and "deepseek-v4" in ((req.repo_id or "") + " " + (req.cmd or "")).lower()
)
mlx_shim_model_id = ""
if is_mlx_deepseek_v4 and short_name:
home_match = re.search(r"((?:/Users|/home)/[^/\s'\"]+)", req.cmd or "")
remote_home = home_match.group(1) if home_match else ""
if remote_home:
mlx_shim_model_id = f"{remote_home}/.cache/odysseus/mlx-shims/{short_name}"
# If the serve command opts models into OpenAI tool-calling, record it so
# agent_loop trusts emitted tool_calls instead of the name heuristic.
is_ollama_endpoint = "ollama" in (req.cmd or "").lower()
supports_tools = True if "--enable-auto-tool-choice" in req.cmd else None
# Pin the model the user launched for every Cookbook-created LLM
# endpoint, not just Ollama. Some OpenAI-compatible servers report a
# deployment alias from /v1/models, and a stale server can answer on the
# same port while the new launch failed. Keeping the requested model id
# pinned makes the picker reflect the actual launch intent.
launched_model = mlx_shim_model_id or req.repo_id
pinned_models = [launched_model] if launched_model else []
# Launch registration must not delete other endpoints.
# Matching names, model pins, and failed probes do not prove that
# another serve has stopped. Lifecycle cleanup owns termination.
db = SessionLocal()
try:
# Reuse an endpoint already pointed at this URL instead of duplicating.
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
if existing:
existing.is_enabled = True
existing.model_type = "llm"
# Follow the newly served model unless the user renamed the endpoint.
pinned_names = {m.split("/")[-1] for m in _normalize_model_ids(existing.pinned_models)}
if existing.name in pinned_names:
existing.name = display_name
existing.endpoint_kind = "local"
existing.model_refresh_mode = "auto"
# A reused endpoint belongs to the newest serve session.
existing.cookbook_serve_session_id = serve_session_id
if launched_model:
# Pins represent model selection, not launch history.
existing_pins = _normalize_model_ids(
existing.pinned_models
)
if launched_model not in existing_pins:
existing_pins.append(launched_model)
existing.pinned_models = json.dumps(existing_pins)
if is_ollama_endpoint:
existing.endpoint_kind = "ollama"
if pinned_models:
existing.cached_models = json.dumps(pinned_models)
if supports_tools is not None:
existing.supports_tools = supports_tools
db.commit()
logger.info(f"Updated existing local model endpoint: {base_url}")
# Re-probe so cached_models matches what the server actually
# serves right now (the URL may have stayed the same but the
# model behind it changed across launches).
try:
if mlx_shim_model_id:
existing.cached_models = json.dumps([mlx_shim_model_id])
existing.pinned_models = json.dumps([mlx_shim_model_id])
db.commit()
else:
from routes.model_routes import _probe_endpoint
import json as _json2
probed = _probe_endpoint(base_url, existing.api_key, timeout=5)
if probed:
existing.cached_models = _json2.dumps(probed)
db.commit()
except Exception as _pe:
logger.warning(f"Re-probe failed for {base_url}: {_pe!r}")
return existing.id
ep_id = f"local-{uuid.uuid4().hex[:8]}"
ep = ModelEndpoint(
id=ep_id,
name=display_name,
base_url=base_url,
api_key=None,
is_enabled=True,
model_type="llm",
endpoint_kind="ollama" if is_ollama_endpoint else "local",
model_refresh_mode="auto",
cached_models=json.dumps(pinned_models) if pinned_models else None,
pinned_models=json.dumps(pinned_models) if pinned_models else None,
supports_tools=supports_tools,
cookbook_serve_session_id=serve_session_id,
)
db.add(ep)
db.commit()
logger.info(f"Auto-registered local model endpoint: {display_name} @ {base_url}")
# Probe /v1/models NOW and write cached_models so the chat
# picker actually shows the model on the next /api/models
# call. Without this immediate probe, the endpoint has empty
# cached_models until the next background refresh fires (up
# to a minute later) and the picker shows nothing — even
# though the endpoint is in the DB and the server is up.
try:
if mlx_shim_model_id:
ep.cached_models = json.dumps([mlx_shim_model_id])
ep.pinned_models = json.dumps([mlx_shim_model_id])
db.commit()
logger.info(f"Auto-register: pinned MLX DeepSeek-V4 shim model @ {base_url}")
else:
from routes.model_routes import _probe_endpoint
import json as _json2
probed = _probe_endpoint(base_url, None, timeout=5)
if probed:
ep.cached_models = _json2.dumps(probed)
db.commit()
logger.info(f"Auto-register: probed {len(probed)} models @ {base_url}")
except Exception as _pe:
logger.warning(f"Auto-register: probe-after-create failed for {base_url}: {_pe!r}")
return ep_id
except Exception as e:
logger.error(f"Failed to auto-register local model endpoint: {e}")
db.rollback()
return None
finally:
db.close()
+3 -271
View File
@@ -43,6 +43,9 @@ from routes.cookbook_output import (
error_aware_output_tail, classify_dead_download,
HF_CACHE_COMPLETE_PROBE, HF_CACHE_INCOMPLETE_PROBE,
)
from routes.cookbook.endpoint_registration import (
_auto_register_image_endpoint, _auto_register_llm_endpoint,
)
logger = logging.getLogger(__name__)
@@ -1408,90 +1411,6 @@ def setup_cookbook_routes() -> APIRouter:
return {"models": models, "host": host or "local"}
def _auto_register_image_endpoint(
req: ServeRequest,
remote: str | None,
serve_session_id: str | None = None,
) -> str | None:
"""Register a diffusion model as an image endpoint so it appears in the model selector."""
import re
from core.database import SessionLocal, ModelEndpoint
from routes.model_routes import _normalize_model_ids
from src.settings import load_settings, save_settings
# Parse port from command (--port NNNN), default 8100 for diffusion_server
port_match = re.search(r'--port\s+(\d+)', req.cmd)
port = int(port_match.group(1)) if port_match else 8100
# Determine host
if remote:
# SSH alias — use as hostname (Tailscale resolves it later)
host = remote.split("@")[-1] if "@" in remote else remote
else:
host = "localhost"
base_url = f"http://{host}:{port}/v1"
# Friendly display name from repo_id
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
display_name = f"{short_name} (image)"
pinned_models = [req.repo_id] if req.repo_id else []
db = SessionLocal()
try:
# Check for existing endpoint with same base_url — update it
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
if existing:
existing.is_enabled = True
existing.model_type = "image"
# Follow the newly served model unless the user renamed the endpoint.
pinned_names = {f"{m.split('/')[-1]} (image)" for m in _normalize_model_ids(existing.pinned_models)}
if existing.name in pinned_names:
existing.name = display_name
existing.endpoint_kind = "local"
existing.model_refresh_mode = "manual"
existing.cookbook_serve_session_id = serve_session_id
if pinned_models:
existing.cached_models = json.dumps(pinned_models)
existing.pinned_models = json.dumps(pinned_models)
db.commit()
settings = load_settings()
if settings.get("image_gen_enabled") is not True:
settings["image_gen_enabled"] = True
save_settings(settings)
logger.info(f"Updated existing image endpoint: {base_url}")
return existing.id
ep_id = f"img-{uuid.uuid4().hex[:8]}"
ep = ModelEndpoint(
id=ep_id,
name=display_name,
base_url=base_url,
api_key=None,
is_enabled=True,
model_type="image",
endpoint_kind="local",
model_refresh_mode="manual",
cached_models=json.dumps(pinned_models) if pinned_models else None,
pinned_models=json.dumps(pinned_models) if pinned_models else None,
cookbook_serve_session_id=serve_session_id,
)
db.add(ep)
db.commit()
settings = load_settings()
settings["image_gen_enabled"] = True
if not settings.get("image_model"):
settings["image_model"] = req.repo_id
save_settings(settings)
logger.info(f"Auto-registered image endpoint: {display_name} @ {base_url}")
return ep_id
except Exception as e:
logger.error(f"Failed to auto-register image endpoint: {e}")
db.rollback()
return None
finally:
db.close()
def _pick_free_port_for_ollama(
remote: str | None, ssh_port: str | None, start_port: int, max_offset: int
) -> int | None:
@@ -1680,193 +1599,6 @@ def setup_cookbook_routes() -> APIRouter:
return
logger.debug(f"crash-watchdog: no exit marker for {session_id} within window; leaving endpoint {endpoint_id}")
def _auto_register_llm_endpoint(
req: ServeRequest,
remote: str | None,
serve_session_id: str | None = None,
) -> str | None:
"""Register a freshly-served LLM as a model endpoint so it appears in the
model picker without a manual /setup step — the text-model sibling of
_auto_register_image_endpoint.
Cookbook serve commands launch an OpenAI-compatible server (llama.cpp's
llama-server, vLLM, SGLang, or Ollama) on a known port. We point an
endpoint at that server's /v1; the picker auto-discovers the model id by
probing /v1/models and dims the endpoint until the server is reachable,
so registering immediately (before the server finishes loading) is safe.
"""
logger.info(
f"_auto_register_llm_endpoint: ENTRY repo_id={req.repo_id!r} "
f"remote={remote!r} cmd_prefix={req.cmd[:80]!r}"
)
import re
from core.database import SessionLocal, ModelEndpoint
from routes.model_routes import _normalize_model_ids
# Port: ordered fallbacks so we match whatever the user actually
# asked for, not a hardcoded default:
# 1. explicit `--port N` (vllm / sglang / llama-server)
# 2. `OLLAMA_HOST=host:port` (the way Ollama specifies its bind)
# 3. fallback by backend (11434 ollama / 8080 llama.cpp)
# Previously the OLLAMA_HOST form was silently ignored and we
# registered every Ollama endpoint at 11434 — even if the user
# set OLLAMA_HOST=0.0.0.0:11435 to avoid colliding with an
# existing systemd Ollama, the registered endpoint pointed at
# the OLD port and showed as offline.
port_match = re.search(r'--port\s+(\d+)', req.cmd)
ollama_host_match = re.search(r'OLLAMA_HOST=[^\s]*?:(\d+)', req.cmd)
if port_match:
port = int(port_match.group(1))
elif ollama_host_match:
port = int(ollama_host_match.group(1))
elif "ollama" in req.cmd:
port = 11434
else:
port = 8080 # llama.cpp's llama-server default — the Apple Silicon path
# Determine host. The cookbook tmux for `local=true` serves runs INSIDE
# the odysseus container — so the right URL for the in-container
# backend to reach it is `localhost`, NOT `host.docker.internal`
# (the latter points at the docker HOST, which doesn't have a server
# on that port). The previous host.docker.internal fallback only made
# sense for /setup-added external services like systemd Ollama on the
# host — and those go through manual setup, not this auto-register
# code path. For remote serves we still use the SSH host alias.
if remote:
host = remote.split("@")[-1] if "@" in remote else remote
elif re.search(r"\bdocker\s+exec\s+(?:ollama-rocm|ollama-test)\b", req.cmd or ""):
host = "host.docker.internal"
else:
host = "localhost"
base_url = f"http://{host}:{port}/v1"
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
display_name = short_name or "Local model"
is_mlx_deepseek_v4 = (
"mlx_lm.server" in (req.cmd or "")
and "deepseek-v4" in ((req.repo_id or "") + " " + (req.cmd or "")).lower()
)
mlx_shim_model_id = ""
if is_mlx_deepseek_v4 and short_name:
home_match = re.search(r"((?:/Users|/home)/[^/\s'\"]+)", req.cmd or "")
remote_home = home_match.group(1) if home_match else ""
if remote_home:
mlx_shim_model_id = f"{remote_home}/.cache/odysseus/mlx-shims/{short_name}"
# If the serve command opts models into OpenAI tool-calling, record it so
# agent_loop trusts emitted tool_calls instead of the name heuristic.
is_ollama_endpoint = "ollama" in (req.cmd or "").lower()
supports_tools = True if "--enable-auto-tool-choice" in req.cmd else None
# Pin the model the user launched for every Cookbook-created LLM
# endpoint, not just Ollama. Some OpenAI-compatible servers report a
# deployment alias from /v1/models, and a stale server can answer on the
# same port while the new launch failed. Keeping the requested model id
# pinned makes the picker reflect the actual launch intent.
launched_model = mlx_shim_model_id or req.repo_id
pinned_models = [launched_model] if launched_model else []
# Launch registration must not delete other endpoints.
# Matching names, model pins, and failed probes do not prove that
# another serve has stopped. Lifecycle cleanup owns termination.
db = SessionLocal()
try:
# Reuse an endpoint already pointed at this URL instead of duplicating.
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
if existing:
existing.is_enabled = True
existing.model_type = "llm"
# Follow the newly served model unless the user renamed the endpoint.
pinned_names = {m.split("/")[-1] for m in _normalize_model_ids(existing.pinned_models)}
if existing.name in pinned_names:
existing.name = display_name
existing.endpoint_kind = "local"
existing.model_refresh_mode = "auto"
# A reused endpoint belongs to the newest serve session.
existing.cookbook_serve_session_id = serve_session_id
if launched_model:
# Pins represent model selection, not launch history.
existing_pins = _normalize_model_ids(
existing.pinned_models
)
if launched_model not in existing_pins:
existing_pins.append(launched_model)
existing.pinned_models = json.dumps(existing_pins)
if is_ollama_endpoint:
existing.endpoint_kind = "ollama"
if pinned_models:
existing.cached_models = json.dumps(pinned_models)
if supports_tools is not None:
existing.supports_tools = supports_tools
db.commit()
logger.info(f"Updated existing local model endpoint: {base_url}")
# Re-probe so cached_models matches what the server actually
# serves right now (the URL may have stayed the same but the
# model behind it changed across launches).
try:
if mlx_shim_model_id:
existing.cached_models = json.dumps([mlx_shim_model_id])
existing.pinned_models = json.dumps([mlx_shim_model_id])
db.commit()
else:
from routes.model_routes import _probe_endpoint
import json as _json2
probed = _probe_endpoint(base_url, existing.api_key, timeout=5)
if probed:
existing.cached_models = _json2.dumps(probed)
db.commit()
except Exception as _pe:
logger.warning(f"Re-probe failed for {base_url}: {_pe!r}")
return existing.id
ep_id = f"local-{uuid.uuid4().hex[:8]}"
ep = ModelEndpoint(
id=ep_id,
name=display_name,
base_url=base_url,
api_key=None,
is_enabled=True,
model_type="llm",
endpoint_kind="ollama" if is_ollama_endpoint else "local",
model_refresh_mode="auto",
cached_models=json.dumps(pinned_models) if pinned_models else None,
pinned_models=json.dumps(pinned_models) if pinned_models else None,
supports_tools=supports_tools,
cookbook_serve_session_id=serve_session_id,
)
db.add(ep)
db.commit()
logger.info(f"Auto-registered local model endpoint: {display_name} @ {base_url}")
# Probe /v1/models NOW and write cached_models so the chat
# picker actually shows the model on the next /api/models
# call. Without this immediate probe, the endpoint has empty
# cached_models until the next background refresh fires (up
# to a minute later) and the picker shows nothing — even
# though the endpoint is in the DB and the server is up.
try:
if mlx_shim_model_id:
ep.cached_models = json.dumps([mlx_shim_model_id])
ep.pinned_models = json.dumps([mlx_shim_model_id])
db.commit()
logger.info(f"Auto-register: pinned MLX DeepSeek-V4 shim model @ {base_url}")
else:
from routes.model_routes import _probe_endpoint
import json as _json2
probed = _probe_endpoint(base_url, None, timeout=5)
if probed:
ep.cached_models = _json2.dumps(probed)
db.commit()
logger.info(f"Auto-register: probed {len(probed)} models @ {base_url}")
except Exception as _pe:
logger.warning(f"Auto-register: probe-after-create failed for {base_url}: {_pe!r}")
return ep_id
except Exception as e:
logger.error(f"Failed to auto-register local model endpoint: {e}")
db.rollback()
return None
finally:
db.close()
@router.post("/api/model/serve")
async def model_serve(request: Request, req: ServeRequest):
"""Launch a model server in a tmux session (or PowerShell background process on Windows).
+1
View File
@@ -418,6 +418,7 @@ _HOST_TO_CURATED = (
("nvidia.com", "nvidia"),
("openrouter.ai", "openrouter"),
("ollama.com", "ollama"),
("conifer.build", "openrouter"),
)
+1
View File
@@ -340,6 +340,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
max_tokens=20,
headers=headers,
timeout=15,
thinking_mode="off",
)
title = result.strip().strip('"\'').strip()
return title[:60] if title else prompt[:50].strip()
+16 -1
View File
@@ -64,6 +64,21 @@ _SHELL_COMMAND = (
r"nvcc|docker|systemctl|journalctl|tmux|git)"
)
_BENCHMARK_COMMAND = r"(?:[a-z][a-z0-9_-]*bench(?:mark)?s?|bench(?:mark)?s?)"
# "on <word>" and "from <word>" alone describe people, companies, and topics
# ("Jensen Huang from NVIDIA", "a book on Rome") as often as hosts, so a target
# only counts as a computer next to wording that describes work on a machine.
_NAMED_COMPUTER = (
r"(?!this\b|my\b|the\b|a\b|an\b|that\b|it\b|same\b|current\b)"
r"[a-z][a-z0-9_.-]{1,31}\b"
)
NAMED_COMPUTER_TASK = (
r"\b(?:disks?|uptime|processes|logs?|files?|folders?|director(?:y|ies)|running|installed|"
r"(?:storage|memory|ram|vram|cpu|gpu)\s+(?:usage|space|utili[sz]ation|load|temp(?:erature)?))\b"
rf"[^.?!\n]{{0,80}}\bon\s+{_NAMED_COMPUTER}"
r"|\b(?:copy|fetch|get|grab|pull|download|scp|rsync|sync|read|show|list|tail|check)\b"
r"[^.?!\n]{0,60}\b(?:files?|logs?|folders?|director(?:y|ies)|configs?|checkpoints?|backups?)\b"
rf"[^.?!\n]{{0,40}}\bfrom\s+{_NAMED_COMPUTER}"
)
_CODE_ACTION = r"(?:write|create|add|edit|modify|code|program|implement|build)"
_CODE_ARTIFACT = (
r"(?:code|function|class|script|module|component|snippet|program|app|feature|file|"
@@ -164,7 +179,7 @@ _ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple(
("workspace", "file/code inspection request", rf"{_PLEASE}(?:find|inspect|look\s+at|open|read|check)\b.{{0,120}}\b(?:file|folder|directory|repo|repository|code|source|logs?|trace|stack|diff)\b"),
("workspace", "server/process debugging request", rf"{_PLEASE}(?:check|debug|fix|restart|start|stop|kill|tail|inspect)\b.{{0,120}}\b(?:server|service|process|port|docker|container|tmux|endpoint|logs?)\b"),
("workspace", "local computer task request", r"\b(?:on|from|in|using|with)\s+(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b|\b(?:local|host)\s+(?:computer|machine|files?|system)\b"),
("workspace", "named computer task request", r"\b(?:on|from)\s+(?!this\b|my\b|the\b|a\b|an\b|that\b|it\b|same\b|current\b)(?:[a-z][a-z0-9_.-]{1,31})\b"),
("workspace", "named computer task request", NAMED_COMPUTER_TASK),
("workspace", "terminal workspace request", rf"\b(?:terminal|shell|workspace|tmux|docker|container|git|branch|commit|diff|pytest|stacktrace|traceback|{_BENCHMARK_COMMAND}|eval(?:uation)?s?)\b"),
# Shell / remote-host intent.
+2 -1
View File
@@ -44,6 +44,7 @@ from src.model_profiles import (
is_odysseus_merged_tools_model,
tool_schema_profile,
)
from src.action_intents import NAMED_COMPUTER_TASK
from src.agent_evidence import (
EvidenceLedger,
command_has_mutation_effect,
@@ -8764,7 +8765,7 @@ _LOCAL_COMPUTER_REFERENCE_RE = re.compile(
r"\b(?:on|from|in|using|with)\s+(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b"
r"|\b(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b"
r"|\b(?:local|host)\s+(?:computer|machine|files?|system)\b"
r"|\b(?:on|from)\s+(?!this\b|my\b|the\b|a\b|an\b)(?:[a-z][a-z0-9_.-]{1,31})\b",
rf"|{NAMED_COMPUTER_TASK}",
re.IGNORECASE,
)
_LOCAL_NETWORK_REFERENCE_RE = re.compile(
+59 -10
View File
@@ -9,9 +9,61 @@ from src.chat_helpers import extract_urls
from src.youtube_handler import is_youtube_url
from src.search import comprehensive_web_search, fetch_webpage_content
from src.prompt_security import UNTRUSTED_CONTEXT_POLICY, untrusted_context_message
from src.text_helpers import strip_think
logger = logging.getLogger(__name__)
_SEARCH_QUERY_HISTORY_TURNS = 4
_SEARCH_QUERY_HISTORY_TURN_CHARS = 500
def _search_query_messages(message: str, history: List[Any]) -> List[Dict[str, str]]:
"""Build the query extraction prompt, with recent turns to resolve references.
A follow-up such as "Does he work with Jane?" has no searchable subject on
its own. The current turn is already the last history entry when the session
persists it, so a trailing user entry is dropped rather than repeated.
"""
turns = [m for m in history if getattr(m, "role", "") in {"user", "assistant"}]
if turns and turns[-1].role == "user":
turns.pop()
lines = []
for turn in turns[-_SEARCH_QUERY_HISTORY_TURNS:]:
content = turn.content
if isinstance(content, list):
content = " ".join(
str(part.get("text") or "") for part in content if isinstance(part, dict)
)
text = " ".join(strip_think(str(content or ""), prose=False, prompt_echo=False).split())
if text:
lines.append(f"{turn.role}: {text[:_SEARCH_QUERY_HISTORY_TURN_CHARS]}")
if not lines:
return [
{
"role": "system",
"content": "Extract a concise search query from the user's message. Reply ONLY with the query.",
},
{"role": "user", "content": message},
]
conversation = "\n".join(lines)
return [
{
"role": "system",
"content": (
"Extract a concise search query from the user's latest message. "
"Use the earlier conversation only to resolve references such as pronouns, "
"and leave out earlier topics the latest message does not refer to. "
"Reply ONLY with the query."
),
},
{
"role": "user",
"content": f"Earlier conversation:\n{conversation}\n\nLatest message:\n{message}",
},
]
def _clean_search_query(query: str, max_len: int = 200) -> str:
"""Strip fenced code blocks from a search query while preserving inline
@@ -422,21 +474,18 @@ class ChatProcessor:
generated_query = llm_call(
t_url,
t_model,
[
{
"role": "system",
"content": (
"Extract a concise search query from the user's message. "
"Reply ONLY with the query."
_search_query_messages(
message,
[] if incognito else getattr(session, "history", None) or [],
),
},
{"role": "user", "content": message},
],
headers=t_headers,
temperature=0.1,
max_tokens=50,
timeout=15,
).strip()
thinking_mode="off",
)
# Models without a reasoning parser put thinking in the content.
generated_query = strip_think(generated_query, prose=False, prompt_echo=False).strip()
if generated_query:
# LLM successfully generated a non-empty query -> use the generated query
+89 -13
View File
@@ -42,6 +42,15 @@ def _content_as_text(content: Any) -> str:
_MULTIMODAL_IMAGE_TYPES = {"image_url", "input_image", "image"}
def _has_preservable_content(content: Any) -> bool:
"""Retain nonblank text or nonempty multimodal blocks after tool-call removal."""
if isinstance(content, str):
return bool(content.strip())
if isinstance(content, list):
return bool(content)
return False
def prune_multimodal_images(
messages: List[Dict],
*,
@@ -222,22 +231,81 @@ def normalize_compaction_summary(summary: str) -> str:
def _sanitize_tool_messages(msgs: List[Dict]) -> List[Dict]:
"""Drop orphaned `tool` messages and dangling assistant `tool_calls`.
"""Normalize tool-call message ordering so strict providers accept the transcript.
OpenAI's API requires every `role:"tool"` message to immediately
follow an assistant message that carries `tool_calls` (or another
tool message in the same batch). Front-trimming the history can cut
the assistant `tool_calls` parent while keeping its tool responses,
which triggers: "messages with role 'tool' must be a response to a
preceding message with 'tool_calls'". This pass repairs that:
- drops `tool` messages with no valid preceding tool_calls
- drops assistant `tool_calls` messages whose tool responses were
all trimmed away (some providers reject unanswered tool_calls)
tool message in the same batch). Gemini and some other providers
additionally require a function call turn to come immediately after
a user turn or a function response turn. Trimming the history can
break either rule, which triggers errors such as "messages with role
'tool' must be a response to a preceding message with 'tool_calls'"
and "Please ensure that function call turn comes immediately after
a user turn or after a function response turn."
The transcript is normalized against its own effective output: each
pass repairs the violations it can see and the passes repeat until
the result is stable, so the returned transcript itself satisfies
the ordering contract and sanitizing it again is a no-op. A
completed call/result batch whose predecessor is an adjacent
assistant text turn keeps its calls by merging that text into the
call message instead of dropping executed-tool evidence; a batch
that cannot get a user/tool predecessor (e.g. a transcript that
starts mid-history) is the only one dropped.
"""
# Pass 1: drop orphan tool messages.
work = list(msgs)
for _ in range(2 * len(work) + 2):
normalized = _normalize_tool_messages_once(work)
if normalized == work:
break
work = normalized
return work
def _normalize_tool_messages_once(msgs: List[Dict]) -> List[Dict]:
"""One normalization pass over an already-trimmed message list."""
# Merge the run of assistant text turns directly before an assistant
# tool_calls turn into a single assistant message carrying both, so the
# batch inherits the text run's predecessor (a user or tool message)
# instead of losing its tool_calls. Only plain-text content is merged;
# multimodal content stays untouched and falls through to the strip logic.
merged: List[Dict] = []
for m in msgs:
cur_text = m.get("content")
if (
m.get("role") == "assistant"
and m.get("tool_calls")
and (cur_text is None or isinstance(cur_text, str))
):
text_run: List[Dict] = []
while (
merged
and merged[-1].get("role") == "assistant"
and not merged[-1].get("tool_calls")
and isinstance(merged[-1].get("content"), str)
):
text_run.append(merged.pop())
if text_run:
text_parts = [
msg["content"].strip()
for msg in reversed(text_run)
if msg["content"].strip()
]
if isinstance(cur_text, str) and cur_text.strip():
text_parts.append(cur_text.strip())
merged_msg = dict(m)
if text_parts:
merged_msg["content"] = "\n\n".join(text_parts)
merged.append(merged_msg)
continue
merged.append(m)
# Strip tool_calls from assistant messages whose predecessor is not a
# user or tool message, then drop orphan tool messages and dangling
# tool_calls whose responses are gone.
cleaned: List[Dict] = []
in_batch = False # are we right after an assistant tool_calls (or mid-batch)?
for m in msgs:
for m in merged:
role = m.get("role")
if role == "tool":
if in_batch:
@@ -245,13 +313,21 @@ def _sanitize_tool_messages(msgs: List[Dict]) -> List[Dict]:
# else: orphan — drop
continue
if role == "assistant" and m.get("tool_calls"):
prev_role = cleaned[-1].get("role") if cleaned else None
if prev_role not in ("user", "tool"):
# Invalid predecessor — strip tool_calls to avoid provider
# errors; keep the message only if it still carries content.
m = {k: v for k, v in m.items() if k != "tool_calls"}
in_batch = False
if not _has_preservable_content(m.get("content")):
continue # nothing left worth keeping
cleaned.append(m)
continue
in_batch = True
else:
in_batch = False
cleaned.append(m)
# Pass 2: drop assistant tool_calls messages that have NO following
# tool response (dangling) — walk backwards so we know what follows.
out: List[Dict] = []
for i, m in enumerate(cleaned):
if m.get("role") == "assistant" and m.get("tool_calls"):
@@ -261,7 +337,7 @@ def _sanitize_tool_messages(msgs: List[Dict]) -> List[Dict]:
# tool_calls so it's a plain assistant turn (preserves any
# text content the model produced alongside the calls).
m = {k: v for k, v in m.items() if k != "tool_calls"}
if not (m.get("content") or "").strip():
if not _has_preservable_content(m.get("content")):
continue # nothing left worth keeping
out.append(m)
return out
+4 -2
View File
@@ -625,7 +625,8 @@ class DeepResearcher:
# LLM helper
# ------------------------------------------------------------------
async def _llm(self, messages: List[Dict], temperature: float = 0.3,
max_tokens: int = 4096, timeout: int = 60) -> str:
max_tokens: int = 4096, timeout: int = 60,
thinking_mode: Optional[str] = None) -> str:
"""Call the LLM asynchronously and strip thinking tags."""
from src.llm_core import llm_call_async
response = await llm_call_async(
@@ -636,6 +637,7 @@ class DeepResearcher:
max_tokens=max_tokens,
headers=self.llm_headers,
timeout=timeout,
thinking_mode=thinking_mode,
)
return strip_thinking(response)
@@ -696,7 +698,7 @@ class DeepResearcher:
try:
result = await self._llm(
[{"role": "user", "content": prompt}],
temperature=0, max_tokens=20, timeout=15,
temperature=0, max_tokens=20, timeout=15, thinking_mode="off",
)
cat = (result or "").strip().lower()
# Clean one-word answer first.
+25 -23
View File
@@ -2321,6 +2321,25 @@ def _apply_explicit_nonstream_reasoning_effort(
payload["reasoning_effort"] = effort
def _nonstream_openai_response_text(msg: Dict, thinking_mode: Optional[str]) -> str:
"""Return the answer text of an OpenAI-compatible non-streaming message.
With thinking off the caller wants only the answer, so an empty answer
stays empty even when the model spent its whole budget on reasoning.
Otherwise reasoning stands in for a missing answer, and Mistral's
structured thinking is kept ahead of its text.
"""
thinking_off = _normalize_thinking_mode(thinking_mode) == "off"
reasoning = "" if thinking_off else msg.get("reasoning_content")
content = msg.get("content")
if not isinstance(content, list):
return content or reasoning or ""
text_part, thinking_part = _normalize_mistral_content(content)
if thinking_part and not thinking_off:
return thinking_part + "\n\n" + (text_part or "")
return text_part or reasoning or ""
def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE,
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
timeout: int = LLMConfig.DEFAULT_TIMEOUT, prompt_type: Optional[str] = None,
@@ -2412,17 +2431,9 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
elif provider == "ollama":
response = _parse_ollama_response(data)
else:
msg = data["choices"][0]["message"]
content = msg.get("content")
if isinstance(content, list):
# Mistral structured content — extract thinking + text
text_part, thinking_part = _normalize_mistral_content(content)
if thinking_part:
response = thinking_part + "\n\n" + (text_part or "")
else:
response = text_part or msg.get("reasoning_content") or ""
else:
response = content or msg.get("reasoning_content") or ""
response = _nonstream_openai_response_text(
data["choices"][0]["message"], thinking_mode
)
_set_cached_response(cache_key, response)
return response
except Exception:
@@ -2860,18 +2871,9 @@ async def llm_call_async(
elif provider == "ollama":
response = _parse_ollama_response(data)
else:
msg = data["choices"][0]["message"]
content = msg.get("content")
if isinstance(content, list):
# Mistral structured content — extract thinking + text
# (same contract as llm_call / stream_llm; see #5435).
text_part, thinking_part = _normalize_mistral_content(content)
if thinking_part:
response = thinking_part + "\n\n" + (text_part or "")
else:
response = text_part or msg.get("reasoning_content") or ""
else:
response = content or msg.get("reasoning_content") or ""
response = _nonstream_openai_response_text(
data["choices"][0]["message"], thinking_mode
)
_set_cached_response(
cache_key,
response,
+16 -91
View File
@@ -400,9 +400,13 @@ function _eventsForDay(dateStr) {
}
// Multi-day timed events: show on each day they span
const startDate = _localDateOf(e.dtstart);
const endDate = _localDateOf(e.dtend);
if (startDate !== endDate) return startDate <= dateStr && endDate >= dateStr;
return startDate === dateStr;
const start = new Date(e.dtstart);
const end = new Date(e.dtend || e.dtstart);
// DTEND is exclusive, including at local midnight and across DST.
const endDate = end > start
? _localDateOf(new Date(end.getTime() - 1).toISOString())
: startDate;
return startDate <= dateStr && endDate >= dateStr;
});
}
@@ -1158,51 +1162,25 @@ async function _renderMonth() {
const dow = _weekStartSun ? first.getDay() : (first.getDay() + 6) % 7;
const gs = new Date(y, m, 1 - dow);
const multiDay = _events.filter(e => {
if (!_eventVisible(e)) return false;
const startD = new Date(e.dtstart), endD = new Date(e.dtend);
return Math.round((endD - startD) / 86400000) > 1 || (!e.all_day && _localDateOf(e.dtstart) !== _localDateOf(e.dtend));
});
const multiUids = new Set(multiDay.map(e => e.uid));
// Render 6 week rows. Each row is a positioned container that holds
// 7 day cells AND any multi-day bars that span the row, drawn as an
// absolute overlay on top of the cells. This avoids the old "each
// bar lives inside its start cell and gets clipped at the cell edge"
// problem so a multi-day event reads as one continuous line across
// every day it covers.
// Use the existing inline event rows for every day an event covers.
for (let row = 0; row < 6; row++) {
// Count how many multi-day bars overlap any column in this row so
// cells can reserve top padding for them — otherwise the bars
// (drawn as absolute overlays) sit on top of the day-number and
// single-event rows below.
const rowStartCd0 = new Date(gs); rowStartCd0.setDate(gs.getDate() + row * 7);
const rowEndCd0 = new Date(gs); rowEndCd0.setDate(gs.getDate() + row * 7 + 6);
const rowStart0 = _ds(rowStartCd0);
const rowEnd0 = _ds(rowEndCd0);
const barsInRow = multiDay.filter(md => {
const mdStart = _localDateOf(md.dtstart);
const mdEnd = _localDateOf(md.dtend);
return !(mdEnd < rowStart0 || mdStart > rowEnd0);
}).length;
h += `<div class="cal-week-row" style="--bars:${barsInRow}">`;
h += '<div class="cal-week-row" style="--bars:0">';
// Day cells for this row
for (let col = 0; col < 7; col++) {
const i = row * 7 + col;
const cd = new Date(gs); cd.setDate(gs.getDate() + i);
const d = _ds(cd);
const isOther = cd.getMonth() !== m;
const singles = _eventsForDay(d).filter(e => !multiUids.has(e.uid));
const dayEvents = _eventsForDay(d);
const isWeekend = cd.getDay() === 0 || cd.getDay() === 6;
const cls = 'cal-day' + (isOther ? ' cal-other' : '') + (isWeekend ? ' cal-weekend' : '') + (!singles.length ? ' cal-empty-day' : '') + (d === today ? ' cal-today' : '') + (d === _selectedDay ? ' cal-selected' : '');
const cls = 'cal-day' + (isOther ? ' cal-other' : '') + (isWeekend ? ' cal-weekend' : '') + (!dayEvents.length ? ' cal-empty-day' : '') + (d === today ? ' cal-today' : '') + (d === _selectedDay ? ' cal-selected' : '');
h += `<div class="${cls}" data-date="${d}"><span class="cal-day-num">${cd.getDate()}</span>`;
// Single events — show up to 3 inline rows (multi-day events are
// drawn separately as an overlay below).
if (singles.length) {
// Reuse the same rows for single-day and continuing events.
if (dayEvents.length) {
const maxInline = window.innerWidth <= 768 ? 2 : 3;
const showInline = singles.slice(0, maxInline);
const showInline = dayEvents.slice(0, maxInline);
for (const ev of showInline) {
const t = ev.all_day ? '' : _fmtTime(ev.dtstart);
const t = ev.all_day || _localDateOf(ev.dtstart) !== d ? '' : _fmtTime(ev.dtstart);
const _impMark = ev.importance === 'critical' ? '<span style="color:var(--red);margin-right:2px" title="critical">!!</span>'
: ev.importance === 'high' ? '<span style="color:var(--orange,#e5a33a);margin-right:2px" title="high">!</span>' : '';
const _typeBadge = ev.event_type ? `<span class="cal-event-type-badge" data-type="${_e(ev.event_type)}" title="${_e(ev.event_type)}"></span>` : '';
@@ -1213,63 +1191,10 @@ async function _renderMonth() {
<span class="cal-event-row-name">${_impMark}${_e(ev.summary)}${_eventReminderHtml(ev)}${_eventSourceHtml(ev)}</span>
</div>`;
}
if (singles.length > maxInline) h += `<div class="cal-event-more">+${singles.length - maxInline} more</div>`;
if (dayEvents.length > maxInline) h += `<div class="cal-event-more">+${dayEvents.length - maxInline} more</div>`;
}
h += '</div>';
}
// Multi-day overlay bars for this row. Stack each bar one slot below
// the previous so two events on the same row don't overlap.
let barSlot = 0;
for (const md of multiDay) {
const mdStart = _localDateOf(md.dtstart);
const mdEnd = _localDateOf(md.dtend);
// Compute the row's date range
const rowStartCd = new Date(gs); rowStartCd.setDate(gs.getDate() + row * 7);
const rowEndCd = new Date(gs); rowEndCd.setDate(gs.getDate() + row * 7 + 6);
const rowStart = _ds(rowStartCd);
const rowEnd = _ds(rowEndCd);
if (mdEnd < rowStart || mdStart > rowEnd) continue; // not in this row
// Column within the row where the bar starts and how many days it spans
const startCol = mdStart < rowStart ? 0 : ((new Date(mdStart + 'T00:00:00') - rowStartCd) / 86400000);
const endCol = mdEnd > rowEnd ? 6 : ((new Date(mdEnd + 'T00:00:00') - rowStartCd) / 86400000);
const startColInt = Math.round(startCol);
const endColInt = Math.round(endCol);
const span = endColInt - startColInt + 1;
// Proportional offsets for timed events that span across midnight
// (e.g. 8 PM Mon → 5 AM Tue). Without this, an overnight serve
// window visually fills the ENTIRE next day even when it only
// covers a few hours. All-day events keep the full-day shape.
// Bar visually spans from column (col+startFrac) to (col+span-1+endFrac),
// so a 8 PM→5 AM run shows ~17% of day 1 + ~21% of day 2, not 200%.
let startFrac = 0;
let endFrac = 1;
if (!md.all_day) {
try {
const sIso = md.dtstart || '';
const eIso = md.dtend || '';
const sDate = sIso ? new Date(sIso) : null;
const eDate = eIso ? new Date(eIso) : null;
// First-visible-day fraction (0 = midnight start). Clamp to 0
// when the event started before this row, so the bar still
// starts at the row's left edge.
if (sDate && !isNaN(sDate) && mdStart >= rowStart) {
const midnight = new Date(sDate); midnight.setHours(0, 0, 0, 0);
startFrac = Math.max(0, Math.min(1, (sDate - midnight) / 86400000));
}
if (eDate && !isNaN(eDate) && mdEnd <= rowEnd) {
const midnight = new Date(eDate); midnight.setHours(0, 0, 0, 0);
endFrac = Math.max(0, Math.min(1, (eDate - midnight) / 86400000));
// CalDAV end-times are exclusive: an event ending at exactly
// 00:00 on day N really ended at end-of-day N-1, so endFrac=0
// would visually paint a zero-width slice. Snap to a small
// visible minimum (5% of a day) so the bar still registers.
if (endFrac === 0) endFrac = 1;
}
} catch (_) { startFrac = 0; endFrac = 1; }
}
h += `<div class="cal-multiday" style="--col:${startColInt};--span:${span};--slot:${barSlot};--start-frac:${startFrac.toFixed(4)};--end-frac:${endFrac.toFixed(4)};background:${_calColor(md)};--cal-event-fg:${_calEventFg(md)}" draggable="true" data-uid="${_e(md.uid)}" title="${_e(md.summary)}">${_e(md.summary)}${_eventReminderHtml(md)}${_eventSourceHtml(md)}</div>`;
barSlot++;
}
h += '</div>';
}
h += '</div>';
+2 -1
View File
@@ -2588,6 +2588,7 @@ import { invalidateSettings } from './appConfig.js';
currentHolder = null;
let abortCtrl = null;
let _isAgent = false; // read by the catch block's timeout message
let streamingTTS = false;
try {
// Re-enable auto-scroll when user sends a message
@@ -2985,7 +2986,7 @@ import { invalidateSettings } from './appConfig.js';
currentAbort = abortCtrl;
const _tState = Storage.loadToggleState();
const _isAgent = (_tState.mode || 'chat') === 'agent' || !!_tState.plan_mode || workspaceAgentIntent;
_isAgent = (_tState.mode || 'chat') === 'agent' || !!_tState.plan_mode || workspaceAgentIntent;
// Timeout: 6 min for research and agent mode, 3 min otherwise
const timeoutMs = el('research-toggle').checked || _isAgent ? RESEARCH_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
+1
View File
@@ -124,6 +124,7 @@ const _ENDPOINT_LABELS = [
[/(^|\.)perplexity\.ai$/i, "Perplexity"],
[/(^|\.)nvidia\.com$/i, "NVIDIA"],
[/(^|\.)x\.ai$/i, "xAI"],
[/(^|\.)conifer\.build$/i, "Conifer"],
];
/**
+41
View File
@@ -1,4 +1,7 @@
import pytest
from src.action_intents import classify_tool_intent, message_needs_tools
from src.turn_contract import requested_capabilities
def test_calendar_entry_request_promotes_to_agent():
@@ -97,6 +100,44 @@ def test_page_references_are_not_mistaken_for_named_computers():
assert intent.needs_tools and intent.category == "workspace"
@pytest.mark.parametrize(
"prompt",
[
"Who is Jensen Huang from NVIDIA?",
"Who is Tim Cook from Apple?",
"Who is Satya Nadella from Microsoft?",
"What happened on Monday?",
"What is the best book on Rome?",
"Tell me a story from Japan",
"What is the best GPU from NVIDIA?",
"Who is running from the police?",
],
)
def test_people_and_topics_are_not_mistaken_for_named_computers(prompt):
assert classify_tool_intent(prompt).reason != "named computer task request"
assert "shell_files" not in requested_capabilities(prompt)
@pytest.mark.parametrize(
"prompt",
[
"check disk usage on gpu-box",
"how much free disk on gpu-box?",
"what's the uptime on nas01",
"list running processes on gpu-box",
"is nginx running on gpu-box",
"is ffmpeg installed on workstation",
"what is the gpu usage on gpu-box",
"copy the logs from gpu-box",
"fetch the config file from prod-db",
"download the checkpoint from gpu-box",
],
)
def test_computer_tasks_on_a_named_host_keep_shell_tools(prompt):
assert classify_tool_intent(prompt).category == "workspace"
assert requested_capabilities(prompt) == {"shell_files"}
def test_direct_code_requests_promote_to_workspace_agent():
prompts = [
"write a Python function that parses CSV",
+1 -1
View File
@@ -71,7 +71,7 @@ def test_calendar_event_surfaces_use_computed_foreground_variable():
assert "_calReadableTextColor" in utils_js
assert "function _calEventFg(ev)" in calendar_js
assert "--cal-event-fg:${_calEventFg(md)}" in calendar_js
# Month events share transparent dot rows; remaining solid tiles need contrast.
assert "--cal-event-fg:${_calEventFg(ev)}" in calendar_js
assert "color: var(--cal-event-fg, #fff);" in style_css
assert "color: var(--cal-event-fg, var(--fg));" in style_css
+53
View File
@@ -0,0 +1,53 @@
"""Calendar day membership uses the event's exclusive end instant."""
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.helpers.js_modules import js_function_source
ROOT = Path(__file__).resolve().parents[1]
SOURCE = js_function_source("_eventsForDay", (ROOT / "static/js/calendar.js").read_text())
ZONES = ["UTC", "Europe/Istanbul", "America/New_York", "Asia/Tokyo"]
CASES = [
("2026-10-22T17:00:00", "2026-10-23T00:00:00", False, ["2026-10-22"]),
("2026-10-22T17:00:00", "2026-10-23T00:01:00", False, ["2026-10-22", "2026-10-23"]),
("2026-10-22T17:00:00", "2026-10-22T17:00:00", False, ["2026-10-22"]),
("2026-10-15", "2026-10-17", True, ["2026-10-15", "2026-10-16"]),
("2026-10-15", "2026-10-15", True, ["2026-10-15"]),
("2026-10-31T23:00:00", "2026-11-01T00:00:00", False, ["2026-10-31"]),
]
def _coverage(zone, start, end, all_day, days):
script = """
import { _localDateOf } from './static/js/calendar/utils.js';
const {source,event,days}=JSON.parse(process.argv[1]);
const filter=new Function('_events','_eventVisible','_localDateOf',source+'; return _eventsForDay;')([event],()=>true,_localDateOf);
console.log(JSON.stringify(days.filter(day=>filter(day).length)));
"""
data = dict(source=SOURCE, event=dict(uid="event", dtstart=start, dtend=end, all_day=all_day), days=days)
result = subprocess.run(["node", "--input-type=module", "-e", script, json.dumps(data)],
cwd=ROOT, env={**os.environ, "TZ": zone}, capture_output=True,
text=True, check=True, timeout=15)
return json.loads(result.stdout)
@pytest.mark.skipif(not shutil.which("node"), reason="node not installed")
@pytest.mark.parametrize("zone", ZONES)
@pytest.mark.parametrize("start,end,all_day,expected", CASES)
def test_event_day_coverage(zone, start, end, all_day, expected):
days = [f"2026-10-{day:02d}" for day in range(14, 32)] + ["2026-11-01", "2026-11-02"]
assert _coverage(zone, start, end, all_day, days) == expected
@pytest.mark.skipif(not shutil.which("node"), reason="node not installed")
@pytest.mark.parametrize("zone,start,end,days,expected", [
("Europe/Berlin", "2026-10-24T20:00:00+02:00", "2026-10-26T00:00:00+01:00", ["2026-10-24", "2026-10-25", "2026-10-26"], ["2026-10-24", "2026-10-25"]),
("America/New_York", "2026-10-31T20:00:00-04:00", "2026-11-02T00:00:00-05:00", ["2026-10-31", "2026-11-01", "2026-11-02"], ["2026-10-31", "2026-11-01"]),
])
def test_midnight_end_across_dst(zone, start, end, days, expected):
assert _coverage(zone, start, end, False, days) == expected
+2 -3
View File
@@ -2,7 +2,7 @@ from pathlib import Path
import re
from tests.helpers.stylesheets import app_css
from tests.helpers.js_modules import email_library_source
from tests.helpers.js_modules import email_library_source, js_function_source
ROOT = Path(__file__).resolve().parents[1]
@@ -172,8 +172,7 @@ def test_calendar_event_cards_show_compact_source_badges():
style_src = app_css()
assert "function _eventSourceHtml" in calendar_src
assert "${_eventSourceHtml(ev)}" in calendar_src
assert "${_eventSourceHtml(md)}" in calendar_src
assert "${_eventSourceHtml(ev)}" in js_function_source("_renderMonth", calendar_src)
assert ".cal-event-source" in style_src
+122
View File
@@ -1,4 +1,5 @@
from unittest.mock import MagicMock
import httpx
from types import SimpleNamespace
from src.chat_processor import ChatProcessor
@@ -93,3 +94,124 @@ def test_build_context_preface_web_search_query_sanitization(monkeypatch):
called_query = mock_web_search.call_args[0][0]
assert len(called_query) <= 150
assert " " not in called_query
def _run_web_preface(monkeypatch, message):
mock_web_search = MagicMock(return_value=("Search Results", []))
monkeypatch.setattr("src.chat_processor.comprehensive_web_search", mock_web_search)
processor = ChatProcessor(memory_manager=MagicMock(), personal_docs_manager=MagicMock())
session = SimpleNamespace(
endpoint_url="http://127.0.0.1:8081/v1/chat/completions", model="gemma4-26b", headers={},
)
processor.build_context_preface(
message=message,
session=session,
use_web=True,
use_rag=False,
use_memory=False,
use_skills=False,
)
return mock_web_search
def test_truncated_reasoning_never_becomes_the_web_search_query(monkeypatch):
"""A reasoning model that exhausts max_tokens falls back to the user's line."""
sent = []
def fake_post(_url, **kwargs):
sent.append(kwargs["json"])
return httpx.Response(
200,
request=httpx.Request("POST", "http://127.0.0.1:8081/v1/chat/completions"),
json={"choices": [{
"finish_reason": "length",
"message": {
"content": "",
"reasoning_content": '* User message: "Who is Jensen?" * Goal: Extract a query.',
},
}]},
)
monkeypatch.setattr("src.llm_core.httpx.post", fake_post)
mock_web_search = _run_web_preface(monkeypatch, "Does he work with Lisa Su?\nThanks")
mock_web_search.assert_called_with(
"Does he work with Lisa Su?", time_filter=None, return_sources=True,
)
assert sent[0]["chat_template_kwargs"] == {"enable_thinking": False}
def test_inline_think_block_never_becomes_the_web_search_query(monkeypatch):
"""Without a reasoning parser, thinking arrives as ordinary content."""
monkeypatch.setattr(
"src.llm_core.llm_call",
MagicMock(return_value="<think>The user is asking about"),
)
mock_web_search = _run_web_preface(monkeypatch, "Who is Jensen Huang?")
mock_web_search.assert_called_with("Who is Jensen Huang?", time_filter=None, return_sources=True)
def _turn(role, content):
return SimpleNamespace(role=role, content=content)
def _extraction_messages(monkeypatch, message, history, **kwargs):
mock_llm_call = MagicMock(return_value="query")
monkeypatch.setattr("src.llm_core.llm_call", mock_llm_call)
monkeypatch.setattr(
"src.chat_processor.comprehensive_web_search",
MagicMock(return_value=("Search Results", [])),
)
processor = ChatProcessor(memory_manager=MagicMock(), personal_docs_manager=MagicMock())
processor.build_context_preface(
message=message,
session=SimpleNamespace(endpoint_url="http://local", model="test", headers={}, history=history),
use_web=True,
use_rag=False,
use_memory=False,
use_skills=False,
**kwargs,
)
return mock_llm_call.call_args.args[2]
def test_follow_up_query_extraction_sees_recent_turns(monkeypatch):
followup = "Does he work with Lisa Su?"
messages = _extraction_messages(monkeypatch, followup, [
_turn("user", "Who is Jensen Huang from NVIDIA?"),
_turn("assistant", "<think>Let me look.</think>Jensen Huang leads NVIDIA."),
_turn("user", followup),
])
assert messages[1]["content"] == (
"Earlier conversation:\n"
"user: Who is Jensen Huang from NVIDIA?\n"
"assistant: Jensen Huang leads NVIDIA.\n\n"
f"Latest message:\n{followup}"
)
def test_first_message_query_extraction_is_only_the_message(monkeypatch):
messages = _extraction_messages(monkeypatch, "Who is Jensen?", [_turn("user", "Who is Jensen?")])
assert messages[1] == {"role": "user", "content": "Who is Jensen?"}
def test_query_extraction_history_is_bounded(monkeypatch):
history = [_turn("user", f"old-{i} " + "x" * 2000) for i in range(10)]
history.append(_turn("assistant", "reply"))
prompt = _extraction_messages(monkeypatch, "next", history)[1]["content"]
assert "old-6" not in prompt
assert "old-7" in prompt and "old-9" in prompt
assert len(prompt) < 2200
def test_incognito_query_extraction_ignores_saved_history(monkeypatch):
messages = _extraction_messages(
monkeypatch, "Does he code?", [_turn("user", "Saved chat"), _turn("assistant", "Reply")],
incognito=True,
)
assert messages[1] == {"role": "user", "content": "Does he code?"}
+13
View File
@@ -0,0 +1,13 @@
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
def test_timeout_catch_can_read_is_agent():
"""The catch block reads _isAgent, so it must not be a try-scoped const (#6764)."""
src = (ROOT / "static/js/chat.js").read_text()
assert re.search(r"^\s*let _isAgent = false;", src, re.M)
assert not re.search(r"\bconst _isAgent\b", src)
assert re.search(r"^\s*_isAgent = \(_tState\.mode", src, re.M)
+232
View File
@@ -516,3 +516,235 @@ messages = [
assert '[Image]' in keep_recent_image_turns(messages, keep=1)[0]['content'][0]['text']
'''
subprocess.run([sys.executable, '-c', script], check=True, timeout=8)
class TestSanitizeToolMessagesPredecessor:
"""``_sanitize_tool_messages`` must return a transcript that satisfies the
provider ordering contract and is stable under repeated sanitation.
Contract under test:
- an assistant ``tool_calls`` turn follows a user or tool turn;
- every tool message belongs to a preceding ``tool_calls`` batch;
- an assistant ``tool_calls`` turn has at least one tool response.
"""
@staticmethod
def _violations(msgs):
problems = []
in_batch = False
for i, m in enumerate(msgs):
role = m.get("role")
if role == "tool":
if not in_batch:
problems.append(f"[{i}] orphan tool message")
continue
prev = msgs[i - 1] if i else None
prev_role = prev.get("role") if prev else None
if role == "assistant" and m.get("tool_calls"):
if prev_role not in ("user", "tool"):
problems.append(f"[{i}] tool_calls after {prev_role!r}")
nxt = msgs[i + 1] if i + 1 < len(msgs) else None
if not (nxt and nxt.get("role") == "tool"):
problems.append(f"[{i}] dangling tool_calls")
in_batch = True
else:
in_batch = False
return problems
def _sanitize(self, msgs):
result = cc._sanitize_tool_messages(msgs)
assert self._violations(result) == [], (
"sanitized transcript still violates the ordering contract: "
f"{self._violations(result)}"
)
assert cc._sanitize_tool_messages(result) == result, (
"sanitizing the sanitized transcript changed it"
)
return result
def test_user_then_calls_is_unchanged(self):
msgs = [
{"role": "user", "content": "search for X"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1", "function": {"name": "web_search"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "results"},
]
result = self._sanitize(msgs)
assert len(result) == 3
assert result[1].get("tool_calls") is not None
def test_second_batch_after_tool_response_is_unchanged(self):
"""A call batch whose predecessor is a tool response is valid."""
msgs = [
{"role": "user", "content": "search for X"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1", "function": {"name": "web_search"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "results"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c2", "function": {"name": "web_search"}}]},
{"role": "tool", "tool_call_id": "c2", "content": "more results"},
]
result = self._sanitize(msgs)
assert len(result) == 5
assert result[3].get("tool_calls") is not None
def test_two_batch_mid_history_reaches_stable_result(self):
"""A transcript that starts mid-history has no user/tool predecessor
for its first call batch. Dropping that batch leaves the second one
with no predecessor either, so the contract-valid, stable result is
empty; the sanitizer must not return the half-repaired transcript
where the second batch sits at the start without a predecessor.
"""
msgs = [
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1", "function": {"name": "web_search"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "results"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c2", "function": {"name": "web_search"}}]},
{"role": "tool", "tool_call_id": "c2", "content": "more results"},
]
result = self._sanitize(msgs)
assert result == []
def test_assistant_text_then_completed_call_is_preserved(self):
"""An assistant text turn directly before a completed call batch must
not cost the batch its tool_calls: the text is merged into the call
message so the executed call/result pair stays in the transcript.
"""
msgs = [
{"role": "user", "content": "plan my day"},
{"role": "assistant", "content": "I'll check the calendar.",
"tool_calls": [{"id": "c1", "function": {"name": "manage_calendar"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "[]"},
{"role": "assistant", "content": "Found it."},
{"role": "assistant", "content": "Let me look at day two.",
"tool_calls": [{"id": "c2", "function": {"name": "manage_calendar"}}]},
{"role": "tool", "tool_call_id": "c2", "content": "[]"},
]
result = self._sanitize(msgs)
assert len(result) == 5
assert result[1].get("tool_calls") is not None
merged = result[3]
assert merged.get("content") == "Found it.\n\nLet me look at day two."
assert merged.get("tool_calls") is not None
assert merged["tool_calls"][0]["id"] == "c2"
def test_assistant_text_directly_before_calls_merges(self):
msgs = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "Thinking out loud"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1", "function": {"name": "web_search"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "results"},
]
result = self._sanitize(msgs)
assert len(result) == 3
assert result[1]["content"] == "Thinking out loud"
assert result[1]["tool_calls"][0]["id"] == "c1"
def test_chain_of_text_turns_before_calls_merges_into_one(self):
msgs = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "one"},
{"role": "assistant", "content": "two"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1", "function": {"name": "web_search"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "results"},
]
result = self._sanitize(msgs)
assert len(result) == 3
assert result[1]["content"] == "one\n\ntwo"
assert result[1]["tool_calls"][0]["id"] == "c1"
def test_system_predecessor_calls_dropped(self):
msgs = [
{"role": "system", "content": "You are helpful"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1", "function": {"name": "web_search"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "results"},
]
result = self._sanitize(msgs)
assert [m["role"] for m in result] == ["system"]
def test_first_message_calls_dropped(self):
msgs = [
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1", "function": {"name": "web_search"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "results"},
]
assert self._sanitize(msgs) == []
def test_calls_with_content_after_system_keep_text(self):
msgs = [
{"role": "system", "content": "You are helpful"},
{"role": "assistant", "content": "I'll search for you",
"tool_calls": [{"id": "c1", "function": {"name": "web_search"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "results"},
]
result = self._sanitize(msgs)
assert [m["role"] for m in result] == ["system", "assistant"]
assert result[1].get("tool_calls") is None
assert "I'll search for you" in result[1]["content"]
def test_repeated_sanitation_is_stable_across_shapes(self):
shapes = [
[],
[{"role": "user", "content": "hi"}],
[{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1", "function": {"name": "f"}}]}],
[{"role": "tool", "tool_call_id": "c1", "content": "x"}],
[{"role": "user", "content": "hi"},
{"role": "tool", "tool_call_id": "c1", "content": "x"}],
[{"role": "assistant", "content": "a"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1", "function": {"name": "f"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "x"},
{"role": "assistant", "content": "b"},
{"role": "assistant", "content": "c",
"tool_calls": [{"id": "c2", "function": {"name": "f"}}]},
{"role": "tool", "tool_call_id": "c2", "content": "y"}],
]
for msgs in shapes:
once = cc._sanitize_tool_messages(msgs)
assert self._violations(once) == []
assert cc._sanitize_tool_messages(once) == once
def test_input_messages_are_not_mutated(self):
import copy
msgs = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "text"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1", "function": {"name": "f"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "x"},
]
before = copy.deepcopy(msgs)
cc._sanitize_tool_messages(msgs)
assert msgs == before
@pytest.mark.parametrize("content", [
[{"type": "text", "text": "Keep observation"}],
[{"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}],
[],
])
@pytest.mark.parametrize("dangling", [False, True])
def test_stripped_calls_preserve_multimodal_content(self, content, dangling):
if dangling:
messages = [
{"role": "user", "content": "Inspect image"},
{"role": "assistant", "content": content,
"tool_calls": [{"id": "c1", "function": {"name": "inspect"}}]},
]
else:
messages = [
{"role": "system", "content": "Instructions"},
{"role": "assistant", "content": content,
"tool_calls": [{"id": "c1", "function": {"name": "inspect"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "orphan"},
]
output = self._sanitize(messages)
expected = [messages[0]]
if content:
expected.append({"role": "assistant", "content": content})
assert output == expected
+81 -18
View File
@@ -50,17 +50,10 @@ def _launch_llm(
port: int,
serve_session_id: str | None = None,
) -> str:
import routes.cookbook_routes as cookbook_routes
from routes.cookbook.endpoint_registration import _auto_register_llm_endpoint
from routes.cookbook_helpers import ServeRequest
router = cookbook_routes.setup_cookbook_routes()
serve = next(
route.endpoint for route in router.routes
if route.path == "/api/model/serve" and "POST" in route.methods
)
cells = dict(zip(serve.__code__.co_freevars, serve.__closure__ or ()))
register = cells["_auto_register_llm_endpoint"].cell_contents
endpoint_id = register(
endpoint_id = _auto_register_llm_endpoint(
ServeRequest(repo_id=repo_id, cmd=f"vllm serve {repo_id} --port {port}"),
None,
serve_session_id=serve_session_id,
@@ -76,6 +69,83 @@ def _endpoint(factory, endpoint_id: str):
return db.get(database.ModelEndpoint, endpoint_id)
@pytest.mark.parametrize(
("cmd", "registers", "skips"),
[
(
"python scripts/diffusion_server.py --port 8100",
"_auto_register_image_endpoint",
"_auto_register_llm_endpoint",
),
(
"llama-server -m /models/model.gguf --port 8085",
"_auto_register_llm_endpoint",
"_auto_register_image_endpoint",
),
],
)
@pytest.mark.asyncio
async def test_serve_registers_endpoint_for_its_model_type(monkeypatch, tmp_path, cmd, registers, skips):
from unittest.mock import AsyncMock, MagicMock
from starlette.requests import Request
from routes import cookbook_routes
from routes.cookbook_helpers import ServeRequest
launch =MagicMock(returncode=0, wait=AsyncMock(return_value=0))
monkeypatch.setattr(cookbook_routes, "TMUX_LOG_DIR", tmp_path)
monkeypatch.setattr(cookbook_routes, "require_admin", lambda request: None)
monkeypatch.setattr(cookbook_routes, "_binary_available", AsyncMock(return_value=True))
monkeypatch.setattr(cookbook_routes, "load_stored_hf_token", lambda **kwargs: "")
monkeypatch.setattr(cookbook_routes.asyncio, "create_subprocess_shell", AsyncMock(return_value=launch))
# Returning no endpoint id keeps the crash watchdog from being scheduled.
register = MagicMock(return_value=None)
skip = MagicMock(return_value=None)
monkeypatch.setattr(cookbook_routes, registers, register)
monkeypatch.setattr(cookbook_routes, skips, skip)
router = cookbook_routes.setup_cookbook_routes()
serve = next(
route.endpoint for route in router.routes
if route.path == "/api/model/serve" and "POST" in route.methods
)
req = ServeRequest(repo_id="organization-a/Model-A", cmd=cmd, remote_host="gpu-box")
response = await serve(Request({"type": "http", "headers": []}), req)
register.assert_called_once_with(req, "gpu-box", serve_session_id=response["session_id"])
skip.assert_not_called()
def _register_ollama(cmd: str) -> str:
from routes.cookbook.endpoint_registration import _auto_register_llm_endpoint
from routes.cookbook_helpers import ServeRequest
endpoint_id = _auto_register_llm_endpoint(ServeRequest(repo_id="library/model", cmd=cmd), None)
assert endpoint_id
return endpoint_id
@pytest.mark.parametrize("host", ["0.0.0.0", "h" * 253])
def test_registers_port_from_ollama_host(endpoint_db, host):
endpoint_id = _register_ollama(f"OLLAMA_HOST={host}:11435 ollama serve")
assert _endpoint(endpoint_db, endpoint_id).base_url == "http://localhost:11435/v1"
def test_repeated_ollama_host_assignments_register_quickly(endpoint_db):
import time
start = time.perf_counter()
endpoint_id = _register_ollama("OLLAMA_HOST=" * 32000 + " ollama serve")
elapsed = time.perf_counter() - start
# The unbounded pattern took about 30s on this input and blocked the event loop.
assert elapsed < 4.0, f"registration took {elapsed:.2f}s"
assert _endpoint(endpoint_db, endpoint_id).base_url == "http://localhost:11434/v1"
@pytest.mark.parametrize("reuse", [False, True])
def test_launch_keeps_endpoint_of_other_model_with_same_short_name(endpoint_db, reuse):
if reuse:
@@ -448,20 +518,13 @@ async def test_ordinary_admin_delete_does_not_require_serve_owner(
def _launch_image(repo_id: str, port: int, monkeypatch) -> str:
import routes.cookbook_routes as cookbook_routes
import src.settings as settings
from routes.cookbook.endpoint_registration import _auto_register_image_endpoint
from routes.cookbook_helpers import ServeRequest
monkeypatch.setattr(settings, "load_settings", lambda: {"image_gen_enabled": True, "image_model": repo_id})
monkeypatch.setattr(settings, "save_settings", lambda _settings: None)
router = cookbook_routes.setup_cookbook_routes()
serve = next(
route.endpoint for route in router.routes
if route.path == "/api/model/serve" and "POST" in route.methods
)
cells = dict(zip(serve.__code__.co_freevars, serve.__closure__ or ()))
register = cells["_auto_register_image_endpoint"].cell_contents
endpoint_id = register(
endpoint_id = _auto_register_image_endpoint(
ServeRequest(repo_id=repo_id, cmd=f"python diffusion_server.py --model {repo_id} --port {port}"), None
)
assert endpoint_id
+2 -20
View File
@@ -156,28 +156,10 @@ async def test_scheduled_stop_requires_session_identity(endpoint_db, endpoint_ht
def _launch_image(repo_id, port, serve_session_id):
import routes.cookbook_routes as cookbook_routes
from routes.cookbook.endpoint_registration import _auto_register_image_endpoint
from routes.cookbook_helpers import ServeRequest
router = cookbook_routes.setup_cookbook_routes()
serve = next(
route.endpoint
for route in router.routes
if route.path == "/api/model/serve"
and "POST" in route.methods
)
cells = dict(
zip(
serve.__code__.co_freevars,
serve.__closure__ or (),
)
)
register = cells["_auto_register_image_endpoint"].cell_contents
endpoint_id = register(
endpoint_id = _auto_register_image_endpoint(
ServeRequest(
repo_id=repo_id,
cmd=f"python scripts/diffusion_server.py --port {port}",
+19
View File
@@ -93,3 +93,22 @@ def test_small_model_detection_uses_parameter_size_in_model_title():
assert DeepResearcher._looks_like_small_local_model("model-9B")
assert DeepResearcher._looks_like_small_local_model("model-10B")
assert not DeepResearcher._looks_like_small_local_model("model-11B")
def test_category_classification_turns_thinking_off(monkeypatch):
"""Reasoning lists every category name, which would defeat the label scan."""
sent = {}
async def fake_call(**kwargs):
sent.update(kwargs)
return "product"
monkeypatch.setattr("src.llm_core.llm_call_async", fake_call)
r = DeepResearcher.__new__(DeepResearcher)
r.llm_endpoint = "http://x"
r.llm_model = "m"
r.llm_headers = {}
assert asyncio.run(r._classify_category("Best ThinkPad to buy?")) == "product"
assert sent["thinking_mode"] == "off"
assert sent["max_tokens"] == 20
@@ -87,6 +87,58 @@ def test_llm_call_content_wins_over_reasoning_content(monkeypatch):
assert result == "Normal answer"
# ---------------------------------------------------------------------------
# Thinking off: reasoning never stands in for the answer
# ---------------------------------------------------------------------------
def test_llm_call_with_thinking_off_returns_empty_instead_of_reasoning(monkeypatch):
monkeypatch.setattr(
llm_core.httpx, "post",
lambda *a, **kw: _sync_response(_openai_msg("", "* User message: ...")),
)
result = llm_core.llm_call(
"http://test/v1", "qwen3-8b",
[{"role": "user", "content": "extract"}],
thinking_mode="off",
)
assert result == ""
def test_llm_call_async_with_thinking_off_returns_empty_instead_of_reasoning(monkeypatch):
class _FakeAsyncClient:
async def post(self, *a, **kw):
req = httpx.Request("POST", "http://test-async/v1/chat/completions")
return httpx.Response(200, request=req,
json=_openai_msg("", "* User message: ..."))
monkeypatch.setattr(llm_core, "_get_http_client",
lambda: _FakeAsyncClient())
result = asyncio.run(llm_core.llm_call_async(
"http://test-async/v1", "qwen3-8b",
[{"role": "user", "content": "extract"}],
thinking_mode="off",
))
assert result == ""
def test_llm_call_with_thinking_off_drops_mistral_structured_thinking(monkeypatch):
content = [
{"type": "thinking", "thinking": [{"type": "text", "text": "reasoning"}]},
{"type": "text", "text": "answer"},
]
monkeypatch.setattr(
llm_core.httpx, "post",
lambda *a, **kw: _sync_response(_openai_msg(content)),
)
result = llm_core.llm_call(
"http://test/v1", "some-model",
[{"role": "user", "content": "extract"}],
thinking_mode="off",
)
assert result == "answer"
# ---------------------------------------------------------------------------
# Streaming agent path tests (4 and 5)
# These import and test _empty_response_fallback — the real production helper
@@ -94,21 +146,7 @@ def test_llm_call_content_wins_over_reasoning_content(monkeypatch):
# changed, these tests will fail.
# ---------------------------------------------------------------------------
import sys
from unittest.mock import MagicMock
# Mock heavy DB/tool deps before importing agent_loop
for _mod in [
"sqlalchemy", "sqlalchemy.orm", "sqlalchemy.ext",
"sqlalchemy.ext.declarative", "sqlalchemy.ext.hybrid",
"sqlalchemy.sql", "sqlalchemy.sql.expression",
"src.database", "src.agent_tools",
"core.models", "core.database",
]:
if _mod not in sys.modules:
sys.modules[_mod] = MagicMock()
from src.agent_loop import _empty_response_fallback # noqa: E402
from src.agent_loop import _empty_response_fallback
# ---------------------------------------------------------------------------
+20
View File
@@ -232,6 +232,13 @@ class TestMatchProviderCurated:
def test_kimi_code_url(self):
assert _match_provider_curated("https://api.kimi.com/coding/v1", "openai") == "kimi-code"
def test_conifer_url(self):
assert _match_provider_curated("https://api.conifer.build/v1", "openai") == "openrouter"
assert _match_provider_curated("https://conifer.build/v1", "openai") == "openrouter"
def test_conifer_lookalike_url_not_matched(self):
assert _match_provider_curated("https://conifer.build.attacker.example/v1", "openai") == "openai"
def test_no_url_match_returns_provider(self):
assert _match_provider_curated("https://localhost:1234", "openai") == "openai"
@@ -339,6 +346,19 @@ class TestCurateModels:
assert curated == models
assert extra == []
def test_openrouter_sentinel_returns_all_as_curated(self):
models = ["model-a", "model-b", "any-custom-model"]
curated, extra = _curate_models(models, "openrouter")
assert curated == models
assert extra == []
def test_conifer_curate_models_via_match(self):
models = ["conifer-model-1", "conifer-model-2"]
curated_key = _match_provider_curated("https://api.conifer.build/v1", "openai")
curated, extra = _curate_models(models, curated_key)
assert curated == models
assert extra == []
def test_kimi_code_partitions(self):
models = ["kimi-for-coding", "other-model"]
curated, extra = _curate_models(models, "kimi-code")
+1
View File
@@ -45,6 +45,7 @@ class TestDetectProvider:
("https://api.openai.com/v1", "openai"),
("https://api.x.ai/v1", "openai"),
("https://api.deepseek.com", "openai"),
("https://api.conifer.build/v1", "openai"),
("https://generativelanguage.googleapis.com/v1beta/openai", "openai"),
# Ollama's OpenAI-compatible /v1 surface is generic, not native ollama.
("http://localhost:11434/v1", "openai"),
+11
View File
@@ -52,3 +52,14 @@ def _provider_label(url: str) -> str | None:
])
def test_provider_label_neutral_for_loopback(url, expected):
assert _provider_label(url) == expected
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
@pytest.mark.parametrize("url,expected", [
("https://api.conifer.build/v1", "Conifer"),
("https://conifer.build/v1", "Conifer"),
("https://API.CONIFER.BUILD/v1", "Conifer"),
("https://conifer.build.attacker.example/v1", "conifer.build.attacker.example"),
])
def test_provider_label_conifer(url, expected):
assert _provider_label(url) == expected
+30
View File
@@ -17,6 +17,7 @@ clear_fake_database_modules()
import core.database as cdb
import routes.task_routes as task_routes
from core.database import ScheduledTask
from routes.task.task_routes import TaskCreate
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_ENGINE = create_engine(
@@ -125,3 +126,32 @@ def test_scheduler_cycle_guard_treats_cross_owner_chain_as_unsafe():
assert scheduler._has_chain_cycle(db, "bob-target-cycle", owner="alice") is True
finally:
db.close()
@pytest.mark.asyncio
async def test_generated_task_name_turns_thinking_off(monkeypatch):
sent = {}
async def fake_call(**kwargs):
sent.update(kwargs)
return "Daily AI News"
monkeypatch.setattr("src.llm_core.llm_call_async", fake_call)
db = _TS()
try:
db.add(cdb.Session(
id="task-name-session", name="s", owner="alice",
endpoint_url="http://127.0.0.1:8081/v1", model="gemma4-26b",
))
db.commit()
finally:
db.close()
create_task = _endpoint("POST", "/api/tasks")
created = await create_task(
SimpleNamespace(state=SimpleNamespace(current_user="alice"), headers={}),
TaskCreate(prompt="Summarize AI news", task_type="llm", trigger_type="webhook"),
)
assert sent["thinking_mode"] == "off"
assert sent["max_tokens"] == 20
assert created["name"] == "Daily AI News"
+7
View File
@@ -196,6 +196,13 @@ def test_network_prompts_are_classified_as_local_computer_work():
assert _looks_like_local_computer_request(prompt), prompt
def test_named_targets_need_computer_task_wording_to_be_local_computer_work():
assert _looks_like_local_computer_request("check disk usage on gpu-box")
assert _looks_like_local_computer_request("copy the logs from gpu-box")
assert not _looks_like_local_computer_request("Who is Jensen Huang from NVIDIA?")
assert not _looks_like_local_computer_request("What is the best book on Rome?")
def test_network_lookup_replaces_ping_with_address_evidence():
normalized = _tui_normalize_network_host_command(
"ping -c 4 ajax.local",
+30
View File
@@ -393,6 +393,36 @@ def test_auto_title_on_a_different_utility_model_gets_no_chat_effort(util, monke
assert sent == [(UTIL[0], UTIL[1], None)]
def _capture_title_thinking(monkeypatch):
sent = []
async def fake_call(url, model, messages, **kw):
sent.append((kw.get("reasoning_effort"), kw.get("thinking_mode")))
return "A title"
monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
return sent
def test_auto_title_turns_thinking_off_without_a_configured_effort(util, monkeypatch):
from routes import chat_helpers
sent = _capture_title_thinking(monkeypatch)
monkeypatch.setattr(task_endpoint, "resolve_task_endpoint", lambda *a, **k: (UTIL[0], UTIL[1], {}))
asyncio.run(chat_helpers.auto_name_session(_Manager(), _title_session()))
assert sent == [(None, "off")]
def test_auto_title_keeps_thinking_default_with_a_configured_effort(util, monkeypatch):
from routes import chat_helpers
util.settings["alice"] = "medium"
sent = _capture_title_thinking(monkeypatch)
monkeypatch.setattr(task_endpoint, "resolve_task_endpoint", lambda *a, **k: (UTIL[0], UTIL[1], {}))
asyncio.run(chat_helpers.auto_name_session(_Manager(), _title_session()))
assert sent == [("medium", None)]
def test_memory_extraction_passes_chat_effort(util, monkeypatch):
from services.memory import memory_extractor