mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-11 23:52:20 +01:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0833f57192 |
@@ -14,8 +14,6 @@ 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:
|
||||
@@ -81,32 +79,6 @@ 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:
|
||||
|
||||
@@ -13,9 +13,6 @@
|
||||
|
||||
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 #
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
# Byte-compile sources — catches syntax errors without installing deps.
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: "22.23.4"
|
||||
- run: npm ci --ignore-scripts --no-audit --no-fund
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
with:
|
||||
python-version: "3.11"
|
||||
@@ -168,7 +168,7 @@ jobs:
|
||||
|
||||
# uv resolves the same versions as pip but installs ~5x faster, and its
|
||||
# cache keeps each shard from re-downloading the whole requirement set.
|
||||
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
- uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
with:
|
||||
version: "0.13.5"
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
uv pip install --system -r requirements.txt
|
||||
uv pip install --system "playwright==1.62.0"
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
with:
|
||||
node-version: "22.23.4"
|
||||
@@ -272,7 +272,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
# Test-only renderer, pinned independently of application requirements.
|
||||
|
||||
@@ -31,11 +31,11 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: none
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Lint Dockerfile
|
||||
uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0
|
||||
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
|
||||
with:
|
||||
dockerfile: Dockerfile
|
||||
# DL3008: pinning apt package versions is impractical on a -slim base
|
||||
|
||||
@@ -61,14 +61,14 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
with:
|
||||
driver: docker
|
||||
|
||||
# Build without pushing so a broken Dockerfile is caught here, and the
|
||||
# exact image we ship is what gets scanned.
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
@@ -107,12 +107,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
with:
|
||||
driver: docker
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
|
||||
|
||||
- name: Upload Trivy results
|
||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-image
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
|
||||
@@ -47,4 +47,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
|
||||
|
||||
@@ -55,16 +55,16 @@ jobs:
|
||||
shell: bash
|
||||
run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
pull: true
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: digest-${{ matrix.arch }}
|
||||
path: /tmp/digests/*
|
||||
@@ -107,15 +107,15 @@ jobs:
|
||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||
echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -62,9 +62,9 @@ jobs:
|
||||
env:
|
||||
SEARXNG_TEST_IMAGE: ${{ steps.image.outputs.image }}
|
||||
run: docker pull "$SEARXNG_TEST_IMAGE"
|
||||
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
- name: Check unmodified upstream trust and actual entrypoint offline
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: docker/searxng/Dockerfile
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
|
||||
+63
-107
@@ -1,50 +1,28 @@
|
||||
# Contributing to Odysseus
|
||||
|
||||
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)
|
||||
|
||||
---
|
||||
Thanks for helping. The project is moving quickly, so the best contributions are focused, easy to review, and easy to test.
|
||||
|
||||
## Branch model
|
||||
|
||||
| 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. |
|
||||
Odysseus has two branches:
|
||||
|
||||
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.
|
||||
- **`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.
|
||||
|
||||
End users cloning the repository land on `dev` by default. To use the curated release branch, run `git checkout main` after cloning.
|
||||
**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.
|
||||
|
||||
## Before You Start
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
## Setup
|
||||
|
||||
<details open>
|
||||
<summary><strong>Docker (recommended for normal testing)</strong></summary>
|
||||
Docker is the recommended path for normal testing:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/odysseus-dev/odysseus.git
|
||||
@@ -53,10 +31,7 @@ cp .env.example .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Manual Python development (Python 3.11+)</strong></summary>
|
||||
Manual development uses Python 3.11+:
|
||||
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
@@ -65,14 +40,11 @@ pip install -r requirements.txt
|
||||
python -m uvicorn app:app --host 127.0.0.1 --port 7000
|
||||
```
|
||||
|
||||
</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.
|
||||
Windows is not actively tested. Docker on Linux or a Linux/macOS manual install is the safer path for now.
|
||||
|
||||
## Running Checks
|
||||
|
||||
Run relevant checks for the affected area. Examples from the existing contributor workflow:
|
||||
Run the smallest relevant checks for your change:
|
||||
|
||||
```bash
|
||||
python -m pytest
|
||||
@@ -88,94 +60,78 @@ docker compose up -d --build
|
||||
docker compose logs --tail=120 odysseus
|
||||
```
|
||||
|
||||
> [!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.
|
||||
Mention what you ran in the pull request description. If you could not run a check, say so.
|
||||
|
||||
## Maintainer governance and review guidance
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
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.
|
||||
Good pull requests usually include:
|
||||
|
||||
| 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 |
|
||||
- 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`.
|
||||
|
||||
For exact required sections and attestations, follow the current [PR template](.github/pull_request_template.md).
|
||||
Please keep PRs small. Large PRs that mix unrelated cleanup, formatting, refactors, and behavior changes are much harder to review.
|
||||
|
||||
### 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.
|
||||
> **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.
|
||||
|
||||
## Style and visual changes
|
||||
|
||||
Odysseus has an intentional visual language. Changes that ignore it can be closed without merge even when their underlying code is correct.
|
||||
Odysseus has an intentional visual style. PRs that ignore it will be closed without merge, no matter how correct the underlying code is.
|
||||
|
||||
For any change affecting buttons, icons, typography, colors, spacing, layout, CSS, HTML, SVG, or JavaScript that renders into the DOM:
|
||||
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:
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
> [!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.
|
||||
If you are unsure whether a change is "visual," it is. Default to attaching a screenshot.
|
||||
|
||||
## Code conventions
|
||||
|
||||
**Use existing constants and helpers.** Hardcoded duplicates drift, break non-default deployments, and can reintroduce previously fixed issues.
|
||||
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.
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
**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.
|
||||
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.
|
||||
|
||||
## Issue Reports
|
||||
|
||||
| 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 |
|
||||
For bugs, include:
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
## Security
|
||||
|
||||
> [!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).
|
||||
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).
|
||||
|
||||
|
||||
+74
-104
@@ -1,117 +1,87 @@
|
||||
# Roadmap / Help Wanted
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
> [!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).
|
||||
If you see weird CSS, strange layout behavior, or a suspiciously murky corner of
|
||||
the codebase, you are probably right to stay away.
|
||||
|
||||
## At a glance
|
||||
## High Priority
|
||||
|
||||
| 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) |
|
||||
- SQUASH BUGS
|
||||
- Fresh install smoke tests on Linux, macOS, and Windows. Docker, native Python,
|
||||
and WSL all need coverage.
|
||||
|
||||
**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)
|
||||
- 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.
|
||||
|
||||
---
|
||||
## 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.
|
||||
|
||||
## Stabilization and reliability
|
||||
## Frontend
|
||||
|
||||
**Priority:** Make Odysseus dependable across installations, platforms, providers, and supported hardware.
|
||||
- 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.
|
||||
|
||||
| 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) |
|
||||
## Backend
|
||||
|
||||
Additional reliability priorities:
|
||||
- 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.
|
||||
|
||||
- **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.
|
||||
## Not The Focus Right Now
|
||||
|
||||
> [!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.
|
||||
I prob shouldnt add more themes.
|
||||
|
||||
@@ -9,7 +9,6 @@ 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 |
|
||||
@@ -23,7 +22,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, and the feature proposal workflow.
|
||||
- [Product and feature proposals](product-and-features.md): product approval versus technical ownership.
|
||||
- [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.
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
|
||||
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:** features that are **big**, set a **new direction**, or are **hard to undo**.
|
||||
**Product Owner approval before implementation:** substantial new features, changes to product direction, and product changes that are hard to reverse.
|
||||
|
||||
**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**.
|
||||
**No separate product approval by default:** bug fixes, security fixes, maintenance, tests, CI, documentation, internal refactors, and small, reversible product changes within existing direction.
|
||||
|
||||
Maintainers may make those smaller product decisions. Pewds can ask for changes or a rollback; the maintainer who decided owns the rollback or recovery.
|
||||
Maintainers may make those smaller product decisions. Pewds can ask for changes or a rollback; the maintainer owns safe rollback or recovery.
|
||||
|
||||
### Urgent security and reliability fixes
|
||||
|
||||
@@ -27,171 +25,19 @@ 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.
|
||||
|
||||
## Feature proposal workflow
|
||||
## Community or maintainer initiated
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
```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"]
|
||||
```text
|
||||
Feature proposal
|
||||
-> Product Owner accepts / rejects, where approval is required
|
||||
-> technical feasibility
|
||||
-> plan and implementation
|
||||
-> risk-based review and validation
|
||||
```
|
||||
|
||||
### 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.
|
||||
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.
|
||||
|
||||
## Decision boundaries
|
||||
|
||||
@@ -199,4 +45,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 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.
|
||||
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.
|
||||
|
||||
@@ -26,8 +26,6 @@ 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
|
||||
|
||||
@@ -696,10 +696,11 @@ async def auto_name_session(session_manager, sess):
|
||||
logger.info(f"Auto-named session {sess.id} deterministically (no authorized model endpoint): {title}")
|
||||
return
|
||||
|
||||
# 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)
|
||||
# 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.
|
||||
try:
|
||||
title = await llm_call_async(
|
||||
t_url,
|
||||
@@ -712,8 +713,7 @@ async def auto_name_session(session_manager, sess):
|
||||
max_tokens=64,
|
||||
headers=t_headers,
|
||||
timeout=15,
|
||||
reasoning_effort=title_effort,
|
||||
thinking_mode=None if title_effort else "off",
|
||||
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[auto-name] non-spec title call failed for %s: %s", sess.id, e)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Cookbook route domain package (#4071)."""
|
||||
@@ -1,284 +0,0 @@
|
||||
"""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()
|
||||
+271
-3
@@ -43,9 +43,6 @@ 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__)
|
||||
|
||||
@@ -1411,6 +1408,90 @@ 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:
|
||||
@@ -1599,6 +1680,193 @@ 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).
|
||||
|
||||
@@ -418,7 +418,6 @@ _HOST_TO_CURATED = (
|
||||
("nvidia.com", "nvidia"),
|
||||
("openrouter.ai", "openrouter"),
|
||||
("ollama.com", "ollama"),
|
||||
("conifer.build", "openrouter"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -340,7 +340,6 @@ 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()
|
||||
|
||||
+1
-16
@@ -64,21 +64,6 @@ _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|"
|
||||
@@ -179,7 +164,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", NAMED_COMPUTER_TASK),
|
||||
("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", "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.
|
||||
|
||||
+1
-2
@@ -44,7 +44,6 @@ 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,
|
||||
@@ -8765,7 +8764,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"
|
||||
rf"|{NAMED_COMPUTER_TASK}",
|
||||
r"|\b(?:on|from)\s+(?!this\b|my\b|the\b|a\b|an\b)(?:[a-z][a-z0-9_.-]{1,31})\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LOCAL_NETWORK_REFERENCE_RE = re.compile(
|
||||
|
||||
+11
-60
@@ -9,61 +9,9 @@ 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
|
||||
@@ -474,18 +422,21 @@ class ChatProcessor:
|
||||
generated_query = llm_call(
|
||||
t_url,
|
||||
t_model,
|
||||
_search_query_messages(
|
||||
message,
|
||||
[] if incognito else getattr(session, "history", None) or [],
|
||||
),
|
||||
[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Extract a concise search query from the user's message. "
|
||||
"Reply ONLY with the query."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": message},
|
||||
],
|
||||
headers=t_headers,
|
||||
temperature=0.1,
|
||||
max_tokens=50,
|
||||
timeout=15,
|
||||
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()
|
||||
).strip()
|
||||
|
||||
if generated_query:
|
||||
# LLM successfully generated a non-empty query -> use the generated query
|
||||
|
||||
+13
-89
@@ -42,15 +42,6 @@ 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],
|
||||
*,
|
||||
@@ -231,81 +222,22 @@ def normalize_compaction_summary(summary: str) -> str:
|
||||
|
||||
|
||||
def _sanitize_tool_messages(msgs: List[Dict]) -> List[Dict]:
|
||||
"""Normalize tool-call message ordering so strict providers accept the transcript.
|
||||
"""Drop orphaned `tool` messages and dangling assistant `tool_calls`.
|
||||
|
||||
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). 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.
|
||||
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)
|
||||
"""
|
||||
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.
|
||||
# Pass 1: drop orphan tool messages.
|
||||
cleaned: List[Dict] = []
|
||||
in_batch = False # are we right after an assistant tool_calls (or mid-batch)?
|
||||
for m in merged:
|
||||
for m in msgs:
|
||||
role = m.get("role")
|
||||
if role == "tool":
|
||||
if in_batch:
|
||||
@@ -313,21 +245,13 @@ def _normalize_tool_messages_once(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"):
|
||||
@@ -337,7 +261,7 @@ def _normalize_tool_messages_once(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 _has_preservable_content(m.get("content")):
|
||||
if not (m.get("content") or "").strip():
|
||||
continue # nothing left worth keeping
|
||||
out.append(m)
|
||||
return out
|
||||
|
||||
@@ -625,8 +625,7 @@ class DeepResearcher:
|
||||
# LLM helper
|
||||
# ------------------------------------------------------------------
|
||||
async def _llm(self, messages: List[Dict], temperature: float = 0.3,
|
||||
max_tokens: int = 4096, timeout: int = 60,
|
||||
thinking_mode: Optional[str] = None) -> str:
|
||||
max_tokens: int = 4096, timeout: int = 60) -> str:
|
||||
"""Call the LLM asynchronously and strip thinking tags."""
|
||||
from src.llm_core import llm_call_async
|
||||
response = await llm_call_async(
|
||||
@@ -637,7 +636,6 @@ class DeepResearcher:
|
||||
max_tokens=max_tokens,
|
||||
headers=self.llm_headers,
|
||||
timeout=timeout,
|
||||
thinking_mode=thinking_mode,
|
||||
)
|
||||
return strip_thinking(response)
|
||||
|
||||
@@ -698,7 +696,7 @@ class DeepResearcher:
|
||||
try:
|
||||
result = await self._llm(
|
||||
[{"role": "user", "content": prompt}],
|
||||
temperature=0, max_tokens=20, timeout=15, thinking_mode="off",
|
||||
temperature=0, max_tokens=20, timeout=15,
|
||||
)
|
||||
cat = (result or "").strip().lower()
|
||||
# Clean one-word answer first.
|
||||
|
||||
+23
-25
@@ -2321,25 +2321,6 @@ 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,
|
||||
@@ -2431,9 +2412,17 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
||||
elif provider == "ollama":
|
||||
response = _parse_ollama_response(data)
|
||||
else:
|
||||
response = _nonstream_openai_response_text(
|
||||
data["choices"][0]["message"], thinking_mode
|
||||
)
|
||||
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 ""
|
||||
_set_cached_response(cache_key, response)
|
||||
return response
|
||||
except Exception:
|
||||
@@ -2871,9 +2860,18 @@ async def llm_call_async(
|
||||
elif provider == "ollama":
|
||||
response = _parse_ollama_response(data)
|
||||
else:
|
||||
response = _nonstream_openai_response_text(
|
||||
data["choices"][0]["message"], thinking_mode
|
||||
)
|
||||
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 ""
|
||||
_set_cached_response(
|
||||
cache_key,
|
||||
response,
|
||||
|
||||
+91
-16
@@ -400,13 +400,9 @@ function _eventsForDay(dateStr) {
|
||||
}
|
||||
// Multi-day timed events: show on each day they span
|
||||
const startDate = _localDateOf(e.dtstart);
|
||||
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;
|
||||
const endDate = _localDateOf(e.dtend);
|
||||
if (startDate !== endDate) return startDate <= dateStr && endDate >= dateStr;
|
||||
return startDate === dateStr;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1162,25 +1158,51 @@ async function _renderMonth() {
|
||||
const dow = _weekStartSun ? first.getDay() : (first.getDay() + 6) % 7;
|
||||
const gs = new Date(y, m, 1 - dow);
|
||||
|
||||
// Use the existing inline event rows for every day an event covers.
|
||||
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.
|
||||
for (let row = 0; row < 6; row++) {
|
||||
h += '<div class="cal-week-row" style="--bars:0">';
|
||||
// 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}">`;
|
||||
// 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 dayEvents = _eventsForDay(d);
|
||||
const singles = _eventsForDay(d).filter(e => !multiUids.has(e.uid));
|
||||
const isWeekend = cd.getDay() === 0 || cd.getDay() === 6;
|
||||
const cls = 'cal-day' + (isOther ? ' cal-other' : '') + (isWeekend ? ' cal-weekend' : '') + (!dayEvents.length ? ' cal-empty-day' : '') + (d === today ? ' cal-today' : '') + (d === _selectedDay ? ' cal-selected' : '');
|
||||
const cls = 'cal-day' + (isOther ? ' cal-other' : '') + (isWeekend ? ' cal-weekend' : '') + (!singles.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>`;
|
||||
// Reuse the same rows for single-day and continuing events.
|
||||
if (dayEvents.length) {
|
||||
// Single events — show up to 3 inline rows (multi-day events are
|
||||
// drawn separately as an overlay below).
|
||||
if (singles.length) {
|
||||
const maxInline = window.innerWidth <= 768 ? 2 : 3;
|
||||
const showInline = dayEvents.slice(0, maxInline);
|
||||
const showInline = singles.slice(0, maxInline);
|
||||
for (const ev of showInline) {
|
||||
const t = ev.all_day || _localDateOf(ev.dtstart) !== d ? '' : _fmtTime(ev.dtstart);
|
||||
const t = ev.all_day ? '' : _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>` : '';
|
||||
@@ -1191,10 +1213,63 @@ async function _renderMonth() {
|
||||
<span class="cal-event-row-name">${_impMark}${_e(ev.summary)}${_eventReminderHtml(ev)}${_eventSourceHtml(ev)}</span>
|
||||
</div>`;
|
||||
}
|
||||
if (dayEvents.length > maxInline) h += `<div class="cal-event-more">+${dayEvents.length - maxInline} more</div>`;
|
||||
if (singles.length > maxInline) h += `<div class="cal-event-more">+${singles.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>';
|
||||
|
||||
+1
-2
@@ -2588,7 +2588,6 @@ 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
|
||||
@@ -2986,7 +2985,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
currentAbort = abortCtrl;
|
||||
|
||||
const _tState = Storage.loadToggleState();
|
||||
_isAgent = (_tState.mode || 'chat') === 'agent' || !!_tState.plan_mode || workspaceAgentIntent;
|
||||
const _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;
|
||||
|
||||
@@ -124,7 +124,6 @@ const _ENDPOINT_LABELS = [
|
||||
[/(^|\.)perplexity\.ai$/i, "Perplexity"],
|
||||
[/(^|\.)nvidia\.com$/i, "NVIDIA"],
|
||||
[/(^|\.)x\.ai$/i, "xAI"],
|
||||
[/(^|\.)conifer\.build$/i, "Conifer"],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
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():
|
||||
@@ -100,44 +97,6 @@ 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",
|
||||
|
||||
@@ -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
|
||||
# Month events share transparent dot rows; remaining solid tiles need contrast.
|
||||
assert "--cal-event-fg:${_calEventFg(md)}" in calendar_js
|
||||
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
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"""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,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, js_function_source
|
||||
from tests.helpers.js_modules import email_library_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -172,7 +172,8 @@ def test_calendar_event_cards_show_compact_source_badges():
|
||||
style_src = app_css()
|
||||
|
||||
assert "function _eventSourceHtml" in calendar_src
|
||||
assert "${_eventSourceHtml(ev)}" in js_function_source("_renderMonth", calendar_src)
|
||||
assert "${_eventSourceHtml(ev)}" in calendar_src
|
||||
assert "${_eventSourceHtml(md)}" in calendar_src
|
||||
assert ".cal-event-source" in style_src
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from unittest.mock import MagicMock
|
||||
import httpx
|
||||
from types import SimpleNamespace
|
||||
from src.chat_processor import ChatProcessor
|
||||
|
||||
@@ -94,124 +93,3 @@ 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?"}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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)
|
||||
@@ -516,235 +516,3 @@ 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
|
||||
|
||||
@@ -50,10 +50,17 @@ def _launch_llm(
|
||||
port: int,
|
||||
serve_session_id: str | None = None,
|
||||
) -> str:
|
||||
from routes.cookbook.endpoint_registration import _auto_register_llm_endpoint
|
||||
import routes.cookbook_routes as cookbook_routes
|
||||
from routes.cookbook_helpers import ServeRequest
|
||||
|
||||
endpoint_id = _auto_register_llm_endpoint(
|
||||
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(
|
||||
ServeRequest(repo_id=repo_id, cmd=f"vllm serve {repo_id} --port {port}"),
|
||||
None,
|
||||
serve_session_id=serve_session_id,
|
||||
@@ -69,83 +76,6 @@ 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:
|
||||
@@ -518,13 +448,20 @@ 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)
|
||||
endpoint_id = _auto_register_image_endpoint(
|
||||
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(
|
||||
ServeRequest(repo_id=repo_id, cmd=f"python diffusion_server.py --model {repo_id} --port {port}"), None
|
||||
)
|
||||
assert endpoint_id
|
||||
|
||||
@@ -156,10 +156,28 @@ async def test_scheduled_stop_requires_session_identity(endpoint_db, endpoint_ht
|
||||
|
||||
|
||||
def _launch_image(repo_id, port, serve_session_id):
|
||||
from routes.cookbook.endpoint_registration import _auto_register_image_endpoint
|
||||
import routes.cookbook_routes as cookbook_routes
|
||||
from routes.cookbook_helpers import ServeRequest
|
||||
|
||||
endpoint_id = _auto_register_image_endpoint(
|
||||
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(
|
||||
ServeRequest(
|
||||
repo_id=repo_id,
|
||||
cmd=f"python scripts/diffusion_server.py --port {port}",
|
||||
|
||||
@@ -93,22 +93,3 @@ 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,58 +87,6 @@ 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
|
||||
@@ -146,7 +94,21 @@ def test_llm_call_with_thinking_off_drops_mistral_structured_thinking(monkeypatc
|
||||
# changed, these tests will fail.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from src.agent_loop import _empty_response_fallback
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -232,13 +232,6 @@ 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"
|
||||
|
||||
@@ -346,19 +339,6 @@ 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")
|
||||
|
||||
@@ -45,7 +45,6 @@ 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"),
|
||||
|
||||
@@ -52,14 +52,3 @@ 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
|
||||
|
||||
@@ -17,7 +17,6 @@ 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(
|
||||
@@ -126,32 +125,3 @@ 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"
|
||||
|
||||
@@ -196,13 +196,6 @@ 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",
|
||||
|
||||
@@ -393,36 +393,6 @@ 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user