mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-01 10:42:23 +01:00
Compare commits
224
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a19d75481 | ||
|
|
39303f19cd | ||
|
|
5353241743 | ||
|
|
285d05e72e | ||
|
|
dc0fbbf7aa | ||
|
|
12b9c5f2ad | ||
|
|
cb26ccd9a7 | ||
|
|
23a480571a | ||
|
|
6a43cf23ea | ||
|
|
64b457dc0a | ||
|
|
ef3467e8d4 | ||
|
|
72e8520fa3 | ||
|
|
9276db9c11 | ||
|
|
a797cee0b3 | ||
|
|
76061498c8 | ||
|
|
04f8546d9d | ||
|
|
9835c1a7f9 | ||
|
|
239f09cbc4 | ||
|
|
a1a7f8fc9a | ||
|
|
87193d23f5 | ||
|
|
516f046fef | ||
|
|
48c3a7fc36 | ||
|
|
70fc58225c | ||
|
|
9efedc8b07 | ||
|
|
1d251ef867 | ||
|
|
bf5bf57bc1 | ||
|
|
2019a97339 | ||
|
|
9a1fdb192e | ||
|
|
bebf53be3c | ||
|
|
a8e0ff1c8e | ||
|
|
31685b050a | ||
|
|
03d25943ee | ||
|
|
71e3649985 | ||
|
|
45a57c0c38 | ||
|
|
71f6dc4e5d | ||
|
|
0c8a06a937 | ||
|
|
eea39ce103 | ||
|
|
9a5a15e17f | ||
|
|
f141ad2be5 | ||
|
|
a6e9294394 | ||
|
|
cfbe4aeca4 | ||
|
|
6f0679bb25 | ||
|
|
93c93bf076 | ||
|
|
f0f7464dfa | ||
|
|
1e57f774d3 | ||
|
|
2b310010cf | ||
|
|
b88154ecf9 | ||
|
|
e7796f71d3 | ||
|
|
412789051e | ||
|
|
b8adc4eb60 | ||
|
|
2246215b79 | ||
|
|
06fee0a3d8 | ||
|
|
f3813c261c | ||
|
|
e7a376b161 | ||
|
|
fec2bb11d6 | ||
|
|
c811f76d44 | ||
|
|
02d28c06d0 | ||
|
|
00ecf1c74e | ||
|
|
63feb227bb | ||
|
|
12fcddefab | ||
|
|
b46aa02ce1 | ||
|
|
352ee706f3 | ||
|
|
98b359cd4c | ||
|
|
2a2256af0e | ||
|
|
4f131d7c21 | ||
|
|
945e747eb4 | ||
|
|
61051d28f0 | ||
|
|
d87ae2a7f5 | ||
|
|
0e49c4ef40 | ||
|
|
1aff71836b | ||
|
|
b1c3125865 | ||
|
|
dbee1155e6 | ||
|
|
56c7cdf804 | ||
|
|
fe5d5caf34 | ||
|
|
bb654482f3 | ||
|
|
f45724faed | ||
|
|
6fc6095aec | ||
|
|
ef16398c5c | ||
|
|
6037707430 | ||
|
|
5c542d03ac | ||
|
|
4fe8fb28e5 | ||
|
|
84e232e7ee | ||
|
|
2cace27723 | ||
|
|
508c7b3d9d | ||
|
|
5b8a72931d | ||
|
|
2e7edf908d | ||
|
|
b80f9afb3e | ||
|
|
533d48ad8d | ||
|
|
d5bc7fb70e | ||
|
|
3b8c60ac1e | ||
|
|
d237c3fbba | ||
|
|
f510b1973a | ||
|
|
fe81c491cd | ||
|
|
1339efa856 | ||
|
|
6f5c28b345 | ||
|
|
aa2db8d87c | ||
|
|
8b8fc62449 | ||
|
|
0f996f58da | ||
|
|
18329268b0 | ||
|
|
6cc98f34b8 | ||
|
|
c493a66101 | ||
|
|
d5949c0286 | ||
|
|
9ec897d947 | ||
|
|
9257f0e6d0 | ||
|
|
a334a21d57 | ||
|
|
87ef52f7cc | ||
|
|
25617a8c10 | ||
|
|
4992cc620f | ||
|
|
59cca6b915 | ||
|
|
1ddda10716 | ||
|
|
958848f9f8 | ||
|
|
571ac09cea | ||
|
|
f979e5602f | ||
|
|
0f95032177 | ||
|
|
b3e2e628ab | ||
|
|
f1a45e85c5 | ||
|
|
f843a3dc01 | ||
|
|
72ba37af6b | ||
|
|
5bf4524618 | ||
|
|
1a7c79946b | ||
|
|
6d102a213f | ||
|
|
de8f151632 | ||
|
|
cce5892eeb | ||
|
|
f4e3ae6e68 | ||
|
|
671cbd79ac | ||
|
|
2e1c2a343a | ||
|
|
0116f0a62b | ||
|
|
c237a1cb9c | ||
|
|
6b284d2bc5 | ||
|
|
8acaec20ee | ||
|
|
5f18bcf6f8 | ||
|
|
18b6140511 | ||
|
|
eeb1107f7b | ||
|
|
4795c8d462 | ||
|
|
5c738a5a8e | ||
|
|
0ff5d47a8c | ||
|
|
f780f1dce9 | ||
|
|
57a1859f08 | ||
|
|
1c5853003e | ||
|
|
0f8b137713 | ||
|
|
2c5d5005ba | ||
|
|
901b3ff42d | ||
|
|
c207ed1ed8 | ||
|
|
8b3a866899 | ||
|
|
a646b59110 | ||
|
|
4079f12ae6 | ||
|
|
4d8d363681 | ||
|
|
a7e8a6ef2e | ||
|
|
12631da4d3 | ||
|
|
927985f847 | ||
|
|
2046ce893f | ||
|
|
35ef91b25c | ||
|
|
091364f7e6 | ||
|
|
91922d854c | ||
|
|
0133de6f0e | ||
|
|
641752387b | ||
|
|
339c6886cb | ||
|
|
25c2fad3e1 | ||
|
|
d105fbde95 | ||
|
|
da8ba69c74 | ||
|
|
0b8176b11e | ||
|
|
3246ae5d9c | ||
|
|
03baea7700 | ||
|
|
020fe02aa0 | ||
|
|
375e263419 | ||
|
|
8865c975aa | ||
|
|
a58a5e6632 | ||
|
|
106b1e7c86 | ||
|
|
49d57a43fe | ||
|
|
e67cb8bbb9 | ||
|
|
d9797ce10f | ||
|
|
2c48e7fdab | ||
|
|
9ab440c4cb | ||
|
|
ff1d7df8b0 | ||
|
|
6c2a1e6873 | ||
|
|
fa29a86696 | ||
|
|
8cfdb8e2e5 | ||
|
|
871c0c0687 | ||
|
|
76ab72412d | ||
|
|
5c6bb9798b | ||
|
|
56b85c359c | ||
|
|
04ca6c26c3 | ||
|
|
ae28501e7f | ||
|
|
fdb2a330d9 | ||
|
|
08452ab5f9 | ||
|
|
e461a6b5c5 | ||
|
|
aa91e69b2a | ||
|
|
c54bc1d2d3 | ||
|
|
9ab3f70fe4 | ||
|
|
89b41b9224 | ||
|
|
22144050fe | ||
|
|
e8551e9cbc | ||
|
|
3d47888559 | ||
|
|
ace6be44f3 | ||
|
|
36a71886bb | ||
|
|
39ca749f94 | ||
|
|
444b60d14e | ||
|
|
93da30c4af | ||
|
|
4d0610a1eb | ||
|
|
be9f575b59 | ||
|
|
1000b6582c | ||
|
|
b3f86a0724 | ||
|
|
94dc74e500 | ||
|
|
fd27e9a429 | ||
|
|
30430acfea | ||
|
|
76abce349b | ||
|
|
4954cc2e8c | ||
|
|
bf3c000d1c | ||
|
|
7fd4e0e9bb | ||
|
|
8cd96aad46 | ||
|
|
5fac693c1c | ||
|
|
eb940f24a3 | ||
|
|
6123b70b5c | ||
|
|
bfa514f6e8 | ||
|
|
9bdea753ae | ||
|
|
8fda203d28 | ||
|
|
364229f2dc | ||
|
|
1ff2fd0054 | ||
|
|
ad3e309074 | ||
|
|
15c4cef36a | ||
|
|
84cdd866c5 | ||
|
|
50b05bf54f | ||
|
|
dbfc96b8f3 | ||
|
|
a7c03f85bf |
@@ -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"
|
||||
@@ -179,8 +179,9 @@ jobs:
|
||||
uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
|
||||
python scripts/check_ca_trust.py --python
|
||||
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"
|
||||
@@ -256,3 +257,10 @@ jobs:
|
||||
env:
|
||||
PYTEST_SHARD: ${{ matrix.shard }}
|
||||
run: python -m pytest -q -rs --durations=25 --shard "$PYTEST_SHARD"
|
||||
|
||||
- name: Playwright mobile layout checks (shard 1/4 only)
|
||||
if: steps.docs-check.outputs.docs_only != 'true' && matrix.shard == '1/4'
|
||||
run: |
|
||||
ODYSSEUS_TEST_PYTHON=python ODYSSEUS_LAYOUT_BROWSER=chromium npx --no-install playwright test \
|
||||
--config tests/e2e/ios-layout.config.cjs \
|
||||
--reporter=line
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ services/data/
|
||||
|
||||
# Test capture artifacts (browser session dumps may contain personal content)
|
||||
.playwright-mcp/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
@@ -35,3 +35,15 @@ regexTarget = "secret"
|
||||
regexes = [
|
||||
'''^7abc5b334a6c3c52dd3eba15d8bc516da728a67f14fb7f56d0bb5992d89a16ca$''',
|
||||
]
|
||||
|
||||
[[allowlists]]
|
||||
description = "Synthetic dummy API key in trace artifact sanitizer test fixture"
|
||||
targetRules = ["generic-api-key"]
|
||||
condition = "AND"
|
||||
paths = [
|
||||
'''(?:^|/)tests/test_trace_artifact_sanitizer\.py$''',
|
||||
]
|
||||
regexTarget = "secret"
|
||||
regexes = [
|
||||
'''^sk-123456789$''',
|
||||
]
|
||||
|
||||
+18
-3
@@ -1,9 +1,22 @@
|
||||
# Both stages need the fixed native decoder, including curl in the builder and
|
||||
# distro Node in the runtime. A cached Python base may predate the Debian fix.
|
||||
FROM python:3.14.9-slim AS brotli-base
|
||||
COPY scripts/check_brotli.py /usr/local/bin/check-brotli.py
|
||||
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
|
||||
# Check inherited trust before the first HTTPS package request, retaining Debian's
|
||||
# signed archive verification. Refresh only the explicit native dependency.
|
||||
RUN python /usr/local/bin/check-ca-trust.py --system \
|
||||
&& sed -i 's|http://deb.debian.org/|https://deb.debian.org/|g' /etc/apt/sources.list.d/debian.sources \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libbrotli1 \
|
||||
&& python /usr/local/bin/check-brotli.py \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ----
|
||||
# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'],
|
||||
# which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so
|
||||
# the final image / Cookbook never has to compile the broken sdists. See
|
||||
# docker/build-realesrgan-wheels.sh for the full rationale.
|
||||
FROM python:3.14.9-slim AS realesrgan-wheels
|
||||
FROM brotli-base AS realesrgan-wheels
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
|
||||
@@ -14,7 +27,7 @@ RUN python -m pip install --no-cache-dir --require-hashes --only-binary=:all: --
|
||||
COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh
|
||||
RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels
|
||||
|
||||
FROM python:3.14.9-slim
|
||||
FROM brotli-base
|
||||
|
||||
# System deps. tmux is required by Cookbook for background downloads/serves.
|
||||
# openssh-client is required for Cookbook remote server tests, setup, probes,
|
||||
@@ -52,6 +65,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libglib2.0-0t64 \
|
||||
libxcb1 \
|
||||
libmagic1 \
|
||||
&& python /usr/local/bin/check-brotli.py --node /usr/bin/node \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Refresh both Python trust bundles before installing application dependencies.
|
||||
@@ -132,7 +146,8 @@ RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \
|
||||
# Check gRPC's effective defaults after all Python dependencies are installed.
|
||||
# The application minimum fixes its bundled roots; the checked override also
|
||||
# keeps the effective Docker selection consistent with system trust.
|
||||
RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc
|
||||
RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc \
|
||||
&& python /usr/local/bin/check-brotli.py --node /usr/bin/node
|
||||
|
||||
# Copy app code
|
||||
COPY . .
|
||||
|
||||
@@ -178,6 +178,14 @@ app.add_middleware(SecurityHeadersMiddleware)
|
||||
import asyncio as _asyncio
|
||||
from starlette.middleware.base import BaseHTTPMiddleware as _BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse as _JSONResponse
|
||||
import anyio as _anyio
|
||||
from starlette.requests import ClientDisconnect as _ClientDisconnect
|
||||
|
||||
# SSE endpoints (e.g. /api/editor/bridge/events) end/abort mid-stream when the
|
||||
# browser disconnects; Starlette's BaseHTTPMiddleware surfaces that as
|
||||
# anyio.EndOfStream (and sometimes ClientDisconnect). Those are client-side
|
||||
# events, not server errors, so we map them to a 499 instead of a 500 traceback.
|
||||
_STREAM_CLIENT_EXC = (_anyio.EndOfStream, _ClientDisconnect)
|
||||
|
||||
REQUEST_HARD_TIMEOUT = float(os.getenv("REQUEST_HARD_TIMEOUT", "45"))
|
||||
_TIMEOUT_EXEMPT_PREFIXES = (
|
||||
@@ -190,6 +198,7 @@ _TIMEOUT_EXEMPT_PREFIXES = (
|
||||
"/api/cookbook/setup", # remote pacman/apt installs
|
||||
"/api/upload", # large files
|
||||
"/api/image", # diffusion proxies (inpaint/harmonize/upscale/etc.) — own 120s httpx timeout
|
||||
"/api/editor", # editor agent/bridge: generative steps can take 30-110s (SAM + local diffusion)
|
||||
"/api/memory/audit", # retains own 120s LLM inactivity timeout
|
||||
)
|
||||
|
||||
@@ -225,15 +234,43 @@ class _InteractiveActivityMiddleware(_BaseHTTPMiddleware):
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# Heavy image ops that can run 30-150 s; a deploy restart must not cut these off.
|
||||
_HEAVY_IMAGE_OP_PREFIXES = (
|
||||
"/api/image/inpaint",
|
||||
"/api/image/replace-background",
|
||||
"/api/image/outpaint",
|
||||
"/api/image/extend",
|
||||
"/api/image/remove-bg",
|
||||
"/api/image/mask",
|
||||
"/api/image/upscale-local",
|
||||
"/api/image/denoise",
|
||||
"/api/image/enhance-face",
|
||||
"/api/gallery/ai-upscale",
|
||||
"/api/gallery/style-transfer",
|
||||
"/api/editor/agent",
|
||||
)
|
||||
|
||||
|
||||
class _SlowRequestLogMiddleware(_BaseHTTPMiddleware):
|
||||
async def dispatch(self, request, call_next):
|
||||
from src import activity
|
||||
path = request.url.path or ""
|
||||
heavy = any(path.startswith(p) for p in _HEAVY_IMAGE_OP_PREFIXES)
|
||||
if heavy:
|
||||
activity.begin_image_op(path)
|
||||
start = time.perf_counter()
|
||||
status = 500
|
||||
try:
|
||||
response = await call_next(request)
|
||||
status = getattr(response, "status_code", 0) or 0
|
||||
return response
|
||||
except _STREAM_CLIENT_EXC:
|
||||
# Client disconnected / SSE stream ended — not a server error.
|
||||
status = 499
|
||||
return _JSONResponse({"detail": "client disconnected"}, status_code=499)
|
||||
finally:
|
||||
if heavy:
|
||||
activity.end_image_op(path)
|
||||
elapsed = time.perf_counter() - start
|
||||
try:
|
||||
threshold = float(os.getenv("ODYSSEUS_SLOW_REQUEST_LOG_SECONDS", "0.75") or "0.75")
|
||||
@@ -822,6 +859,10 @@ app.include_router(setup_gallery_routes())
|
||||
from routes.editor_draft_routes import setup_editor_draft_routes
|
||||
app.include_router(setup_editor_draft_routes())
|
||||
|
||||
# Natural-language editor planning (planner wired to the configured chat model)
|
||||
from routes.editor_agent_routes import setup_editor_agent_routes
|
||||
app.include_router(setup_editor_agent_routes())
|
||||
|
||||
# Scheduled tasks + event bus
|
||||
from src.task_scheduler import TaskScheduler
|
||||
task_scheduler = TaskScheduler(session_manager)
|
||||
@@ -1000,6 +1041,16 @@ async def get_version():
|
||||
"source_commit": APP_SOURCE_COMMIT,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/busy")
|
||||
async def get_busy():
|
||||
"""Report in-flight heavy work so a deploy can wait instead of dropping it."""
|
||||
from src import activity
|
||||
return {
|
||||
"busy_image_ops": activity.heavy_image_ops_active(),
|
||||
**activity.activity(),
|
||||
}
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check() -> Dict[str, str]:
|
||||
return {"status": "healthy", "timestamp": datetime.now(timezone.utc).isoformat()}
|
||||
@@ -1438,4 +1489,35 @@ if __name__ == "__main__":
|
||||
bind_host = os.getenv("APP_BIND", "127.0.0.1")
|
||||
bind_port = int(os.getenv("APP_PORT", "7011"))
|
||||
|
||||
uvicorn.run(app, host=bind_host, port=bind_port, log_level="info")
|
||||
# Give in-flight requests time to finish on SIGTERM instead of being cut
|
||||
# off mid-flight. Diffusion edits run 60-120 s; outpaint/extend can run up
|
||||
# to ~330 s (provider cap 900 s), so default the graceful window to 900 s.
|
||||
# systemd's TimeoutStopSec must exceed this (see the graceful-stop drop-in).
|
||||
#
|
||||
# We hook uvicorn's signal handler so a SIGTERM also flips src.shutdown:
|
||||
# SSE / streaming generators check it and end promptly, so an open browser
|
||||
# stream cannot stall a restart for the whole window. The long window stays
|
||||
# a safety net for genuine in-flight image ops.
|
||||
from src import shutdown as _shutdown
|
||||
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
host=bind_host,
|
||||
port=bind_port,
|
||||
log_level="info",
|
||||
timeout_graceful_shutdown=int(os.getenv("ODYSSEUS_GRACEFUL_SHUTDOWN_TIMEOUT", "900") or 900),
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
# uvicorn's capture_signals() wires SIGINT/SIGTERM to self.handle_exit, so
|
||||
# wrapping that method lets us set the shutdown flag before the normal exit
|
||||
# path runs (this uvicorn version has no install_signal_handlers kwarg).
|
||||
_uvicorn_handle_exit = server.handle_exit
|
||||
|
||||
def _handle_shutdown_signal(signum, frame):
|
||||
_shutdown.request_shutdown()
|
||||
_uvicorn_handle_exit(signum, frame)
|
||||
|
||||
server.handle_exit = _handle_shutdown_signal
|
||||
|
||||
server.run()
|
||||
|
||||
@@ -82,13 +82,15 @@ services:
|
||||
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
|
||||
# Host workspace translation is opt-in. Keep the public compose file
|
||||
# user-neutral; configure these in a local .env or use the host-workspace
|
||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
||||
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
|
||||
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
|
||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||
@@ -165,7 +167,7 @@ services:
|
||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||
exec /usr/local/searxng/entrypoint.sh
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- searxng-data:/etc/searxng
|
||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||
|
||||
@@ -81,13 +81,15 @@ services:
|
||||
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
|
||||
# Host workspace translation is opt-in. Keep the public compose file
|
||||
# user-neutral; configure these in a local .env or use the host-workspace
|
||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
||||
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
|
||||
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
|
||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||
@@ -168,7 +170,7 @@ services:
|
||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||
exec /usr/local/searxng/entrypoint.sh
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- searxng-data:/etc/searxng
|
||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||
|
||||
+4
-2
@@ -70,13 +70,15 @@ services:
|
||||
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
|
||||
# Host workspace translation is opt-in. Keep the public compose file
|
||||
# user-neutral; configure these in a local .env or use the host-workspace
|
||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
||||
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
|
||||
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
|
||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||
@@ -146,7 +148,7 @@ services:
|
||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||
exec /usr/local/searxng/entrypoint.sh
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- searxng-data:/etc/searxng
|
||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||
|
||||
@@ -31,21 +31,43 @@ for spec in $SPECS; do
|
||||
name="${spec%%==*}"
|
||||
ver="${spec##*==}"
|
||||
# pip download builds metadata (and trips the same bug), so fetch the raw
|
||||
# sdist URL from the PyPI JSON API instead.
|
||||
url="$(python - "$name" "$ver" <<'PY'
|
||||
import json, sys, urllib.request
|
||||
name, ver = sys.argv[1], sys.argv[2]
|
||||
data = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{ver}/json"))
|
||||
for f in data["urls"]:
|
||||
if f["packagetype"] == "sdist":
|
||||
print(f["url"]); break
|
||||
# sdist URL from the PyPI JSON API instead. Use curl with retries to
|
||||
# survive transient TLS / network failures that urllib cannot recover from.
|
||||
metadata="${name}-${ver}.json"
|
||||
curl --fail --silent --show-error --location \
|
||||
--retry 8 \
|
||||
--retry-delay 3 \
|
||||
--retry-max-time 120 \
|
||||
--retry-all-errors \
|
||||
"https://pypi.org/pypi/${name}/${ver}/json" \
|
||||
-o "$metadata"
|
||||
|
||||
url="$(python - "$metadata" <<'PY'
|
||||
import json, sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
for item in data["urls"]:
|
||||
if item["packagetype"] == "sdist":
|
||||
print(item["url"])
|
||||
break
|
||||
else:
|
||||
sys.exit(f"no sdist found for {name}=={ver}")
|
||||
raise SystemExit("no sdist found")
|
||||
PY
|
||||
)"
|
||||
)"
|
||||
rm -f "$metadata"
|
||||
|
||||
echo ">> fetching ${name} ${ver}: ${url}"
|
||||
curl -fsSL "$url" -o "${name}.tar.gz"
|
||||
curl --fail --silent --show-error --location \
|
||||
--retry 8 \
|
||||
--retry-delay 3 \
|
||||
--retry-max-time 120 \
|
||||
--retry-all-errors \
|
||||
"$url" \
|
||||
-o "${name}.tar.gz"
|
||||
tar xzf "${name}.tar.gz"
|
||||
rm -f "${name}.tar.gz"
|
||||
done
|
||||
|
||||
echo ">> patching get_version()"
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
# Editor Agent API
|
||||
|
||||
How an agent (LLM or script) drives the PhotoCraft editor. There are three layers:
|
||||
|
||||
1. **Control API** — `window.geEditorControl`, a stable in-page command surface.
|
||||
2. **NL planner** — `window.geEditorAI`, turns a prompt into control-API steps (LLM or basic mode).
|
||||
3. **Bridge** — server-mediated SSE channel so a *server-side* agent can drive an **open** editor.
|
||||
|
||||
All three are owner-scoped by the app session; nothing is exposed to unauthenticated users.
|
||||
|
||||
---
|
||||
|
||||
## 1. Control API (`window.geEditorControl`)
|
||||
|
||||
Installed when the editor opens. Version `EDITOR_CONTROL_VERSION`.
|
||||
|
||||
```js
|
||||
await window.geEditorControl.invoke(op, params) // { ok, op, result } | { ok:false, op, error }
|
||||
await window.geEditorControl.run([{op, params}]) // sequential, stops on first failure
|
||||
window.geEditorControl.list() // [{op, description}]
|
||||
```
|
||||
|
||||
`invoke` is async. Every op that mutates the document pushes **one** undo entry via
|
||||
`_saveState`, so a single op is one undo step.
|
||||
|
||||
### Op catalog
|
||||
|
||||
| Op | Params | Notes |
|
||||
| --- | --- | --- |
|
||||
| `document.info` | – | size, layers, mode |
|
||||
| `document.importImage` | `{dataUrl,name,raster}` | load a raster image as a new layer |
|
||||
| `layers.list` | – | top-most first |
|
||||
| `layers.select` | `{id\|index\|name}` | index is top-most-first |
|
||||
| `layers.addRaster` | – | new empty pixel layer |
|
||||
| `layers.addAdjustment` | `{type,params}` | adjustment layer |
|
||||
| `layers.duplicate` / `layers.delete` | – | on the active/selected layer(s) |
|
||||
| `layers.setOpacity` | `{id?,opacity 0-1}` | |
|
||||
| `layers.setBlend` | `{id?,blendMode\|mode}` | |
|
||||
| `layers.group` / `layers.ungroup` | – | |
|
||||
| `selection.selectAll` / `deselect` / `invert` | – | |
|
||||
| `selection.rectangle` / `selection.ellipse` | `{x,y,w,h}` | document space |
|
||||
| `selection.subject` | `{label}` | SAM text grounding (falls back to a centred rect) |
|
||||
| `selection.face` | – | SAM "face" (falls back to an upper-centre ellipse) |
|
||||
| `selection.sky` | – | SAM "sky" (falls back to a top-band colour range) |
|
||||
| `selection.background` | `{fuzziness}` | border colour union |
|
||||
| `selection.samText` | `{text}` | SAM text grounding only (throws without SAM) |
|
||||
| `selection.colorRange` | `{color,fuzziness}` | |
|
||||
| `selection.hasMask` | – | `{active, space}` |
|
||||
| `adjustments.apply` | `{type,params}` | adjustment layer; camelCase types accepted |
|
||||
| `adjustments.applyMasked` | `{brightness,contrast,saturation,warmth,invert}` | pixel pass, masked if a selection exists else whole layer |
|
||||
| `filters.apply` | `{id}` | opens the slider dialog and applies |
|
||||
| `filters.applyMasked` | `{id,radius,amount,invert}` | `id`: blur-gaussian, sharpen, unsharp-mask |
|
||||
| `transform.start` / `transform.apply` / `transform.cancel` | – | |
|
||||
| `text.add` | `{text,x,y,color,size,font,align}` | |
|
||||
| `history.undo` / `history.redo` | – | |
|
||||
| `history.mark` | – | returns current undo depth (for grouping) |
|
||||
| `history.collapse` | `{mark,label}` | collapses entries above `mark` into one |
|
||||
| `export.png` | – | `{dataUrl}` of the flattened document |
|
||||
| `export.gallery` | – | saves a copy back to the gallery |
|
||||
| `screenshot` | – | `{dataUrl,width,height}` of the composited canvas |
|
||||
|
||||
Param conventions the planner must respect:
|
||||
|
||||
- `adjustments.applyMasked`: `brightness`/`contrast`/`warmth` are **-100..100** (0 neutral);
|
||||
`saturation` is a **multiplier** (1 neutral; values >3 are treated as percentages).
|
||||
- `adjustments.apply` `type` aliases are normalised (`blackAndWhite` -> `black-white`).
|
||||
- `color-balance` params are `{shadows:{r,g,b}, midtones:{r,g,b}, highlights:{r,g,b}}`;
|
||||
a bare number is coerced to `{r:n,g:n,b:n}`.
|
||||
- A masked op with **no** active selection degrades to a whole-layer edit (does not throw).
|
||||
|
||||
---
|
||||
|
||||
## 2. NL planner (`window.geEditorAI`)
|
||||
|
||||
```js
|
||||
const res = await window.geEditorAI.execute('blur the background behind the person', { onStep });
|
||||
// exec: window.geEditorAI.execute('blur the background behind the person', { onStep });
|
||||
// res = { ok, prompt, reply, planner, basicMode, steps, trace, failed }
|
||||
```
|
||||
|
||||
Flow (`static/js/editor/nl-edit.js`):
|
||||
|
||||
1. Read document state + a **before** screenshot.
|
||||
2. Ask the **server planner** (`POST /api/editor/plan`) with the prompt, state and
|
||||
screenshot. The client passes its strong guidance via `state.planner_guidance`.
|
||||
If the route is unavailable it falls back to `localPlan` (regex — labelled
|
||||
**"basic mode"** in the UI).
|
||||
3. `sanitizeSteps()`: drop no-op/export steps and, if a masked op has no preceding
|
||||
`selection.*`, insert the region implied by the prompt (face/sky/background/subject).
|
||||
4. `history.mark`, then run each step, screenshotting after each one.
|
||||
5. **Self-check** (server plans only, max 3 rounds): send the after screenshot back in
|
||||
`mode:"check"` with the original intent. If not satisfied, **revert** the previous
|
||||
attempt (undo its history entries) and apply the model's **full corrected** plan.
|
||||
6. `history.collapse` — the whole request becomes a single undoable step.
|
||||
|
||||
UI: the Quick Edit box routes multi-clause / "and ..." prompts to this executor and
|
||||
streams `Step i/n: <op>` into its status line; a `(basic mode)` suffix marks the offline planner.
|
||||
|
||||
### Server route
|
||||
|
||||
`POST /api/editor/plan` (HARNESS, `routes/editor_agent_routes.py`)
|
||||
|
||||
```
|
||||
{ prompt, state, mode?: "plan"|"check", screenshot?: "data:image/..." }
|
||||
-> { reply, planner, steps: [{op, params}], done? }
|
||||
```
|
||||
|
||||
Owner-scoped, authenticated. Uses the configured default/utility model with vision.
|
||||
If the model call is unavailable it returns a deterministic `planner:"fallback"` plan.
|
||||
|
||||
---
|
||||
|
||||
## 3. Bridge (server agent drives an open editor)
|
||||
|
||||
`static/js/editor/editor-bridge.js` subscribes the open editor to the server queue.
|
||||
Enabled automatically when the editor opens (`window.__geEditorBridge`).
|
||||
|
||||
- Subscribe: `GET /api/editor/bridge/events?session=<id>` — SSE, `data: {id,op,params}` (keepalive every 15s).
|
||||
- The page runs the op via the control API and replies:
|
||||
`POST /api/editor/bridge/result {id,ok,result,error,screenshot,ms}`.
|
||||
- Enqueue a command (server side): `POST /api/editor/bridge/command {session, op|steps, wait, timeout}`.
|
||||
With `wait:true` and one step the call blocks and returns the editor's result.
|
||||
Otherwise use `POST /api/editor/bridge/await {id, timeout}` for each queued id.
|
||||
Timeouts are seconds, greater than zero and at most 900. Pending results expire
|
||||
after the command timeout; an unknown or another owner's id returns 404.
|
||||
|
||||
Round-trip example (authenticated; `<session>` = `window.__geEditorBridge.session`):
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:7011/api/editor/bridge/command \
|
||||
-H 'Content-Type: application/json' -b "odysseus_session=$TOKEN" \
|
||||
-d '{"session":"default","op":"layers.addRaster","wait":true,"timeout":8}'
|
||||
# -> {"id":"...","ok":true,"result":{...},"screenshot":"data:image/png;base64,..."}
|
||||
```
|
||||
|
||||
Session scoping uses the authenticated owner and session as separate values. A
|
||||
bound editor-tool turn uses its server-owned session, not a model-selected one.
|
||||
The bridge is a **browser-only** flow: it requires an editor page to be open.
|
||||
For `/api/editor/agent`, clients must send `incognito: true` for private image
|
||||
results; omitted or false requests use normal gallery persistence. These
|
||||
owner-scoped routes do not accept delegated API tokens without a scope contract.
|
||||
|
||||
---
|
||||
|
||||
## 4. SAM dependency
|
||||
|
||||
`selection.subject` / `selection.face` / `selection.sky` / `selection.samText` use the
|
||||
server SAM mask tool (`POST /api/image/mask`). When the SAM tools are **not installed**
|
||||
the editor raises *"Object mask tools are not installed"* and the control op falls back
|
||||
to a **coarse approximation** (centred rect / upper-centre ellipse / top-band colour range).
|
||||
On a plain background the approximation is acceptable; on real photos it leaks, producing
|
||||
visible rectangular/elliptical artifacts. Install the Cookbook SAM mask dependencies for
|
||||
true subject/face/sky segmentation.
|
||||
|
||||
`selection.colorRange` and `selection.background` never need SAM (pure colour matching),
|
||||
but they are also coarse on busy scenes.
|
||||
|
||||
---
|
||||
|
||||
## 5. Two ways for an agent to use this — pick one
|
||||
|
||||
### Option A — built-in tool (in-process)
|
||||
|
||||
Register an editor-control tool inside the app (same process as the model) that calls the
|
||||
bridge/control API directly. Uses the app's normal login; nothing new is exposed. This is
|
||||
the rejected stdio-MCP design's replacement: a server-side tool that talks to the bridge
|
||||
needs either an internal token (auth-bypass) or to run in-process. **Decision pending (user).**
|
||||
|
||||
### Option B — browser-only flow (works today)
|
||||
|
||||
No new server tool. An external agent drives an open editor purely through the bridge
|
||||
endpoints using the logged-in owner's session. Restricted to the editor the user has open.
|
||||
This is the flow demonstrated above and requires **no** auth-bypass.
|
||||
|
||||
A stdio `photo_editor` MCP server was prototyped and **rejected** because the built-in MCP
|
||||
subprocess has no internal token; wiring it would require expanding an auth bypass.
|
||||
The two viable options above are left for the user to choose.
|
||||
|
||||
---
|
||||
|
||||
## 6. Known limitations / honest grading
|
||||
|
||||
Live run against real 7011 with the server LLM planner (7 prompts): all 7 executed
|
||||
(`planner:"llm"`). Graded by eye:
|
||||
|
||||
- PASS: black & white portrait; warm cinematic; sharper + more vivid; text title.
|
||||
- FAIL (region-masked, SAM-bound): "dramatic sky" (global over-saturation instead of a
|
||||
dramatic, masked sky), "blur the background" (coarse mask leaks; visible rectangle),
|
||||
"brighten the face" (coarse ellipse/rect artifact).
|
||||
|
||||
Whole-image edits are reliable; subject/face/sky region edits need SAM. The self-check
|
||||
loop works (revert-and-replan) but the region cases spend all 3 iterations.
|
||||
|
||||
## 7. Theming (control kit)
|
||||
|
||||
The editor's interactive controls (ranges, number fields, selects, checkboxes) share a
|
||||
single themed kit: `static/js/editor/control-kit.js` adds `.ge-ctl-*` classes scoped to
|
||||
the editor roots (`#gallery-editor-container`, `.ge-adj-popup`, `.ge-filter-overlay`,
|
||||
`.ge-canvas-size-overlay`, `.ge-float-window`, `.ge-transform-popup`), and
|
||||
`static/css/13-image-editor.css` (section "Editor control kit") styles them from the app
|
||||
design tokens (`static/css/00-tokens.css`) to match the app sliders/selects. It keeps
|
||||
range fills in sync and attaches a **scrub-on-label** affordance to numeric fields
|
||||
(drag the field's label to change the value; arrow keys work, Shift = x10).
|
||||
|
||||
**The PhotoCraft editor is dark-only.** `:root.light` flips the global tokens, but
|
||||
`13-image-editor.css` has no `.light` handling and the editor relies on dark surfaces,
|
||||
so light mode renders a garbled mix. Unlike the rest of Odysseus, the editor follows
|
||||
Photoshop and is not offered in a light theme.
|
||||
@@ -0,0 +1,255 @@
|
||||
# Quick Edit Coverage Audit
|
||||
|
||||
Audit of how much of the PhotoCraft editor is reachable through **Quick Edit**
|
||||
(the natural-language editor agent). Built against build **0.20.44** (HARNESS
|
||||
bridge fan-out fix `4dd11826`) and client commit `e78b5a96`.
|
||||
|
||||
Quick Edit executes in two stages:
|
||||
|
||||
1. **Plan** — `static/js/editor/editor-agent.js` posts `{prompt, state,
|
||||
screenshot}` to `/api/editor/agent`. HARNESS `routes/editor_agent_routes.py`
|
||||
plans with the shared style guide (`src/editor_style_guide.py`) plus an LLM,
|
||||
validates every step against its own `OP_CATALOG`, then executes.
|
||||
2. **Execute** — only `ai_edit_image` / `generate_image` run server-side; every
|
||||
other op is delivered to the page over the editor bridge (SSE) and run by the
|
||||
client control surface (`window.geEditorControl.invoke`, catalog in
|
||||
`static/js/galleryEditor.js`). A local fallback planner lives in
|
||||
`static/js/editor/nl-edit.js`.
|
||||
|
||||
There are **three** catalogs that must agree:
|
||||
|
||||
| Catalog | File | Owner |
|
||||
|---|---|---|
|
||||
| Client control ops | `static/js/galleryEditor.js` | PHOTOSHOP |
|
||||
| Server `OP_CATALOG` | `routes/editor_agent_routes.py` | HARNESS |
|
||||
| Style guide / planner | `src/editor_style_guide.py` + `nl-edit.js` | HARNESS + PHOTOSHOP |
|
||||
|
||||
Legend: **C** = in client catalog · **S** = in server `OP_CATALOG` (incl.
|
||||
`NEW_OP_NAMES`) · **P** = the planner/style guide emits it for some phrasing.
|
||||
A capability is **usable from Quick Edit** only when the planner can emit it
|
||||
(**P**) and the op exists on the side that runs it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Menu bar (12 menus)
|
||||
|
||||
| Capability | Trigger phrasing | Op | C | S | P | Status |
|
||||
|---|---|---|---|---|---|---|
|
||||
| File → Import | "open this image" | `document.importImage` | ✔ | ✔ | partial | OK |
|
||||
| File → Load project | "open my project" | `document.openPsd` | ✔ | ✘ | ✘ | **Server gap** |
|
||||
| File → Save / Save copy / Export PSD | "save as psd" | `document.savePsd` | ✔ | ✔ | ✔ | OK |
|
||||
| File → Export | "export png" | `export.png` | ✔ | ✔ | ✔ | OK |
|
||||
| File → Save project | — | (project store) | n/a | n/a | ✘ | Out of scope (not an edit op) |
|
||||
| Edit → Undo/Redo/History | "undo" | `history.undo` / `redo` | ✔ | ✔ | ✔ | OK |
|
||||
| Edit → Free Transform | "free transform" | `transform.start` | ✔ | ✔ | ✔ | OK |
|
||||
| Edit → Transform → Rotate 90/180 | "rotate this layer 90" | `layer.rotate` | ✔ | ✔ | ✔ | OK (whole image vs layer routed by the fast path) |
|
||||
| Edit → Transform → Flip H/V | "flip this layer" | `layer.flip` | ✔ | ✔ | ✔ | OK (whole image vs layer routed by the fast path) |
|
||||
| Image → Fill | "fill the whole image with white" | — | ✘ | ✘ | ✘ | **No op** |
|
||||
| Image → Canvas Size | "make the canvas wider" | `document.resizeCanvas` | ✔ | ✔ | partial | OK |
|
||||
| Image → Image Size | "resize the image to 1200px" | `document.resizeCanvas` | ✔ | ✔ | partial | OK |
|
||||
| Image → Rotate 90/180 (whole image) | "rotate the whole image 90" | `document.rotate`/all-layers | ✘ | ✘ | ✘ | **No control op** |
|
||||
| Image → Flip H/V (whole image) | "flip the whole canvas" | `document.flip`/all-layers | ✘ | ✘ | ✘ | **No control op** |
|
||||
| Image → Mode RGB/Grayscale | "make it black and white" | `adjustments.apply` black-white | ✔ | ✔ | ✔ | OK (mode via adjustment) |
|
||||
| Image → Adjustments → Invert | "invert the colours" | `adjustments.apply` invert | ✔ | ✔ | ✔ | OK (fast path colour-inverts the active layer; "invert the selection" stays Select ▸ Inverse) |
|
||||
| Image → Mode CMYK/Lab, Bits 16/32 | — | — | ✘ | ✘ | ✘ | Not implemented (disabled in UI) |
|
||||
| Layer → New | "add a layer" | `layers.addRaster` | ✔ | ✔ | partial | OK |
|
||||
| Layer → Duplicate | "duplicate the layer" | `layers.duplicate` | ✔ | ✔ | partial | OK |
|
||||
| Layer → Group | "group the layers" | `layers.group` | ✔ | ✔ | ✘ | **Server OK, planner gap** |
|
||||
| Layer → Rasterize | "rasterize the text" | `layers.rasterize` | ✔ | ✔ | ✔ | OK |
|
||||
| Layer → Delete | "delete the layer" | `layers.delete` | ✔ | ✔ | partial | OK |
|
||||
| Layer → Merge Down | "merge down" | `layers.mergeDown` | ✔ | ✘ | ✘ | **Server gap** |
|
||||
| Layer → Merge All / Flatten | "flatten the image" | `layers.flatten` | ✔ | ✘ | ✘ | **Server gap** |
|
||||
| Layer → Select All | "select all layers" | (multi-select) | ✔ | ✔ | ✔ | OK (fast path selects every layer) |
|
||||
| Layer → Combine (unite/subtract/…) | "combine the shapes" | `_combineSelectedShapes` | ✔ | ✔ | ✔ | OK (fast path selects the shape layers and combines) |
|
||||
| Layer → Fill | "fill the layer with blue" | `selection.fill` / flood | partial | ✘ | ✘ | **Gap** |
|
||||
| Layer → Layer Mask (reveal/hide/apply/invert/feather) | "add a layer mask" | `layers.addMaskFromSelection` | ✔ | ✔ | ✔ | OK |
|
||||
| Layer → Opacity | "set the layer opacity to 50%" | `layers.setOpacity` | ✔ | ✔ | partial | OK (fast path sets the active layer's opacity) |
|
||||
| Layer → Blend Mode | "set the blend mode to multiply" | `layers.setBlend` | ✔ | ✔ | partial | OK (fast path sets the active layer's blend mode) |
|
||||
| Type → Add | "add a title" | `text.add` | ✔ | ✔ | ✔ | OK |
|
||||
| Type → Character | "make the text bigger" | `text.edit` | ✔ | ✘ | ✘ | **Server gap + planner gap** |
|
||||
| Type → Paragraph | "centre the text" | `text.layout` | ✔ | ✔ | partial | OK |
|
||||
| Type → Warp | "warp the text" | `_rerenderTextLayer` (warp) | ✔ | ✔ | ✔ | OK |
|
||||
| Select → All/Deselect/Invert | "invert the selection" | `selection.*` | ✔ | ✔ | ✔ | OK |
|
||||
| Select → Reselect | "reselect" | `_reselectSelection` | ✔ | ✔ | ✔ | OK |
|
||||
| Select → Transform | "transform the selection" | `transform.start` | ✔ | ✔ | partial | OK |
|
||||
| Select → Refine | "refine the edge" | `selection.feather/expand` | ✔ | ✘ | ✘ | **Server gap** |
|
||||
| Select → Color Range | "select the blue sky" | `selection.colorRange` | ✔ | ✔ | partial | OK |
|
||||
| Select → Select and Mask | "select the subject" | `selection.subject` | ✔ | ✔ | ✔ | OK |
|
||||
| Select → Save | "save the selection" | `_saveNamedSelection` | ✔ | ✔ | ✔ | OK |
|
||||
| Select → Load | "load the selection as sky" | `_loadNamedSelection` | ✔ | ✔ | ✔ | OK |
|
||||
| Filter → every filter (30+) | "pixelate the image" | `filters.apply` | ✔ | ✔ | ✔ | OK (fast path covers pixelate/emboss/twirl/pinch/spherize/ripple/solarize/find-edges/crystallize/noise/despeckle/unsharp + blur/sharpen/vignette) |
|
||||
| Filter → Blur → Motion / Zoom | "motion blur" / "zoom blur" | `filters.apply` (blur-motion / blur-zoom) | ✔ | ✔ | ✔ | OK (fast path maps motion blur and zoom blur to their Filter-menu variants) |
|
||||
| Filter → Layer effects (drop shadow, stroke, colour overlay, outer glow) | "add a drop shadow" | `layers.addStyle` | ✔ | ✔ | ✔ | OK (fast path adds drop shadow / stroke / outer glow / colour & gradient overlay; colour parsed from the prompt) |
|
||||
| View → Rulers/Grid/Snap/Clear guides | "show the grid" | `#ge-view-menu` | ✔ | ✔ | ✔ | OK |
|
||||
| Window → panels | — | n/a | n/a | n/a | ✘ | UI-only |
|
||||
| 3D / Plugins | — | — | ✘ | ✘ | ✘ | Not implemented (disabled in UI) |
|
||||
|
||||
## 2. Tools
|
||||
|
||||
Toolbar tools are interactive, not bridge ops, so coverage is about whether the
|
||||
**effect** of a tool is reachable by prompt.
|
||||
|
||||
| Tool | NL-reachable? | Notes |
|
||||
|---|---|---|
|
||||
| Move / Transform | `transform.*` | OK (transform.start/apply/cancel) |
|
||||
| Hand / Zoom | — | View only |
|
||||
| Crop | `document.crop` + format guard | OK |
|
||||
| Text / Shape | `text.add`, `text.layout` | Shape has no add-shape op — **gap** |
|
||||
| Brush / Eraser / Dodge / Burn / Smudge / Clone / Heal / Sharpen | — | Interactive painting; no NL op — **gap** (out of scope for planner) |
|
||||
| Gradient | — | **No op** |
|
||||
| **Paint Bucket** | `fill.flood` | **Server gap + planner gap** (op exists client-side only) |
|
||||
| Eyedropper | — | UI only |
|
||||
| Marquee / Lasso / Wand / Quick Select / Object Select / Pen / Path | `selection.rectangle/ellipse/polygon/subject/…` | OK for region/reference selects; lasso/pen paths have `selection.polygon` only |
|
||||
| SAM / Inpaint / Rembg | `selection.subject`, `ai.removeObject`, `ai.removeBackground` | OK |
|
||||
| **Pixel brush + grid** | — | UI state only; no op — **gap** (acceptable: it is a brush mode, not an edit op) |
|
||||
|
||||
## 3. Right-click context menu
|
||||
|
||||
| Item | Backing op | Reachable by prompt? |
|
||||
|---|---|---|
|
||||
| Brush size / preset / Pixel Brush | `state` UI | No (interactive) |
|
||||
| Select All / Deselect / Inverse / Feather / Expand / Contract / Border / Smooth | `selection.*` | Yes |
|
||||
| Transform Selection / Refine Edge | `transform.start`, `selection.feather` | Partial |
|
||||
| Apply / Cancel Crop | `document.crop` | Yes |
|
||||
| Commit / Cancel / Free Transform | `transform.apply/cancel/start` | Yes |
|
||||
| Add Text Layer / Rasterize Text | `text.add`, `layers.rasterize` | Yes (rasterize OK) |
|
||||
| Copy Color Hex / Swap Colors | `state.color` | OK (fast path swaps / resets the foreground & background swatches) |
|
||||
| Fill Selection w/ Foreground | `selection.fill` | OK (fast path fills the selection/canvas with the foreground or background colour) |
|
||||
| Cut / Copy / Paste | `selection.copy/paste` | **Gap** (no planner intent) |
|
||||
| Rotate 90/180, Flip H/V | `layer.rotate/flip` | **Server+planner gap** (same as Edit menu) |
|
||||
| Undo / Redo | `history.*` | Yes |
|
||||
|
||||
## 4. Adjustments
|
||||
|
||||
`adjustments.apply` / `adjustments.applyMasked` cover all adjustment types
|
||||
(brightness-contrast, white-balance, shadows-highlights, vibrance, clarity,
|
||||
vignette, black-white, …). The style guide emits several. **Status: OK**
|
||||
(planner covers the common ones; the long tail is reachable only via explicit
|
||||
wording).
|
||||
|
||||
## 5. Filters
|
||||
|
||||
`filters.apply` / `filters.applyMasked` run every filter the UI lists. The
|
||||
planner only emits **sharpen**, **blur-gaussian**, and **vignette** via intents.
|
||||
**Gap (planner):** no intent for noise, pixelate, stylize, distort, render,
|
||||
unsharp, sharpen-edges, colour-overlay/drop-shadow/stroke.
|
||||
**Gap (HARNESS):** `filters.apply`/`applyMasked` are in the server catalog, so
|
||||
the fix is style-guide intents, not catalog.
|
||||
|
||||
## 6. Layer styles
|
||||
|
||||
`layers.addStyle {type, params}` exists in both catalogs. **Planner never emits
|
||||
it**, so "add a drop shadow to the title", "add a stroke", "colour overlay" fail.
|
||||
**Gap (planner).**
|
||||
|
||||
## 7. Selection ops
|
||||
|
||||
`selection.rectangle/ellipse/polygon/fill/delete/copy/paste/feather/expand/
|
||||
contract/selectAll/deselect/invert/colorRange/subject/samText/face/sky/
|
||||
background/hasMask/maskBounds/exportMask/regionLuminance`. **Status: mostly OK.**
|
||||
Missing: reselect, save-selection.
|
||||
|
||||
## 8. Document ops
|
||||
|
||||
| Capability | Op | C | S | P |
|
||||
|---|---|---|---|---|
|
||||
| Crop | `document.crop` | ✔ | ✔ | ✔ |
|
||||
| Resize canvas / image | `document.resizeCanvas` | ✔ | ✔ | partial |
|
||||
| **Extend canvas** | `document.extend` | ✔ | ✘ | ✘ |
|
||||
| Save PSD | `document.savePsd` | ✔ | ✔ | ✔ |
|
||||
| Export layers | `document.exportLayers` | ✔ | ✘ | ✘ |
|
||||
| Duplicate / Create / Switch / List | `document.*` | ✔ | ✘ | partial |
|
||||
|
||||
## 9. AI edit & generate modes
|
||||
|
||||
| Mode | Op | Notes |
|
||||
|---|---|---|
|
||||
| Generative fill | `ai.generativeFill` | OK |
|
||||
| Remove object | `ai.removeObject` | OK |
|
||||
| Harmonize | `ai.harmonize` | OK |
|
||||
| Upscale | `ai.generativeUpscale` | OK |
|
||||
| Generate image | `ai.generateImage` / server `generate_image` | OK |
|
||||
| Expand | `ai.generativeExpand` | OK (content-bounds prefill, SW v760) |
|
||||
| Remove background | `ai.removeBackground` | OK |
|
||||
| Instruction edit / scene replace | server `ai_edit_image` | OK (HARNESS) |
|
||||
|
||||
---
|
||||
|
||||
## 10. Gap list
|
||||
|
||||
### Client-side (fix in this repo — PHOTOSHOP-owned) — **ALL FIXED**
|
||||
1. ✅ **`fill.flood` not in the NL path** — added to `nl-edit.js OP_CATALOG` +
|
||||
`localPlan` fill intent ("fill … with <colour>"; falls back to `selection.fill`
|
||||
when a region is selected).
|
||||
2. ✅ **`layer.flip` / `layer.rotate` not in the NL path** — added to
|
||||
`nl-edit.js OP_CATALOG` + "this layer" intent (whole-image phrasing routes to
|
||||
`document.flip`/`document.rotate`).
|
||||
3. ✅ **`text.edit` not in the NL path** — added catalog entry + intent for font
|
||||
size/colour ("make the text bigger/smaller", "make it red").
|
||||
4. ✅ **`document.extend` not in the NL path** — added catalog entry + intent for
|
||||
"extend the canvas to 16:9" (derives target size from the document).
|
||||
5. ✅ **`layers.align`** — added planner intent ("align … to the centre/left/…").
|
||||
6. ✅ **`layers.addStyle`** — added planner trigger ("drop shadow", "stroke",
|
||||
"colour overlay").
|
||||
7. ✅ **Whole-image rotate/flip has no control op** — added `document.rotate` /
|
||||
`document.flip` control ops (`galleryEditor.js`) and catalog/intent entries.
|
||||
8. ✅ **`selection.copy/paste` intents** — added "copy the selection"/"paste".
|
||||
|
||||
### Server-side (file for HARNESS in the passover)
|
||||
1. `routes/editor_agent_routes.py OP_CATALOG` is missing ops the client runs:
|
||||
`layer.flip`, `layer.rotate`, `text.edit`, `fill.flood`, `layers.align`,
|
||||
`layers.rasterize`, `layers.mergeDown`, `layers.flatten`, `document.extend`,
|
||||
`document.exportLayers`, `document.*` (list/duplicate/create/switch/openPsd),
|
||||
`mask.*`, `selection.*` (polygon/feather/expand/contract/copy/paste/hasMask/
|
||||
face), `transform.*` are partly present.
|
||||
2. `src/editor_style_guide.py` has no intents for: layer transform
|
||||
(rotate/flip active layer), whole-document rotate/flip, text editing, align,
|
||||
layer styles (drop shadow/stroke/overlay), flood fill, copy/paste, noise/
|
||||
pixelate/stylize/distort/render filters.
|
||||
|
||||
## 11. Coverage summary
|
||||
|
||||
Counting distinct user-facing capabilities in the toolbar + 12 menus + right-click
|
||||
(~150 including the filter/effect long tail):
|
||||
|
||||
- **Have a client op:** ~85%
|
||||
- **Client op also in server OP_CATALOG:** ~75%
|
||||
- **Planner can emit for natural phrasing:** ~72% (was ~55%; client-side
|
||||
planner intents for fill, layer/whole-image transform, text edit, align,
|
||||
layer styles, copy/paste and extend are now implemented)
|
||||
- **Usable from Quick Edit end-to-end today:** **45/45 prompts (100%)** measured
|
||||
live on build **0.20.45** with `tests/e2e/photo-editor/quickedit-coverage.js`
|
||||
(`/tmp/opencode/qe-coverage45/quickedit-coverage.json`). HARNESS build 0.20.45
|
||||
(`4a6932a2`) added the 12 missing structural ops to the server `OP_CATALOG` +
|
||||
style guide v5, so every audited prompt now plans to the exact op.
|
||||
|
||||
The gap was dominated by the **planner**, not by missing client functionality;
|
||||
that planner gap is now closed end-to-end (30/45 on 0.20.44 -> 45/45 on 0.20.45).
|
||||
|
||||
## 12. Test prompts (~40)
|
||||
|
||||
See `docs/QUICKEDIT_PROMPTS.md` and the runner
|
||||
`tests/e2e/photo-editor/quickedit-coverage.js` for per-prompt pass/fail.
|
||||
|
||||
### Live run (build 0.20.45) — 45/45 PASS
|
||||
|
||||
All 45 prompts plan to the exact expected op. HARNESS 0.20.45 routes these
|
||||
structural ops server-side: `fill.flood`, `text.edit`, `layers.align`,
|
||||
`layer.rotate`/`layer.flip`, `document.rotate`/`document.flip`,
|
||||
`selection.copy`/`selection.paste`, `layers.flatten`, `layers.mergeDown` (plus
|
||||
the previously passing set). Results:
|
||||
`/tmp/opencode/qe-coverage45/quickedit-coverage.json`.
|
||||
|
||||
### Previous run (build 0.20.44) — 30/45 PASS
|
||||
|
||||
Kept for provenance. Failures were all the server planner emitting a generic
|
||||
`adjustments.apply`/`transform.apply` or a wrong op; ids 1, 2, 3, 4, 8, 9, 10,
|
||||
11, 12, 13, 14, 19, 20, 40, 41. All now fixed in 0.20.45.
|
||||
|
||||
|
||||
## Provenance
|
||||
|
||||
Recon inventory captured from `static/js/editor/build/topbar.js`,
|
||||
`static/js/editor/build/toolbar.js`, `static/js/editor/canvas-context-menu.js`,
|
||||
`static/js/galleryEditor.js` (`installEditorControl`), `static/js/editor/nl-edit.js`,
|
||||
`routes/editor_agent_routes.py`, and `src/editor_style_guide.py`.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@@ -112,7 +112,7 @@ export function createCardInteractions({
|
||||
if (state.selectMode) {
|
||||
body.querySelectorAll('.note-card').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.note-card-cb')) return; // checkbox handles itself
|
||||
if (e.target.closest('.note-card-cb, a')) return; // links open without toggling selection
|
||||
e.stopPropagation();
|
||||
tapToEditOrSelect(card);
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ export function createEditor({
|
||||
<input type="hidden" class="note-form-due" value="${note?.due_date || ''}" />
|
||||
<input type="hidden" class="note-form-repeat" value="${note?.repeat || 'none'}" />
|
||||
</div>
|
||||
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" draggable="false" /><button class="note-form-image-rm" title="Remove">×</button></div>` : ''}
|
||||
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">×</button></div>` : ''}
|
||||
<div class="note-form-body">
|
||||
${type === 'note'
|
||||
? `<textarea class="note-form-content" placeholder="Take a note..." rows="4">${_esc(note?.content || '')}</textarea>`
|
||||
@@ -297,7 +297,7 @@ export function createEditor({
|
||||
form.querySelector('.note-form-image-wrap')?.remove();
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'note-form-image-wrap';
|
||||
wrap.innerHTML = `<img class="note-form-image" draggable="false" /><button class="note-form-image-rm" title="Remove">×</button>`;
|
||||
wrap.innerHTML = `<img class="note-form-image" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">×</button>`;
|
||||
// Insert AFTER the whole header (a flex-row), not after the
|
||||
// title input itself : otherwise the image lands as a sibling
|
||||
// of the title inside the header and flex puts them side-by-side.
|
||||
|
||||
@@ -31,7 +31,7 @@ export function createText({
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
const href = url.startsWith('www.') ? `https://${url}` : url;
|
||||
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
|
||||
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer">${url}</a>` + (url !== m ? m.slice(url.length) : '');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1185,6 +1185,9 @@ def _fixture_email_record(row: dict, uid_num: int, owner: str) -> dict:
|
||||
"subject": subject,
|
||||
"from": sender_name or sender_addr or sender,
|
||||
"from_address": sender_addr,
|
||||
"to": str(row.get("to") or headers.get("To") or ""),
|
||||
"cc": str(row.get("cc") or headers.get("Cc") or ""),
|
||||
"references": str(row.get("references") or headers.get("References") or ""),
|
||||
"date": date_str,
|
||||
"date_epoch": date_epoch,
|
||||
"summary": body[:240],
|
||||
@@ -1557,6 +1560,7 @@ def _fixture_update_email(uid=None, source_folder="INBOX", account=None, **updat
|
||||
if not isinstance(rows, list):
|
||||
return False
|
||||
owner = _current_owner()
|
||||
matches = []
|
||||
for index, row in enumerate(rows, start=1):
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
@@ -1568,6 +1572,12 @@ def _fixture_update_email(uid=None, source_folder="INBOX", account=None, **updat
|
||||
continue
|
||||
if not _fixture_folder_matches(row.get("folder") or "INBOX", source_folder):
|
||||
continue
|
||||
if not _fixture_row_matches_account(_fixture_email_record(row, index, owner or row_owner), account):
|
||||
continue
|
||||
matches.append(row)
|
||||
if len(matches) != 1:
|
||||
return False
|
||||
row = matches[0]
|
||||
for key, value in updates.items():
|
||||
if value is None:
|
||||
row.pop(key, None)
|
||||
@@ -1575,7 +1585,6 @@ def _fixture_update_email(uid=None, source_folder="INBOX", account=None, **updat
|
||||
row[key] = value
|
||||
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ── Tool implementations ──
|
||||
@@ -2925,7 +2934,9 @@ def _create_email_draft_document(
|
||||
|
||||
def _draft_reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None, title=None):
|
||||
"""Create a threaded Odysseus reply draft document. Does not send."""
|
||||
fixture = _fixture_email_action_target(uid=uid, folder=folder, account=account)
|
||||
fixture = _fixture_read_email(uid=uid, folder=folder, account=account)
|
||||
if _fixture_email_enabled() and (fixture is None or fixture.get("error")):
|
||||
return fixture or {"error": "No email fixture is available for the authenticated owner"}
|
||||
if fixture is not None:
|
||||
sender = str(fixture.get("from_address") or fixture.get("from") or "")
|
||||
_, sender_addr = email.utils.parseaddr(sender)
|
||||
@@ -3178,7 +3189,9 @@ def _reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None):
|
||||
|
||||
def _set_flag(uid, folder, flag, add=True, account=None):
|
||||
"""Add or remove an IMAP flag (e.g. \\Seen, \\Answered, \\Deleted)."""
|
||||
if _fixture_email_action_target(uid=uid, folder=folder, account=account) is not None:
|
||||
if _fixture_email_enabled():
|
||||
if _fixture_email_action_target(uid=uid, folder=folder, account=account) is None:
|
||||
return False
|
||||
if flag == "\\Seen":
|
||||
return _fixture_update_email(uid=uid, source_folder=folder, account=account, read=bool(add))
|
||||
if flag == "\\Answered":
|
||||
|
||||
@@ -33,7 +33,7 @@ async def list_tools() -> list[Tool]:
|
||||
"prompt": {"type": "string", "description": "Image description prompt"},
|
||||
"model": {"type": "string", "description": "Model name (auto-detects if omitted)"},
|
||||
"size": {"type": "string", "description": "Image size (default 1024x1024)"},
|
||||
"quality": {"type": "string", "description": "Quality: low, medium, high, auto (default medium)"},
|
||||
"quality": {"type": "string", "description": "Quality: low, medium, high, auto (default configured quality, otherwise medium)"},
|
||||
},
|
||||
"required": ["prompt"],
|
||||
},
|
||||
@@ -66,7 +66,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
|
||||
if not model_spec:
|
||||
model_spec = _settings.get("image_model", "")
|
||||
if quality == "medium" and _settings.get("image_quality"):
|
||||
if "quality" not in arguments and _settings.get("image_quality"):
|
||||
quality = _settings["image_quality"]
|
||||
|
||||
# Auto-detect best available image model
|
||||
|
||||
+5
-1
@@ -9,7 +9,11 @@
|
||||
"test:photo-editor": "playwright test --config tests/e2e/playwright.config.js",
|
||||
"test:photo-editor:install": "playwright install chromium firefox webkit",
|
||||
"test:photo-editor:firefox": "PHOTO_EDITOR_E2E_BROWSER=firefox playwright test --config tests/e2e/playwright.config.js",
|
||||
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js"
|
||||
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js",
|
||||
"test:ios-layout": "playwright test --config tests/e2e/ios-layout.config.cjs",
|
||||
"test:ios-layout:chromium": "ODYSSEUS_LAYOUT_BROWSER=chromium playwright test --config tests/e2e/ios-layout.config.cjs",
|
||||
"test:ios-layout:webkit": "ODYSSEUS_LAYOUT_BROWSER=webkit playwright test --config tests/e2e/ios-layout.config.cjs",
|
||||
"test:ios-layout:install": "playwright install chromium webkit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antithesishq/bombadil": "^0.7.0",
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
-r requirements.txt
|
||||
# psutil lets `-n auto` use physical cores instead of logical CPU threads.
|
||||
pytest-xdist[psutil]>=3.8,<4
|
||||
playwright==1.62.0
|
||||
|
||||
@@ -887,6 +887,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
"""Create a new integration (admin only)."""
|
||||
_require_admin_user(request)
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
item = add_integration(body)
|
||||
return {"ok": True, "integration": mask_integration_secret(item)}
|
||||
|
||||
@@ -895,6 +897,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
"""Update an existing integration (admin only)."""
|
||||
_require_admin_user(request)
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
item = update_integration(integration_id, body)
|
||||
if not item:
|
||||
raise HTTPException(404, "Integration not found")
|
||||
|
||||
+16
-16
@@ -80,6 +80,18 @@ def _require_user(request: Request) -> str:
|
||||
return FALLBACK_OWNER
|
||||
|
||||
|
||||
async def _json_object_body(request: Request) -> dict:
|
||||
"""Parse a JSON object body. An unreadable body counts as empty; a body
|
||||
that is valid JSON but not an object (a list, string or number) is a 400."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return {}
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
return body
|
||||
|
||||
|
||||
def _get_or_404_calendar(db, cal_id: str, owner: str) -> CalendarCal:
|
||||
cal = db.query(CalendarCal).filter(CalendarCal.id == cal_id).first()
|
||||
if not cal:
|
||||
@@ -994,10 +1006,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
||||
async def save_config(request: Request):
|
||||
"""Legacy single-account endpoint — upserts the first account."""
|
||||
owner = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
body = await _json_object_body(request)
|
||||
accounts = _get_caldav_accounts(owner)
|
||||
if not (body.get("url") or "").strip():
|
||||
_save_caldav_accounts(owner, [])
|
||||
@@ -1052,10 +1061,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
||||
"""Add a new CalDAV account."""
|
||||
import uuid as _uuid
|
||||
owner = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
body = await _json_object_body(request)
|
||||
from src.caldav_sync import validate_caldav_url
|
||||
try:
|
||||
url = validate_caldav_url(body.get("url", ""))
|
||||
@@ -1080,10 +1086,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
||||
async def update_caldav_account(account_id: str, request: Request):
|
||||
"""Update an existing CalDAV account by id."""
|
||||
owner = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
body = await _json_object_body(request)
|
||||
accounts = _get_caldav_accounts(owner)
|
||||
idx = next((i for i, a in enumerate(accounts) if a.get("id") == account_id), None)
|
||||
if idx is None:
|
||||
@@ -1124,10 +1127,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
||||
test an already-saved account. Falls back to the first saved account
|
||||
when nothing is provided."""
|
||||
owner = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
body = await _json_object_body(request)
|
||||
url = (body.get("url") or "").strip()
|
||||
user = (body.get("username") or "").strip()
|
||||
pw = body.get("password") or ""
|
||||
|
||||
@@ -569,8 +569,72 @@ async def auto_name_session(session_manager, sess):
|
||||
if not first_msg:
|
||||
return
|
||||
|
||||
from src.owner_identity import is_request_sentinel_owner
|
||||
owner = getattr(sess, "owner", None)
|
||||
invalid_owner = (
|
||||
not isinstance(owner, str) or not owner.strip()
|
||||
or owner != owner.strip() or is_request_sentinel_owner(owner)
|
||||
)
|
||||
if invalid_owner:
|
||||
session_manager.update_session_name(sess.id, fallback_session_title(first_msg))
|
||||
return
|
||||
|
||||
endpoint_url = str(getattr(sess, "endpoint_url", "") or "")
|
||||
model_name = str(getattr(sess, "model", "") or "")
|
||||
|
||||
# The spec ("Ajax") model names its own chats from the user's first
|
||||
# prompt. This function only runs after the first response stream has
|
||||
# settled (auto_name_session_after_stream), so titling does not
|
||||
# contend with the visible answer. Thinking is disabled and the
|
||||
# budget kept small so the model returns a bare title.
|
||||
try:
|
||||
from src.model_profiles import uses_odysseus_progressive_thinking
|
||||
_is_spec_model = bool(model_name) and uses_odysseus_progressive_thinking(model_name)
|
||||
except Exception:
|
||||
_is_spec_model = False
|
||||
if _is_spec_model and endpoint_url:
|
||||
try:
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import (build_chat_url, build_headers,
|
||||
resolve_endpoint_runtime, resolve_owner_registered_endpoint)
|
||||
from src.owner_identity import auth_disabled
|
||||
|
||||
owner = getattr(sess, "owner", None)
|
||||
if not owner and not auth_disabled():
|
||||
raise ValueError("Session has no owner")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
endpoint = resolve_owner_registered_endpoint(db, endpoint_url, owner)
|
||||
base, key = resolve_endpoint_runtime(endpoint, owner=owner)
|
||||
title_url = build_chat_url(base)
|
||||
title_headers = build_headers(key, base)
|
||||
finally:
|
||||
db.close()
|
||||
title = await llm_call_async(
|
||||
title_url,
|
||||
model_name,
|
||||
[
|
||||
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation \u2014 just the title."},
|
||||
{"role": "user", "content": first_msg},
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=32,
|
||||
headers=title_headers,
|
||||
timeout=20,
|
||||
thinking_mode="off",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[auto-name] spec title call failed for %s: %s", sess.id, e)
|
||||
title = ""
|
||||
title = str(title or "").strip().strip('"\'').strip()
|
||||
from src.text_helpers import strip_think
|
||||
title = strip_think(title, prose=False, prompt_echo=False)
|
||||
if not title or len(title) >= 80 or "\n" in title:
|
||||
title = fallback_session_title(first_msg)
|
||||
session_manager.update_session_name(sess.id, title)
|
||||
logger.info(f"Auto-named spec session {sess.id}: {title}")
|
||||
return
|
||||
|
||||
if (
|
||||
"ttft" in model_name.lower()
|
||||
or re.search(r":18\d{3}\b", endpoint_url)
|
||||
@@ -581,6 +645,14 @@ async def auto_name_session(session_manager, sess):
|
||||
return
|
||||
|
||||
owner = getattr(sess, "owner", None)
|
||||
from src.owner_identity import auth_disabled
|
||||
if not owner and not auth_disabled():
|
||||
title = fallback_session_title(first_msg)
|
||||
session_manager.update_session_name(sess.id, title)
|
||||
logger.info(f"Auto-named unowned session {sess.id} deterministically: {title}")
|
||||
return
|
||||
|
||||
from src.task_endpoint import resolve_task_endpoint
|
||||
t_url, t_model, t_headers = resolve_task_endpoint(
|
||||
sess.endpoint_url, sess.model, sess.headers, owner=owner
|
||||
)
|
||||
@@ -588,11 +660,48 @@ async def auto_name_session(session_manager, sess):
|
||||
logger.debug("[auto-name] No model provided, skipping")
|
||||
return
|
||||
|
||||
sess_url = getattr(sess, "endpoint_url", None)
|
||||
if t_url and sess_url and t_url == sess_url:
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import (
|
||||
build_chat_url,
|
||||
build_headers,
|
||||
resolve_endpoint,
|
||||
resolve_endpoint_runtime,
|
||||
resolve_owner_registered_endpoint,
|
||||
)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
endpoint = resolve_owner_registered_endpoint(db, t_url, owner)
|
||||
base, key = resolve_endpoint_runtime(endpoint, owner=owner)
|
||||
t_url = build_chat_url(base)
|
||||
t_headers = build_headers(key, base)
|
||||
except Exception as e:
|
||||
logger.debug("[auto-name] session fallback endpoint %s unauthorized for %s: %s", t_url, owner, e)
|
||||
t_url, t_model, t_headers = None, None, None
|
||||
for prefix in ("utility", "default"):
|
||||
try:
|
||||
u, m, h = resolve_endpoint(prefix, owner=owner)
|
||||
if u and m:
|
||||
t_url, t_model, t_headers = u, m, h
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if not t_url or not t_model:
|
||||
title = fallback_session_title(first_msg)
|
||||
session_manager.update_session_name(sess.id, title)
|
||||
logger.info(f"Auto-named session {sess.id} deterministically (no authorized model endpoint): {title}")
|
||||
return
|
||||
|
||||
# max_tokens big enough that reasoning models (Minimax M2,
|
||||
# DeepSeek R1, QwQ, etc.) have headroom for <think>…</think>
|
||||
# plus the actual title — 200 used to clip them mid-reasoning
|
||||
# so strip_think left an empty string and no rename happened.
|
||||
# Timeout matches: 60s gives slow local reasoners room to finish.
|
||||
try:
|
||||
title = await llm_call_async(
|
||||
t_url,
|
||||
t_model,
|
||||
@@ -606,6 +715,9 @@ async def auto_name_session(session_manager, sess):
|
||||
timeout=15,
|
||||
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)
|
||||
title = ""
|
||||
|
||||
title = title.strip().strip('"\'').strip()
|
||||
# Strip <think>/<thinking> blocks (closed, dangling, or stray tags)
|
||||
|
||||
+119
-12
@@ -27,11 +27,13 @@ from src.llm_core import (
|
||||
from src.agent_loop import (
|
||||
stream_agent_loop,
|
||||
_configured_model_tool_surface,
|
||||
_configured_model_tool_surface_raw,
|
||||
_local_media_needs_browser_render,
|
||||
_looks_like_workspace_coding_request,
|
||||
)
|
||||
from src.agent_loop import _normalize_ody_qwen_text_artifacts
|
||||
from src import agent_runs
|
||||
from src import shutdown as _shutdown
|
||||
from src.model_context import estimate_tokens
|
||||
from src.context_compactor import (
|
||||
apply_compaction_state,
|
||||
@@ -329,6 +331,7 @@ def _is_external_discovery_request(text: str) -> bool:
|
||||
|
||||
_URL_SCHEME_RE = re.compile(r"https?://", re.I)
|
||||
_PDF_URL_TAIL_RE = re.compile(r"\.pdf\b|/pdf/", re.I)
|
||||
_EDITOR_SESSION_RE = re.compile(r"[A-Za-z0-9@._:-]{1,128}")
|
||||
|
||||
|
||||
def _mentions_pdf_url(value: str) -> bool:
|
||||
@@ -1898,7 +1901,7 @@ def _ts_or_zero(value) -> float:
|
||||
|
||||
|
||||
def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool:
|
||||
"""Re-populate sess.model from the matching endpoint's cached models.
|
||||
"""Recover sess.model from the endpoint's visible cached and pinned models.
|
||||
|
||||
Covers the window between endpoint setup and the first chat send: the
|
||||
picker showed a model in the dropdown but the session record never got
|
||||
@@ -1962,6 +1965,9 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
|
||||
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
|
||||
except Exception:
|
||||
visible = cached
|
||||
if not is_chatgpt_subscription:
|
||||
from src.endpoint_resolver import _endpoint_enabled_models
|
||||
visible = _endpoint_enabled_models(ep)
|
||||
if current_model and current_model in {str(item).strip() for item in visible}:
|
||||
return False
|
||||
if is_chatgpt_subscription:
|
||||
@@ -2688,6 +2694,23 @@ def setup_chat_routes(
|
||||
logger.info("chat→agent auto-escalation: explicit private browser workflow")
|
||||
active_doc_id = form_data.get("active_doc_id", "").strip()
|
||||
active_doc_state = form_data.get("active_doc_state", "").strip().casefold()
|
||||
# Scoped photo_editor agent context: the Quick Edit popup (and any
|
||||
# image-edit chat turn) may carry the open image-editor session id and a
|
||||
# canvas snapshot so the editor tool family can drive it in-process.
|
||||
# The id is quoted into a system message, so accept only an
|
||||
# identifier; any other value is treated as no open editor.
|
||||
editor_session = str(
|
||||
form_data.get("editor_session")
|
||||
or form_data.get("editor_session_id")
|
||||
or ""
|
||||
).strip()
|
||||
if not _EDITOR_SESSION_RE.fullmatch(editor_session):
|
||||
editor_session = ""
|
||||
photo_editor_requested = (
|
||||
str(form_data.get("photo_editor") or "").strip().lower()
|
||||
in {"1", "true", "yes", "on"}
|
||||
or bool(editor_session)
|
||||
)
|
||||
logger.info(
|
||||
"[doc-inject] chat_mode=%s, active_doc_id=%r, active_doc_state=%r",
|
||||
chat_mode, active_doc_id, active_doc_state,
|
||||
@@ -3270,7 +3293,34 @@ def setup_chat_routes(
|
||||
active_document=bool(active_doc), workspace=bool(workspace),
|
||||
image_attachment=any(str(a.get('mime') or '').startswith('image/') for a in (ctx.preprocessed.attachment_meta or [])),
|
||||
) if _use_turn_contract else frozenset()
|
||||
if 'image_editing' in _turn_capabilities and ctx.preprocessed.attachment_meta:
|
||||
# Scoped photo_editor family: offered ONLY for an explicit editor
|
||||
# request (Quick Edit popup / editor session) or an image-edit chat
|
||||
# turn carrying an image. When active the turn offers just the editor
|
||||
# tools plus ask_user, never the general tool list.
|
||||
_photo_editor_active = bool(_use_turn_contract) and (
|
||||
photo_editor_requested
|
||||
or (
|
||||
"image_editing" in _turn_capabilities
|
||||
and bool(ctx.preprocessed.attachment_meta)
|
||||
)
|
||||
)
|
||||
if _photo_editor_active:
|
||||
if editor_session:
|
||||
editor_context = {'role': 'system', 'content':
|
||||
'The image editor is open (session ' + editor_session + '). Use run_editor_command, '
|
||||
'plan_edit and get_editor_screenshot to inspect and change the current canvas, and '
|
||||
'ai_edit_image for model-based edits. Apply the user\'s requested edit to the open '
|
||||
'editor and confirm the result with a screenshot.'}
|
||||
else:
|
||||
editor_context = {'role': 'system', 'content':
|
||||
'The user attached an image but no image editor is open, so the editor tools cannot '
|
||||
'reach the canvas. Do not call editor tools. Reply briefly telling the user to open '
|
||||
'the attached image in the editor (Quick Edit) and repeat the request; the editor '
|
||||
'tools will then apply it.'}
|
||||
ctx.messages.insert(0, editor_context)
|
||||
if foreground_policy.enabled:
|
||||
getattr(ctx, 'route_messages', ctx.messages).insert(0, dict(editor_context))
|
||||
elif 'image_editing' in _turn_capabilities and ctx.preprocessed.attachment_meta:
|
||||
image_refs = ['odysseus://attachment/' + str(a['id']) for a in ctx.preprocessed.attachment_meta
|
||||
if a.get('id') and str(a.get('mime') or '').startswith('image/')]
|
||||
if image_refs:
|
||||
@@ -3307,6 +3357,11 @@ def setup_chat_routes(
|
||||
# pasted revision request. This only offers permitted schemas;
|
||||
# it never requires or performs a document mutation.
|
||||
_turn_capabilities = _turn_capabilities | {"documents"}
|
||||
# Scoped photo_editor family (see above): when active the turn offers
|
||||
# just the editor tools plus ask_user, never the general tool list.
|
||||
if _photo_editor_active:
|
||||
_turn_capabilities = frozenset({"photo_editor"})
|
||||
_active_turn_capabilities = _turn_capabilities
|
||||
# Same decision that prepared the turn; it only stamps the contract
|
||||
# inside the agent-contract branch below.
|
||||
_clean_v3_preview = _compact_preview_turn
|
||||
@@ -3474,7 +3529,7 @@ def setup_chat_routes(
|
||||
if not _privs.get("can_use_documents", True):
|
||||
disabled_tools.update({"manage_documents", "create_document", "edit_document", "update_document", "suggest_document"})
|
||||
if not _privs.get("can_generate_images", True):
|
||||
disabled_tools.update({"generate_image", "edit_image"})
|
||||
disabled_tools.update({"generate_image", "edit_image", "ai_edit_image"})
|
||||
if not _privs.get("can_manage_memory", True):
|
||||
disabled_tools.update({"manage_memory", "manage_skills"})
|
||||
if not _privs.get("can_use_research", True):
|
||||
@@ -3556,9 +3611,22 @@ def setup_chat_routes(
|
||||
"document_editor": not {
|
||||
"manage_documents", "create_document", "edit_document", "update_document",
|
||||
}.issubset(disabled_tools),
|
||||
# A photo-editing chat turn can only reach a canvas when the image
|
||||
# editor is actually open. The compact runtime uses this to reply
|
||||
# (open the image in the editor) instead of looping on editor tools
|
||||
# that have no canvas to drive.
|
||||
"photo_editor_open": bool(editor_session),
|
||||
}
|
||||
# Capture permission state before schema selection/reconciliation.
|
||||
# Only deterministic request intent supplies grants, never inventory.
|
||||
# The strict router (spec) surface picks its tools in src/spec_router.py
|
||||
# AFTER authority is built, so its admission is keyed off the surface.
|
||||
try:
|
||||
_spec_surface = _configured_model_tool_surface_raw(
|
||||
getattr(sess, "endpoint_url", ""), getattr(sess, "model", ""), _user
|
||||
) == "spec"
|
||||
except Exception:
|
||||
_spec_surface = False
|
||||
_request_authority = request_authority_for_http(
|
||||
request, message, owner=_user, session_id=session, workspace=workspace,
|
||||
history=_turn_history, policy=tool_policy,
|
||||
@@ -3566,9 +3634,9 @@ def setup_chat_routes(
|
||||
active_document=bool(active_doc),
|
||||
image_attachment=any(str(a.get('mime') or '').startswith('image/')
|
||||
for a in (ctx.preprocessed.attachment_meta or [])),
|
||||
capabilities=({'search_browser'} if (
|
||||
capabilities=(({"photo_editor"} if _photo_editor_active else set()) | ({'search_browser'} if (
|
||||
_explicit_browser_intent or _external_discovery_intent
|
||||
) else ()),
|
||||
) else set())),
|
||||
)
|
||||
if exact_tool_approval is not None:
|
||||
from src.agent_runtime.authority import RequestAuthority
|
||||
@@ -3758,7 +3826,9 @@ def setup_chat_routes(
|
||||
# personal manager; core shell/Web tools are not task
|
||||
# fallbacks. Other turns retain warm and workspace tools.
|
||||
extra_tools=(
|
||||
frozenset()
|
||||
frozenset({"ask_user"})
|
||||
if _active_turn_capabilities == frozenset({"photo_editor"})
|
||||
else frozenset()
|
||||
if _exact_selected_native_chain or _active_turn_capabilities == frozenset({"tasks"}) or (
|
||||
_active_turn_capabilities in (
|
||||
frozenset({"transcription"}), frozenset({"ocr"}),
|
||||
@@ -3856,6 +3926,19 @@ def setup_chat_routes(
|
||||
if ctx.preprocessed.attachment_meta:
|
||||
yield f"data: {json.dumps({'type': 'attachments', 'data': ctx.preprocessed.attachment_meta})}\n\n"
|
||||
|
||||
# Give the page the saved user message's id. Only the reply's id
|
||||
# was sent back, so a message sent in the open page had none and
|
||||
# a later regenerate/edit of it could not cut the chat by id.
|
||||
try:
|
||||
_last_user_msg = next((m for m in reversed(getattr(sess, "history", None) or [])
|
||||
if getattr(m, "role", None) == "user"), None)
|
||||
_user_db_id = ((getattr(_last_user_msg, "metadata", None) or {}).get("_db_id")
|
||||
if _last_user_msg is not None else None)
|
||||
if _user_db_id:
|
||||
yield f"data: {json.dumps({'type': 'user_message_saved', 'id': _user_db_id})}\n\n"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Announce any docs auto-created during preprocess (e.g. fillable
|
||||
# PDF → editable markdown) so the editor pane switches to them
|
||||
# before the model starts streaming.
|
||||
@@ -3960,7 +4043,7 @@ def setup_chat_routes(
|
||||
_heartbeat_counter = 0
|
||||
_last_progress = {}
|
||||
_sent_avg = False
|
||||
while True:
|
||||
while not _shutdown.is_shutting_down():
|
||||
status = research_handler.get_status(session)
|
||||
if not status or status["status"] != "running":
|
||||
break
|
||||
@@ -4222,8 +4305,6 @@ def setup_chat_routes(
|
||||
# indicator, but don't fold them into the saved
|
||||
# reply (mirrors the rewrite path below).
|
||||
if data.get("thinking"):
|
||||
if thinking_mode == "off":
|
||||
continue
|
||||
thinking_response += data["delta"]
|
||||
else:
|
||||
full_response += data["delta"]
|
||||
@@ -4667,8 +4748,6 @@ def setup_chat_routes(
|
||||
# Forward them for the live indicator, but keep
|
||||
# them out of the saved reply (same as chat mode).
|
||||
if data.get("thinking"):
|
||||
if thinking_mode == "off":
|
||||
continue
|
||||
thinking_response += data["delta"]
|
||||
else:
|
||||
full_response = _render_state.content
|
||||
@@ -4852,6 +4931,25 @@ def setup_chat_routes(
|
||||
_terminal_saved = True
|
||||
if _saved_id:
|
||||
yield f'data: {json.dumps(_render_state.message_saved(_saved_id))}\n\n'
|
||||
run_post_response_tasks(
|
||||
sess, session_manager, session, message, _saveable_no_tool_response,
|
||||
_metrics_to_save, ctx.uprefs, memory_manager, memory_vector, webhook_manager,
|
||||
incognito=incognito, compare_mode=compare_mode,
|
||||
character_name=ctx.preset.character_name,
|
||||
agent_rounds=_agent_rounds,
|
||||
agent_tool_calls=_agent_tool_calls,
|
||||
skills_manager=skills_manager,
|
||||
owner=_user,
|
||||
extract_skills=(
|
||||
user_requested_agent
|
||||
and not tool_approval_continuation
|
||||
),
|
||||
allow_background_extraction=_post_response_extraction_allowed(
|
||||
tools_blocked=tool_policy.block_all_tool_calls,
|
||||
tool_approval_continuation=tool_approval_continuation,
|
||||
client_runtime_context=client_runtime_context,
|
||||
),
|
||||
)
|
||||
yield f'data: {json.dumps(_metrics_event)}\n\n'
|
||||
except json.JSONDecodeError:
|
||||
yield chunk
|
||||
@@ -4962,6 +5060,8 @@ def setup_chat_routes(
|
||||
raises before reaching a mode-specific finally block."""
|
||||
try:
|
||||
async for chunk in stream_with_save():
|
||||
if _shutdown.is_shutting_down():
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
_active_streams.pop(session, None)
|
||||
@@ -5056,7 +5156,12 @@ def setup_chat_routes(
|
||||
if rec is None:
|
||||
if agent_runs.is_active(session_id):
|
||||
return {"status": "streaming", "detached": True}
|
||||
raise HTTPException(404, "No active stream for this session")
|
||||
# No active stream: report an explicit idle state with HTTP 200.
|
||||
# A 404 here made every session load look like a failed request in
|
||||
# the browser console. The client distinguishes "no stream" by the
|
||||
# body (status != "streaming"), and still tolerates a 404 from an
|
||||
# older server, so this stays backward compatible.
|
||||
return {"status": "idle"}
|
||||
return rec
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -5155,6 +5260,8 @@ def setup_chat_routes(
|
||||
max_tokens=0,
|
||||
tools=None,
|
||||
):
|
||||
if _shutdown.is_shutting_down():
|
||||
break
|
||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||
try:
|
||||
data = json.loads(chunk[6:])
|
||||
|
||||
+179
-1
@@ -14,6 +14,7 @@ from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from routes._validators import validate_remote_host, validate_ssh_port
|
||||
from routes.cookbook_output import HF_PARTIAL_BLOB_HELPER
|
||||
from core.platform_compat import _ssh_exec_argv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -378,6 +379,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
||||
"""
|
||||
lines = [
|
||||
"import json, os, re, shutil, subprocess, urllib.request",
|
||||
*HF_PARTIAL_BLOB_HELPER.splitlines(),
|
||||
"models = []",
|
||||
"seen = set()",
|
||||
"BLOCKED_ROOTS = ('/sys', '/proc', '/dev', '/run', '/var/run')",
|
||||
@@ -436,8 +438,10 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
||||
" sz, nf, ic = 0, 0, False",
|
||||
" if os.path.isdir(blobs):",
|
||||
" for f in os.scandir(blobs):",
|
||||
" if f.name.endswith('.incomplete'):",
|
||||
" if not is_partial_blob(f.path): continue",
|
||||
" ic = True",
|
||||
" if f.is_file(): nf += 1; sz += f.stat().st_size",
|
||||
" if f.name.endswith('.incomplete'): ic = True",
|
||||
" snap = os.path.join(cache, d, 'snapshots')",
|
||||
" def snapshot_size():",
|
||||
" total, count, incomplete = 0, 0, False",
|
||||
@@ -1030,6 +1034,166 @@ def _append_llama_cpp_linux_accel_build_lines(runner_lines: list[str]) -> None:
|
||||
runner_lines.append(' fi # end _odysseus_have_prebuilt guard')
|
||||
|
||||
|
||||
ODYSSEUS_LLAMA_SHIM_MARKER = "# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike"
|
||||
|
||||
|
||||
def _append_llama_server_resolution_definition_lines(runner_lines: list[str]) -> None:
|
||||
"""Define the helper shell function that discovers, reconciles and prioritizes llama-server."""
|
||||
marker = f"'{_bash_squote(ODYSSEUS_LLAMA_SHIM_MARKER)}'"
|
||||
runner_lines.append('_odysseus_resolve_llama_server() {')
|
||||
runner_lines.append(' _odysseus_llama_bin=""')
|
||||
runner_lines.append(f' local _marker={marker}')
|
||||
runner_lines.append(' local _saved_ifs="$IFS"')
|
||||
runner_lines.append(" IFS=':'")
|
||||
runner_lines.append(' local -a _parts')
|
||||
runner_lines.append(' read -r -a _parts <<< "$PATH"')
|
||||
runner_lines.append(' IFS="$_saved_ifs"')
|
||||
runner_lines.append(' local _dir _cand')
|
||||
runner_lines.append(' for _dir in "${_parts[@]}"; do')
|
||||
runner_lines.append(' [ -n "$_dir" ] || continue')
|
||||
runner_lines.append(' _cand="$_dir/llama-server"')
|
||||
runner_lines.append(' [ -x "$_cand" ] || continue')
|
||||
runner_lines.append(' if [ -f "$_cand" ] && [ ! -L "$_cand" ] && grep -Fxq "$_marker" "$_cand" 2>/dev/null; then')
|
||||
runner_lines.append(' continue')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' if "$_cand" --version >/dev/null 2>&1; then')
|
||||
runner_lines.append(' _odysseus_llama_bin="$_cand"')
|
||||
runner_lines.append(' break')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' done')
|
||||
runner_lines.append(' if [ -f "$HOME/bin/llama-server" ] && [ ! -L "$HOME/bin/llama-server" ] && grep -Fxq "$_marker" "$HOME/bin/llama-server" 2>/dev/null; then')
|
||||
runner_lines.append(' if [ -n "$_odysseus_llama_bin" ]; then')
|
||||
runner_lines.append(' echo "[odysseus] Native llama-server detected — retiring Odysseus compatibility shim in ~/bin"')
|
||||
runner_lines.append(' rm -f "$HOME/bin/llama-server"')
|
||||
runner_lines.append(' hash -r')
|
||||
runner_lines.append(' elif python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||
runner_lines.append(' _odysseus_llama_bin="$HOME/bin/llama-server"')
|
||||
runner_lines.append(' else')
|
||||
runner_lines.append(' echo "[odysseus] Removing stale Odysseus llama-server shim (python3 -m llama_cpp.server unavailable)"')
|
||||
runner_lines.append(' rm -f "$HOME/bin/llama-server"')
|
||||
runner_lines.append(' hash -r')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' if [ -n "$_odysseus_llama_bin" ]; then')
|
||||
runner_lines.append(' local _cand_dir')
|
||||
runner_lines.append(' _cand_dir="$(dirname "$_odysseus_llama_bin")"')
|
||||
runner_lines.append(' if [ "${PATH%%:*}" != "$_cand_dir" ]; then')
|
||||
runner_lines.append(' export PATH="$_cand_dir:$PATH"')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' hash -r')
|
||||
runner_lines.append(' return 0')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' return 1')
|
||||
runner_lines.append('}')
|
||||
|
||||
|
||||
def _append_llama_server_shim_definition_lines(runner_lines: list[str]) -> None:
|
||||
"""Define the helper shell function that generates the Odysseus compatibility shim."""
|
||||
_append_llama_server_resolution_definition_lines(runner_lines)
|
||||
marker = f"'{_bash_squote(ODYSSEUS_LLAMA_SHIM_MARKER)}'"
|
||||
runner_lines.append('_odysseus_create_llama_shim() {')
|
||||
runner_lines.append(' local _target="$HOME/bin/llama-server"')
|
||||
runner_lines.append(f' local _marker={marker}')
|
||||
runner_lines.append(' if [ -e "$_target" ] || [ -L "$_target" ]; then')
|
||||
runner_lines.append(' if [ -f "$_target" ] && [ ! -L "$_target" ] && grep -Fxq "$_marker" "$_target" 2>/dev/null; then')
|
||||
runner_lines.append(' : # existing Odysseus shim — safe to replace')
|
||||
runner_lines.append(' else')
|
||||
runner_lines.append(' echo "[odysseus] WARNING: $_target already exists and is not an Odysseus-managed shim; leaving untouched." >&2')
|
||||
runner_lines.append(' return 1')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' mkdir -p "$HOME/bin" || return 1')
|
||||
runner_lines.append(' local _tmp')
|
||||
runner_lines.append(' _tmp="$(mktemp "$HOME/bin/.llama-server.odysseus.tmp.XXXXXX")" || {')
|
||||
runner_lines.append(' echo "[odysseus] ERROR: unable to create secure temporary llama-server shim in $HOME/bin" >&2')
|
||||
runner_lines.append(' return 1')
|
||||
runner_lines.append(' }')
|
||||
runner_lines.append(" if ! cat > \"$_tmp\" <<'_ODY_LLAMA_SHIM_EOF'")
|
||||
runner_lines.append('#!/usr/bin/env bash')
|
||||
runner_lines.append(ODYSSEUS_LLAMA_SHIM_MARKER)
|
||||
runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.')
|
||||
runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts')
|
||||
runner_lines.append('# where only the pip llama-cpp-python package is installed.')
|
||||
runner_lines.append('ARGS=()')
|
||||
runner_lines.append('while [ $# -gt 0 ]; do')
|
||||
runner_lines.append(' case "$1" in')
|
||||
runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch')
|
||||
runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;')
|
||||
runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only')
|
||||
runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;')
|
||||
runner_lines.append(' --no-warmup) shift ;; # native-only')
|
||||
runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only')
|
||||
runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;')
|
||||
runner_lines.append(' *) ARGS+=("$1"); shift ;;')
|
||||
runner_lines.append(' esac')
|
||||
runner_lines.append('done')
|
||||
runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"')
|
||||
runner_lines.append('_ODY_LLAMA_SHIM_EOF')
|
||||
runner_lines.append(' then')
|
||||
runner_lines.append(' rm -f -- "$_tmp"')
|
||||
runner_lines.append(' return 1')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' chmod +x "$_tmp" || { rm -f -- "$_tmp"; return 1; }')
|
||||
runner_lines.append(' mv -f -- "$_tmp" "$_target" || { rm -f -- "$_tmp"; return 1; }')
|
||||
runner_lines.append(' hash -r')
|
||||
runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"')
|
||||
runner_lines.append('}')
|
||||
|
||||
|
||||
def _append_llama_server_shim_reconciliation_lines(runner_lines: list[str]) -> None:
|
||||
"""Reconcile an existing ~/bin/llama-server if it is owned by Odysseus Cookbook.
|
||||
|
||||
Invariants:
|
||||
- Never touch an arbitrary user-owned executable or symlink.
|
||||
- If ~/bin/llama-server is an Odysseus generated shim:
|
||||
- If a native llama-server is present and healthy elsewhere on PATH, remove the shim
|
||||
so the native binary is used.
|
||||
- If python3 -m llama_cpp.server is unavailable, remove the stale shim
|
||||
and hash -r so the fallback/build path is triggered.
|
||||
- If no healthy native server exists and llama_cpp.server is working, preserve it.
|
||||
- If a healthy native candidate exists on PATH (even later in PATH), prioritize it and
|
||||
ensure the launched executable is the validated candidate.
|
||||
"""
|
||||
runner_lines.append('[ -r ~/.config/odysseus-llama-cpp-env ] && . ~/.config/odysseus-llama-cpp-env')
|
||||
runner_lines.append('if ! declare -F _odysseus_resolve_llama_server >/dev/null 2>&1; then')
|
||||
_append_llama_server_resolution_definition_lines(runner_lines)
|
||||
runner_lines.append('fi')
|
||||
runner_lines.append('_odysseus_resolve_llama_server || true')
|
||||
|
||||
|
||||
def _append_llama_server_finalization_lines(runner_lines: list[str], platform_hint: str = "") -> None:
|
||||
"""Reconcile post-install launcher availability and enforce fail-closed invariant.
|
||||
|
||||
Guarantees that if python3 -m llama_cpp.server is usable but the `llama-server`
|
||||
command is unresolvable, the compatibility launcher is created. If no usable
|
||||
launcher exists after all build/install attempts, sets ODYSSEUS_PREFLIGHT_EXIT=127.
|
||||
"""
|
||||
err_msg = (
|
||||
f"ERROR: llama.cpp serving is not available on {platform_hint} after install attempts."
|
||||
if platform_hint
|
||||
else "ERROR: llama.cpp serving is not available after install/build attempts."
|
||||
)
|
||||
runner_lines.append('if ! declare -F _odysseus_resolve_llama_server >/dev/null 2>&1; then')
|
||||
_append_llama_server_resolution_definition_lines(runner_lines)
|
||||
runner_lines.append('fi')
|
||||
runner_lines.append('if [ -z "$_odysseus_llama_bin" ] && python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||
runner_lines.append(' _odysseus_create_llama_shim')
|
||||
runner_lines.append(' _odysseus_resolve_llama_server || true')
|
||||
runner_lines.append('fi')
|
||||
runner_lines.append('if [ -z "$_odysseus_llama_bin" ]; then')
|
||||
runner_lines.append(f' echo "{err_msg}"')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append('fi')
|
||||
|
||||
|
||||
def _llama_cpp_rebuild_cmd(update_source: bool = False) -> str:
|
||||
"""Shell command that clears the Cookbook-managed llama.cpp build.
|
||||
|
||||
@@ -1327,6 +1491,20 @@ def _diagnose_serve_output(text: str) -> dict | None:
|
||||
"Port is already in use.",
|
||||
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
|
||||
),
|
||||
(
|
||||
# PyPI torch/vLLM wheels now target CUDA 13, which needs driver 580+.
|
||||
# torch: "The NVIDIA driver on your system is too old (found version 12080)";
|
||||
# vLLM kernels: "CUDA driver version is insufficient for CUDA runtime version".
|
||||
r"NVIDIA driver on your system is too old|CUDA driver version is insufficient",
|
||||
"The installed PyTorch/vLLM build needs a newer NVIDIA driver than this server has.",
|
||||
[
|
||||
{
|
||||
"label": "install builds for the driver's CUDA version, e.g. vLLM's +cu129 wheel from its GitHub release with uv pip install … --torch-backend cu129",
|
||||
"op": "manual",
|
||||
},
|
||||
{"label": "or update the NVIDIA driver (CUDA 13 builds need driver 580 or newer)", "op": "manual"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
|
||||
"No GPUs are visible to the serve process.",
|
||||
|
||||
@@ -8,6 +8,20 @@ import re
|
||||
|
||||
_FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
|
||||
|
||||
# Shared source embedded in local/remote inventory and status probes. A blank
|
||||
# retry marker beside its completed, nonempty content-addressed blob is stale;
|
||||
# a blank marker alone may be a download that has not received its first byte.
|
||||
HF_PARTIAL_BLOB_HELPER = """def is_partial_blob(path):
|
||||
if not path.endswith('.incomplete'):
|
||||
return False
|
||||
try:
|
||||
completed = path[:-len('.incomplete')]
|
||||
return (os.path.getsize(path) != 0 or not os.path.isfile(completed)
|
||||
or os.path.getsize(completed) == 0)
|
||||
except OSError:
|
||||
return True
|
||||
"""
|
||||
|
||||
# Probe scripts for the dead-session download check, run as
|
||||
# `python3 -c <PROBE> <repo_id> <cache_root>` (locally or over SSH).
|
||||
# cache_root is the task's custom download dir, '' for the default HF cache.
|
||||
@@ -15,7 +29,7 @@ _FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
|
||||
# HF_HOME=<local_dir>, so that task's cache lives under <local_dir>/hub, and
|
||||
# the probe process's own environment knows nothing about it.
|
||||
HF_CACHE_COMPLETE_PROBE = (
|
||||
"import os,sys;"
|
||||
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER +
|
||||
"repo=sys.argv[1];"
|
||||
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
|
||||
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
|
||||
@@ -24,18 +38,18 @@ HF_CACHE_COMPLETE_PROBE = (
|
||||
"ok=os.path.isdir(snap) and any(os.path.isdir(os.path.join(snap,x)) and os.listdir(os.path.join(snap,x)) for x in os.listdir(snap));"
|
||||
"inc=False;"
|
||||
"blobs=os.path.join(d,'blobs');"
|
||||
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
|
||||
"inc=os.path.isdir(blobs) and any(is_partial_blob(os.path.join(blobs,x)) for x in os.listdir(blobs));"
|
||||
"sys.exit(0 if ok and not inc else 1)"
|
||||
)
|
||||
|
||||
HF_CACHE_INCOMPLETE_PROBE = (
|
||||
"import os,sys;"
|
||||
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER +
|
||||
"repo=sys.argv[1];"
|
||||
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
|
||||
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
|
||||
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
|
||||
"blobs=os.path.join(d,'blobs');"
|
||||
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
|
||||
"inc=os.path.isdir(blobs) and any(is_partial_blob(os.path.join(blobs,x)) for x in os.listdir(blobs));"
|
||||
"sys.exit(0 if inc else 1)"
|
||||
)
|
||||
|
||||
|
||||
+22
-229
@@ -57,9 +57,11 @@ from routes.cookbook_helpers import (
|
||||
_pip_install_no_cache, _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
|
||||
_diagnose_serve_output, run_ssh_command_async,
|
||||
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache,
|
||||
_user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
|
||||
_append_pip_install_runner_lines, _pip_install_command_without_break_system_packages,
|
||||
_normalize_llama_cpp_python_cache_types,
|
||||
_append_llama_server_shim_definition_lines,
|
||||
_append_llama_server_shim_reconciliation_lines,
|
||||
_append_llama_server_finalization_lines,
|
||||
ModelDownloadRequest, ServeRequest,
|
||||
)
|
||||
|
||||
@@ -466,175 +468,6 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
task["payload"].pop("hf_token", None)
|
||||
return state
|
||||
|
||||
def _diagnose_serve_output(text: str) -> dict | None:
|
||||
"""Server-side mirror of the Cookbook UI's common serve diagnoses.
|
||||
|
||||
The browser uses cookbook-diagnosis.js for clickable fixes. This gives
|
||||
the agent/tool path the same structured signal so it can retry with an
|
||||
adjusted command instead of guessing from raw tmux output.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
tail = text[-6000:]
|
||||
patterns = [
|
||||
(
|
||||
r"No available memory for the cache blocks|Available KV cache memory:.*-",
|
||||
"No GPU memory left for KV cache after loading model.",
|
||||
[
|
||||
{"label": "retry with GPU memory utilization 0.95", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.95"},
|
||||
{"label": "retry with context 2048", "op": "replace", "flag": "--max-model-len", "value": "2048"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"CUDA out of memory|torch\.cuda\.OutOfMemoryError|CUDA error: out of memory|warming up sampler|max_num_seqs.*gpu_memory_utilization",
|
||||
"GPU ran out of memory during startup or warmup.",
|
||||
[
|
||||
{"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"},
|
||||
{"label": "retry with GPU memory utilization 0.80", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.80"},
|
||||
{"label": "retry with --enforce-eager", "op": "append", "arg": "--enforce-eager"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"not divisib|must be divisible|attention heads.*divisible",
|
||||
"Tensor parallel size is incompatible with the model.",
|
||||
[
|
||||
{"label": "retry with tensor parallel size 1", "op": "replace", "flag": "--tensor-parallel-size", "value": "1"},
|
||||
{"label": "retry with tensor parallel size 2", "op": "replace", "flag": "--tensor-parallel-size", "value": "2"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"KV cache.*too (small|large)|max_model_len.*exceeds|maximum.*context",
|
||||
"Context length is too large for available GPU memory.",
|
||||
[
|
||||
{"label": "retry with context 8192", "op": "replace", "flag": "--max-model-len", "value": "8192"},
|
||||
{"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"enable-auto-tool-choice requires --tool-call-parser",
|
||||
"Auto tool choice requires an explicit tool call parser.",
|
||||
[{"label": "retry with Hermes tool parser", "op": "append", "arg": "--tool-call-parser hermes"}],
|
||||
),
|
||||
(
|
||||
r"Please pass.*trust.remote.code=True|contains custom code which must be executed to correctly load|does not recognize this architecture|model type.*but Transformers does not",
|
||||
"Model requires custom code or newer model support.",
|
||||
[{"label": "retry with --trust-remote-code", "op": "append", "arg": "--trust-remote-code"}],
|
||||
),
|
||||
(
|
||||
r"Either a revision or a version must be specified|transformers\.integrations\.hub_kernels|kernels/layer",
|
||||
"vLLM/Transformers kernel package mismatch.",
|
||||
[{"label": "update vLLM, Transformers, and kernels on this server", "op": "dependency", "package": "vllm transformers kernels"}],
|
||||
),
|
||||
(
|
||||
r"Address already in use|bind.*address.*in use",
|
||||
"Port is already in use.",
|
||||
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
|
||||
),
|
||||
(
|
||||
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
|
||||
"No GPUs are visible to the serve process.",
|
||||
[{"label": "clear Cookbook GPU selection or choose available GPUs", "op": "settings", "field": "gpus", "value": ""}],
|
||||
),
|
||||
(
|
||||
r"Failed to infer device type|NVML Shared Library Not Found|No module named 'amdsmi'|platform is not available",
|
||||
"vLLM could not find a supported GPU (CUDA or ROCm). "
|
||||
"This machine may have integrated or unsupported graphics only.",
|
||||
[
|
||||
{"label": "switch to llama.cpp (CPU/Metal, works without a discrete GPU)", "op": "manual"},
|
||||
{"label": "switch to Ollama (CPU/Metal, works without a discrete GPU)", "op": "manual"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"vllm.*command not found|No module named vllm|ERROR: vLLM is not installed",
|
||||
"vLLM is not installed or not in PATH on this server.",
|
||||
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
|
||||
),
|
||||
(
|
||||
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)|"
|
||||
r"(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)[\s\S]*sgl_kernel|"
|
||||
r"Could not load any common_ops library|"
|
||||
r"Please ensure sgl_kernel is properly installed",
|
||||
"SGLang native kernel/runtime is missing or mismatched on this server.",
|
||||
[
|
||||
{"label": "repair sglang-kernel in this Python environment", "op": "dependency", "package": "sglang-kernel"},
|
||||
{"label": "if libnvrtc is still missing, install the matching CUDA/NVRTC runtime on this host", "op": "manual"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"sglang.*command not found|No module named sglang|SGLang is not installed",
|
||||
"SGLang is not installed or not in PATH on this server.",
|
||||
[{"label": "install SGLang in Cookbook Dependencies", "op": "dependency", "package": "sglang[all]"}],
|
||||
),
|
||||
(
|
||||
r"No module named ['\"]?mlx_lm|mlx_lm.*command not found|MLX is not installed|MLX LM is not installed",
|
||||
"MLX LM is not installed on this server.",
|
||||
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
|
||||
),
|
||||
(
|
||||
r"OmniGen2Pipeline|module diffusers has no attribute .*Pipeline|custom_pipeline=.*failed",
|
||||
"This image model uses a custom Diffusers pipeline that the launch environment does not know yet.",
|
||||
[{"label": "update Diffusers image dependencies", "op": "dependency", "package": "diffusers transformers accelerate"}],
|
||||
),
|
||||
(
|
||||
r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear",
|
||||
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
|
||||
[
|
||||
{"label": "relaunch from the cached local Hugging Face snapshot path on this Mac", "op": "manual"},
|
||||
{"label": "Odysseus now rewrites MLX repo-id launches to a cached snapshot when one exists", "op": "manual"},
|
||||
],
|
||||
),
|
||||
# System build deps come BEFORE the generic llama.cpp catch-all
|
||||
# so cmake / build-essential / git missing → a specific OS-package
|
||||
# remediation instead of "install llama-cpp-python[server]" (which
|
||||
# itself fails to compile when cmake is absent).
|
||||
(
|
||||
r"cmake: command not found|cmake.*not found.*[Cc]ould not",
|
||||
"cmake is required to build llama.cpp from source but isn't installed on this server.",
|
||||
[{"label": "install build deps for llama.cpp (apt: cmake build-essential git / pacman: cmake base-devel git / dnf: cmake gcc-c++ make git / brew: cmake git)", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||
),
|
||||
(
|
||||
r"^(make|g\+\+|gcc): command not found|Could not find C\+\+ compiler",
|
||||
"A C/C++ compiler (build-essential) is required to build llama.cpp from source.",
|
||||
[{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||
),
|
||||
(
|
||||
r"^git: command not found",
|
||||
"git is required to clone the llama.cpp source tree.",
|
||||
[{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||
),
|
||||
(
|
||||
r"llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'",
|
||||
"llama.cpp / llama-cpp-python dependencies are missing.",
|
||||
[{"label": "install llama.cpp dependencies or llama-cpp-python[server]", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||
),
|
||||
(
|
||||
r"No GGUF found on this host|no \.gguf file|No GGUF file found",
|
||||
"No GGUF file found for this model on this host. The llama.cpp backend needs a .gguf file.",
|
||||
[{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}],
|
||||
),
|
||||
(
|
||||
r"No module named 'torch'|No module named torch|No module named 'torchvision'|No module named torchvision|No module named 'diffusers'|No module named diffusers|No module named 'scipy'|No module named scipy|install scipy if you want to use beta sigmas|requires the Torchvision library",
|
||||
"Diffusion serving requires PyTorch, Torchvision, Diffusers, Accelerate, and SciPy.",
|
||||
[{"label": "install Diffusers image deps in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch] torchvision accelerate scipy python-multipart"}],
|
||||
),
|
||||
(
|
||||
r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review",
|
||||
"Model access is gated or unauthorized.",
|
||||
[{"label": "set HF token and request model access on HuggingFace", "op": "manual"}],
|
||||
),
|
||||
]
|
||||
for pattern, message, suggestions in patterns:
|
||||
if re.search(pattern, tail, re.I):
|
||||
return {"message": message, "suggestions": suggestions}
|
||||
if re.search(r"Traceback \(most recent call last\)", tail, re.I) and not re.search(
|
||||
r"Application startup complete|GET /v1/|Uvicorn running on", tail, re.I
|
||||
):
|
||||
return {
|
||||
"message": "Python traceback detected during serve startup.",
|
||||
"suggestions": [{"label": "inspect traceback and retry with adjusted backend/settings", "op": "manual"}],
|
||||
}
|
||||
return None
|
||||
|
||||
def _state_for_client(state):
|
||||
"""Return cookbook state without raw secrets for browser clients."""
|
||||
_strip_task_secrets(state)
|
||||
@@ -2161,7 +1994,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
ps_lines.append('}')
|
||||
elif "llama_cpp" in req.cmd or "llama-server" in req.cmd:
|
||||
ps_lines.append('# Auto-install llama-cpp-python if missing')
|
||||
ps_lines.append('try { python -c "import llama_cpp" 2>$null } catch {}')
|
||||
ps_lines.append('try { python -c "import llama_cpp.server" 2>$null } catch {}')
|
||||
ps_lines.append('if ($LASTEXITCODE -ne 0) {')
|
||||
ps_lines.append(' Write-Host "Installing llama-cpp-python..."')
|
||||
ps_lines.append(' python -m pip install llama-cpp-python[server]')
|
||||
@@ -2247,14 +2080,17 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# ollama is found (otherwise macOS falls back to a slow source build).
|
||||
# /opt/homebrew = Apple Silicon, /usr/local = Intel; harmless on Linux.
|
||||
runner_lines.append('export PATH="$HOME/.local/bin:$HOME/bin:$HOME/llama.cpp/build/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
|
||||
_append_llama_server_shim_definition_lines(runner_lines)
|
||||
_append_llama_server_shim_reconciliation_lines(runner_lines)
|
||||
runner_lines.append('if [ -d /data/data/com.termux ]; then')
|
||||
runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).')
|
||||
runner_lines.append(' if ! python3 -c "import llama_cpp" 2>/dev/null; then')
|
||||
runner_lines.append(' if ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||
runner_lines.append(' pkg install -y cmake 2>/dev/null')
|
||||
runner_lines.append(' pip install numpy diskcache jinja2 2>/dev/null')
|
||||
runner_lines.append(' CMAKE_ARGS="-DGGML_BLAS=OFF -DGGML_LLAMAFILE=OFF" pip install \'llama-cpp-python[server]\' --no-build-isolation --no-cache-dir 2>&1 || true')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append('elif ! command -v llama-server &>/dev/null; then')
|
||||
_append_llama_server_finalization_lines(runner_lines, platform_hint="Termux")
|
||||
runner_lines.append('elif [ -z "$_odysseus_llama_bin" ]; then')
|
||||
runner_lines.append(' echo "Native llama-server not found — building from source (one-time, may take a few minutes)..."')
|
||||
runner_lines.append(' mkdir -p ~/bin')
|
||||
runner_lines.append(' cd ~ && [ -d llama.cpp ] || git clone --depth 1 https://github.com/ggml-org/llama.cpp')
|
||||
@@ -2271,8 +2107,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# would reuse the bad settings and fail again. CMAKE_BUILD_TYPE is
|
||||
# explicit so the binary is optimized (Metal auto-enables on macOS).
|
||||
runner_lines.append(' cd ~/llama.cpp && rm -rf build && cmake -B build -DCMAKE_BUILD_TYPE=Release \\')
|
||||
runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server \\')
|
||||
runner_lines.append(' && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server')
|
||||
runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server')
|
||||
runner_lines.append(' else')
|
||||
_append_llama_cpp_linux_accel_build_lines(runner_lines)
|
||||
runner_lines.append(' fi')
|
||||
@@ -2296,58 +2131,13 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' fi')
|
||||
# SHORT-CIRCUIT before the build/pip fallback: if the
|
||||
# native binary is missing but llama_cpp Python is already
|
||||
# installed, drop a wrapper at ~/bin/llama-server that
|
||||
# translates llama-server CLI args to llama_cpp.server's
|
||||
# underscore-style flags. The user's serve command stays
|
||||
# `llama-server ...` and "just works" — no build, no cmake,
|
||||
# no second install. This is the path that unblocks every
|
||||
# remote where pip-installed llama-cpp-python is already
|
||||
# working but Cookbook used to insist on a native binary.
|
||||
runner_lines.append(' if ! command -v llama-server >/dev/null 2>&1 && python3 -c "import llama_cpp" 2>/dev/null; then')
|
||||
runner_lines.append(' mkdir -p ~/bin')
|
||||
runner_lines.append(' cat > ~/bin/llama-server <<\'_ODY_LLAMA_SHIM_EOF\'')
|
||||
runner_lines.append('#!/usr/bin/env bash')
|
||||
runner_lines.append('# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike')
|
||||
runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.')
|
||||
runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts')
|
||||
runner_lines.append('# where only the pip llama-cpp-python package is installed.')
|
||||
runner_lines.append('ARGS=()')
|
||||
runner_lines.append('while [ $# -gt 0 ]; do')
|
||||
runner_lines.append(' case "$1" in')
|
||||
runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch')
|
||||
runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;')
|
||||
runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only')
|
||||
runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;')
|
||||
runner_lines.append(' --no-warmup) shift ;; # native-only')
|
||||
runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only')
|
||||
runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;')
|
||||
runner_lines.append(' *) ARGS+=("$1"); shift ;;')
|
||||
runner_lines.append(' esac')
|
||||
runner_lines.append('done')
|
||||
runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"')
|
||||
runner_lines.append('_ODY_LLAMA_SHIM_EOF')
|
||||
runner_lines.append(' chmod +x ~/bin/llama-server')
|
||||
runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' # If the native build failed, fall back to the Python bindings.')
|
||||
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
|
||||
runner_lines.append(' _odysseus_resolve_llama_server || true')
|
||||
# If the native build failed, fall back to the Python bindings.
|
||||
runner_lines.append(' if [ -z "$_odysseus_llama_bin" ] && ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||
runner_lines.append(' echo "llama-server build failed — installing Python bindings as fallback..."')
|
||||
runner_lines.append(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='pip')} || true")
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
|
||||
runner_lines.append(' echo "ERROR: llama.cpp serving is not available after install/build attempts."')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append(' fi')
|
||||
_append_llama_server_finalization_lines(runner_lines)
|
||||
runner_lines.append('fi')
|
||||
elif re.search(r"\bollama\s+serve\b", req.cmd):
|
||||
handled_ollama_serve = True
|
||||
@@ -3971,7 +3761,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
return {"ok": False, "files": [], "error": f"HF API HTTP {resp.status_code}"}
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
logger.exception("HF GGUF file scan failed for %s", repo)
|
||||
logger.exception("HF GGUF file scan failed for %s", repo_id)
|
||||
return {"ok": False, "files": [], "error": "HF API request failed"}
|
||||
files = [
|
||||
str(s.get("rfilename") or "")
|
||||
@@ -4343,7 +4133,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
tmux output can stop at a stale progress line if the pane/session
|
||||
disappears before Cookbook captures the final DOWNLOAD_OK marker.
|
||||
In that case, trust the cache shape: a snapshot directory with files
|
||||
and no *.incomplete blobs means HuggingFace finished materializing the
|
||||
and no unfinished blobs means HuggingFace finished materializing the
|
||||
model. cache_root is the task's custom download dir — the runner
|
||||
pointed HF_HOME there, so the cache lives under <cache_root>/hub,
|
||||
not wherever this probe's environment says.
|
||||
@@ -4368,7 +4158,8 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
"""Best-effort check for resumable HF partial blobs.
|
||||
|
||||
A lost SSH/tmux session can leave a real download still incomplete.
|
||||
Treat any *.incomplete blob as stronger evidence than stale
|
||||
Treat unfinished blobs (excluding empty markers paired with a
|
||||
completed blob) as stronger evidence than stale
|
||||
"100%" lines in the captured pane output.
|
||||
"""
|
||||
if not repo_id or "/" not in repo_id:
|
||||
@@ -4486,7 +4277,9 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# fills the visible tail. Without this, output_tail ends up
|
||||
# as just "Locale: C / Ubuntu_Odysseus ❯" and the agent
|
||||
# can't diagnose the actual error.
|
||||
capture_cmd = ssh_base + [remote, _remote_tmux_command("capture-pane", "-t", session_id, "-p", "-S", "-500")]
|
||||
# -J joins lines tmux wrapped at the pane width, so an error
|
||||
# split mid-word still matches the serve diagnosis patterns.
|
||||
capture_cmd = ssh_base + [remote, _remote_tmux_command("capture-pane", "-t", session_id, "-p", "-J", "-S", "-500")]
|
||||
elif IS_WINDOWS:
|
||||
# LOCAL Windows task: launched as a detached process (no tmux).
|
||||
# Liveness comes from the <session>.pid file, output from the
|
||||
@@ -4495,7 +4288,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
capture_cmd = None
|
||||
else:
|
||||
check_cmd = ["tmux", "has-session", "-t", session_id]
|
||||
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-S", "-500"]
|
||||
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-J", "-S", "-500"]
|
||||
|
||||
local_win_task = (not remote) and IS_WINDOWS
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from core.database import SessionLocal, Document, DocumentVersion
|
||||
from core.database import Session as DbSession
|
||||
from src.auth_helpers import get_current_user, _auth_disabled
|
||||
from src.constants import MAIL_ATTACHMENTS_DIR
|
||||
from src.session_search import _escape_like
|
||||
from src.upload_handler import reserve_upload_references
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -571,9 +572,10 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
# match docs containing both words regardless of position/order.
|
||||
if search:
|
||||
for tok in search.split():
|
||||
term = f"%{tok}%"
|
||||
term = f"%{_escape_like(tok)}%"
|
||||
q = q.filter(
|
||||
Document.title.ilike(term) | Document.current_content.ilike(term)
|
||||
Document.title.ilike(term, escape="\\")
|
||||
| Document.current_content.ilike(term, escape="\\")
|
||||
)
|
||||
|
||||
# Language filter. "pdf" is a display language derived from the
|
||||
@@ -781,9 +783,11 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse export request body, defaulting to empty", exc_info=e)
|
||||
data = {}
|
||||
ids = data.get("ids") or []
|
||||
ids = data.get("ids") if isinstance(data, dict) else None
|
||||
if not ids:
|
||||
raise HTTPException(400, "No documents specified")
|
||||
if not isinstance(ids, list) or not all(isinstance(i, str) for i in ids):
|
||||
raise HTTPException(400, "ids must be a list of document ids")
|
||||
_ext = {
|
||||
"javascript": ".js", "python": ".py", "html": ".html", "css": ".css",
|
||||
"markdown": ".md", "json": ".json", "yaml": ".yml", "bash": ".sh",
|
||||
@@ -810,10 +814,13 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
ext = _ext.get(doc.language or "text", ".txt")
|
||||
base = (doc.title or "document").strip() or "document"
|
||||
base = re.sub(r"[^\w\-. ]+", "", base)[:60].strip() or doc.id
|
||||
name = base if "." in base else base + ext
|
||||
has_ext = base.lower().endswith(ext)
|
||||
stem = base[:-len(ext)] if has_ext else base
|
||||
suffix = base[-len(ext):] if has_ext else ext
|
||||
name = stem + suffix
|
||||
i = 1
|
||||
while name in used:
|
||||
name = f"{base}-{i}" + ("" if "." in base else ext)
|
||||
name = f"{stem}-{i}{suffix}"
|
||||
i += 1
|
||||
used.add(name)
|
||||
content = doc.current_content or ""
|
||||
@@ -1185,7 +1192,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
async def ai_tidy_documents(request: Request) -> Dict[str, Any]:
|
||||
"""Use AI to judge if documents are junk/test/accidental, then delete them.
|
||||
Caches verdicts so previously-reviewed docs are skipped."""
|
||||
from src.task_endpoint import resolve_task_endpoint
|
||||
from src.task_endpoint import resolve_task_endpoint, task_llm_call_async
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.llm_core import llm_call_async
|
||||
|
||||
@@ -1228,13 +1235,13 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
+ "\n".join(doc_list)
|
||||
)
|
||||
|
||||
response = await llm_call_async(
|
||||
url, model,
|
||||
response = await task_llm_call_async(
|
||||
[{"role": "system", "content": "You classify documents as junk or keep. Respond only with a JSON array."},
|
||||
{"role": "user", "content": prompt}],
|
||||
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
||||
owner=user or None,
|
||||
temperature=0.1,
|
||||
max_tokens=200,
|
||||
headers=headers,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+195
-25
@@ -43,6 +43,7 @@ from starlette.concurrency import run_in_threadpool
|
||||
from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from src.constants import DATA_DIR
|
||||
from src import shutdown as _shutdown
|
||||
from src.path_confinement import confine
|
||||
|
||||
from src.llm_core import llm_call_async
|
||||
@@ -87,6 +88,17 @@ _SERVER_OWNED_OAUTH_FIELDS = {
|
||||
_CACHE_HOOKS: dict = {"invalidate_list": None}
|
||||
|
||||
|
||||
def _check_mail_host(host, owner: str, label: str) -> str | None:
|
||||
"""Reject unsafe or unresolved mail hosts before saving or testing them."""
|
||||
from src.url_safety import check_outbound_host
|
||||
ok, reason = check_outbound_host(host, block_private=_mail_private_blocked(owner))
|
||||
if ok:
|
||||
return None
|
||||
logger.warning("Rejected %s mail host for owner=%r: %s", label, owner, reason)
|
||||
# Resolver and exception details belong in server logs, never the response.
|
||||
return f"Rejected {label} host: server address is not allowed or does not resolve"
|
||||
|
||||
|
||||
def _normalized_mail_host(value) -> str:
|
||||
"""Normalize a mail hostname for exact provider-bound comparisons."""
|
||||
return str(value or "").strip().lower().rstrip(".")
|
||||
@@ -1815,7 +1827,15 @@ def setup_email_routes():
|
||||
# a cached message body.
|
||||
return (account_id or "", folder, str(uid), owner)
|
||||
|
||||
_LIST_STALE = {}
|
||||
_LIST_LOCK = _threading.RLock()
|
||||
_LIST_CACHE_LOCK = _LIST_LOCK
|
||||
# Invalidation fences reads already in flight, including background refresh.
|
||||
_LIST_EPOCH = [0]
|
||||
_LIST_CACHE_GENERATION = _LIST_EPOCH
|
||||
|
||||
def _list_cache_get(key):
|
||||
with _LIST_LOCK:
|
||||
v = _LIST_CACHE.get(key)
|
||||
if not v: return None
|
||||
if v[0] < _time.monotonic():
|
||||
@@ -1823,13 +1843,60 @@ def setup_email_routes():
|
||||
return None
|
||||
return v[1]
|
||||
|
||||
def _list_stale_get(key):
|
||||
with _LIST_LOCK:
|
||||
return _LIST_STALE.get(key)
|
||||
|
||||
def _list_stale_put(key, value):
|
||||
_LIST_STALE[key] = value
|
||||
if len(_LIST_STALE) > 96:
|
||||
for k in list(_LIST_STALE.keys())[:-48]:
|
||||
_LIST_STALE.pop(k, None)
|
||||
|
||||
_LIST_REFRESHING = set()
|
||||
|
||||
def _list_cache_put(key, value):
|
||||
with _LIST_LOCK:
|
||||
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
|
||||
# Cap size
|
||||
# Also snapshot the last-good response for stale-while-revalidate.
|
||||
_list_stale_put(key, value)
|
||||
if len(_LIST_CACHE) > 64:
|
||||
for k in list(_LIST_CACHE.keys())[:-32]:
|
||||
_LIST_CACHE.pop(k, None)
|
||||
|
||||
def _schedule_list_refresh(sync_args, cache_key, folder, account_id, owner):
|
||||
"""Refresh a list snapshot off the event loop, at most once per key.
|
||||
|
||||
Runs the blocking IMAP fetch in a daemon thread, then repopulates both
|
||||
the TTL cache and the stale snapshot so the next list view is warm.
|
||||
"""
|
||||
with _LIST_LOCK:
|
||||
if cache_key in _LIST_REFRESHING:
|
||||
return
|
||||
_LIST_REFRESHING.add(cache_key)
|
||||
epoch = _LIST_EPOCH[0]
|
||||
|
||||
def _run():
|
||||
try:
|
||||
refreshed = _list_emails_sync(*sync_args)
|
||||
if refreshed and not refreshed.get("error"):
|
||||
with _LIST_LOCK:
|
||||
if epoch != _LIST_EPOCH[0]:
|
||||
return
|
||||
_list_cache_put(cache_key, refreshed)
|
||||
try:
|
||||
_schedule_recent_email_warm(
|
||||
refreshed.get("emails") or [], folder, account_id, owner)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc: # pragma: no cover - background best effort
|
||||
logger.debug("email list background refresh failed: %s", exc)
|
||||
finally:
|
||||
with _LIST_LOCK:
|
||||
_LIST_REFRESHING.discard(cache_key)
|
||||
|
||||
_threading.Thread(target=_run, daemon=True, name="email-list-refresh").start()
|
||||
|
||||
def _folder_cache_get(account_id, owner):
|
||||
key = (account_id or "", owner or "")
|
||||
v = _FOLDER_CACHE.get(key)
|
||||
@@ -1863,18 +1930,33 @@ def setup_email_routes():
|
||||
hook(account_id, folder)
|
||||
except Exception:
|
||||
pass
|
||||
with _LIST_LOCK:
|
||||
_LIST_EPOCH[0] += 1
|
||||
if account_id is None and folder is None:
|
||||
_LIST_CACHE.clear()
|
||||
_LIST_STALE.clear()
|
||||
return
|
||||
for k in list(_LIST_CACHE.keys()):
|
||||
for cache in (_LIST_CACHE, _LIST_STALE):
|
||||
for k in list(cache.keys()):
|
||||
k_acct = k[0] if len(k) > 0 else ""
|
||||
k_folder = k[1] if len(k) > 1 else ""
|
||||
if (account_id is None or k_acct == (account_id or "")) and \
|
||||
(folder is None or k_folder == folder):
|
||||
_LIST_CACHE.pop(k, None)
|
||||
cache.pop(k, None)
|
||||
|
||||
def _index_update_flags(owner, account_id, folder, uid, flag, add):
|
||||
with _LIST_LOCK:
|
||||
_email_index_update_flags(owner, account_id, folder, uid, flag, add)
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
|
||||
def _index_delete(owner, account_id, folder, uid):
|
||||
with _LIST_LOCK:
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
|
||||
def _update_list_cache_seen(account_id, folder, uid, seen: bool):
|
||||
uid_s = str(uid)
|
||||
with _LIST_LOCK:
|
||||
for key, (expires_at, value) in list(_LIST_CACHE.items()):
|
||||
if key[0] != (account_id or "") or key[1] != folder:
|
||||
continue
|
||||
@@ -1894,6 +1976,28 @@ def setup_email_routes():
|
||||
changed = True
|
||||
if changed:
|
||||
_LIST_CACHE[key] = (expires_at, value)
|
||||
if key in _LIST_STALE:
|
||||
_LIST_STALE[key] = value
|
||||
|
||||
for key, value in list(_LIST_STALE.items()):
|
||||
if key[0] != (account_id or "") or key[1] != folder:
|
||||
continue
|
||||
emails = list((value or {}).get("emails") or [])
|
||||
changed = False
|
||||
if seen and key[2] == "unread":
|
||||
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
|
||||
if len(kept) != len(emails):
|
||||
value = dict(value)
|
||||
value["emails"] = kept
|
||||
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
|
||||
changed = True
|
||||
else:
|
||||
for e in emails:
|
||||
if str((e or {}).get("uid") or "") == uid_s:
|
||||
e["is_read"] = bool(seen)
|
||||
changed = True
|
||||
if changed:
|
||||
_LIST_STALE[key] = value
|
||||
|
||||
def _read_cache_get(key):
|
||||
v = _READ_CACHE.get(key)
|
||||
@@ -2273,6 +2377,8 @@ def setup_email_routes():
|
||||
SECURITY: `owner` is propagated so when `account_id` is missing,
|
||||
the fallback config lookup is scoped to this user's accounts only.
|
||||
"""
|
||||
with _LIST_CACHE_LOCK:
|
||||
index_generation = _LIST_CACHE_GENERATION[0]
|
||||
conn = None
|
||||
conn_ok = False
|
||||
try:
|
||||
@@ -2567,6 +2673,8 @@ def setup_email_routes():
|
||||
if parsed:
|
||||
fetched_emails.append(parsed)
|
||||
cached_rows[parsed["uid"]] = parsed
|
||||
with _LIST_CACHE_LOCK:
|
||||
if index_generation == _LIST_CACHE_GENERATION[0]:
|
||||
_email_index_upsert(owner, account_id, folder, fetched_emails)
|
||||
for uid_num in uid_order:
|
||||
row = cached_rows.get(uid_num)
|
||||
@@ -2856,6 +2964,48 @@ def setup_email_routes():
|
||||
owner, account_id or "", folder, filter, limit, offset, elapsed_ms,
|
||||
)
|
||||
return cached
|
||||
# Stale-while-revalidate: never block a plain list view on IMAP while
|
||||
# we already hold a recent snapshot or a durable index. Serve it now
|
||||
# with a `stale` marker and refresh in the background so the next view
|
||||
# is warm. Blocking IMAP only remains for a genuine cold start with no
|
||||
# snapshot and no indexed rows (e.g. a brand-new account/folder).
|
||||
if not manual_refresh and not from_addr and not has_attachments and not (date_from or date_to):
|
||||
snapshot = _list_stale_get(ck)
|
||||
if snapshot is None:
|
||||
try:
|
||||
indexed_emails, indexed_total, indexed_at = _email_index_list(
|
||||
owner, account_id, folder, filter, limit, offset, False,
|
||||
)
|
||||
except Exception:
|
||||
indexed_emails, indexed_total, indexed_at = [], 0, None
|
||||
if indexed_total:
|
||||
snapshot = {
|
||||
"emails": indexed_emails,
|
||||
"total": indexed_total,
|
||||
"folder": folder,
|
||||
"offset": offset,
|
||||
"sync": {
|
||||
"source": "index",
|
||||
"indexed": indexed_total,
|
||||
"updated_at": indexed_at,
|
||||
},
|
||||
}
|
||||
if snapshot is not None:
|
||||
payload = dict(snapshot)
|
||||
payload["emails"] = list(payload.get("emails") or [])
|
||||
_hide_unlinked_calendar_tags(payload["emails"])
|
||||
sync_meta = dict(payload.get("sync") or {})
|
||||
sync_meta["source"] = "cache_stale" if _list_stale_get(ck) is not None else "index"
|
||||
sync_meta["stale"] = True
|
||||
payload["sync"] = sync_meta
|
||||
_schedule_list_refresh(
|
||||
(folder, limit, offset, filter, account_id, from_addr,
|
||||
bool(has_attachments), owner, True, date_from or "", date_to or ""),
|
||||
ck, folder, account_id, owner,
|
||||
)
|
||||
return payload
|
||||
with _LIST_CACHE_LOCK:
|
||||
cache_generation = _LIST_CACHE_GENERATION[0]
|
||||
result = await _asyncio.to_thread(
|
||||
_list_emails_sync, folder, limit, offset, filter, account_id, from_addr,
|
||||
bool(has_attachments), owner, manual_refresh, date_from or "", date_to or "",
|
||||
@@ -2864,6 +3014,8 @@ def setup_email_routes():
|
||||
if offset == 0 and not from_addr and not has_attachments and filter in ("all", "unread", "unanswered", "undone"):
|
||||
_record_email_received_events(owner, account_id, folder, result.get("emails") or [])
|
||||
_schedule_recent_email_warm(result.get("emails") or [], folder, account_id, owner)
|
||||
with _LIST_CACHE_LOCK:
|
||||
if cache_generation == _LIST_CACHE_GENERATION[0]:
|
||||
_list_cache_put(ck, result)
|
||||
elapsed_ms = int((_time.monotonic() - started_at) * 1000)
|
||||
if elapsed_ms > 1500:
|
||||
@@ -3212,7 +3364,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
moved = _move_email_message(conn, uid, "Junk", role="junk")
|
||||
if moved:
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_index_delete(owner, account_id, folder, uid)
|
||||
_invalidate_list_cache(account_id)
|
||||
except Exception:
|
||||
logger.debug("unsubscribe move-to-spam skipped", exc_info=True)
|
||||
@@ -3229,7 +3381,7 @@ def setup_email_routes():
|
||||
if deleted:
|
||||
conn.expunge()
|
||||
if deleted:
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_index_delete(owner, account_id, folder, uid)
|
||||
_invalidate_list_cache(account_id)
|
||||
except Exception as exc:
|
||||
delete_error = "Email was unsubscribed but could not be moved to Trash"
|
||||
@@ -3311,7 +3463,7 @@ def setup_email_routes():
|
||||
if moved:
|
||||
changed += 1
|
||||
cleaned_uids.append(uid)
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_index_delete(owner, account_id, folder, uid)
|
||||
else:
|
||||
failed += 1
|
||||
except Exception:
|
||||
@@ -3400,6 +3552,8 @@ def setup_email_routes():
|
||||
folder (Gmail does), we transparently swap to All Mail so the
|
||||
search surfaces archived / labelled emails too. Plain IMAP
|
||||
accounts fall back to whatever folder the caller specified."""
|
||||
with _LIST_CACHE_LOCK:
|
||||
index_generation = _LIST_CACHE_GENERATION[0]
|
||||
if not q or len(q) < 2:
|
||||
return {"emails": [], "total": 0, "query": q}
|
||||
# CRLF in q would terminate the IMAP command early — reject defensively.
|
||||
@@ -3543,6 +3697,8 @@ def setup_email_routes():
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing search result {uid}: {e}")
|
||||
continue
|
||||
with _LIST_CACHE_LOCK:
|
||||
if index_generation == _LIST_CACHE_GENERATION[0]:
|
||||
_email_index_upsert(owner, account_id, effective_folder, fetched_emails)
|
||||
|
||||
return {
|
||||
@@ -3680,8 +3836,7 @@ def setup_email_routes():
|
||||
# accepted it, so the index and list cache cannot drift ahead of
|
||||
# the mailbox.
|
||||
if mark_seen and not mark_seen_failed:
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||
_update_list_cache_seen(account_id, folder, uid, True)
|
||||
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||
|
||||
related_attachments = []
|
||||
if full and not _has_visible_attachments(msg):
|
||||
@@ -3820,8 +3975,7 @@ def setup_email_routes():
|
||||
status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "(\\Seen)")
|
||||
if status != "OK":
|
||||
return False
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||
_update_list_cache_seen(account_id, folder, uid, True)
|
||||
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"mark-seen after cached read failed uid={uid}: {e}")
|
||||
@@ -5053,7 +5207,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
if not _store_email_flag(conn, uid, "\\Seen", add=False):
|
||||
return {"success": False, "error": "Email not found"}
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", False)
|
||||
_index_update_flags(owner, account_id, folder, uid, "\\Seen", False)
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
@@ -5073,7 +5227,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
if not _store_email_flag(conn, uid, "\\Flagged", add=bool(on)):
|
||||
return {"success": False, "error": "Email not found"}
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Flagged", bool(on))
|
||||
_index_update_flags(owner, account_id, folder, uid, "\\Flagged", bool(on))
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True, "flagged": bool(on)}
|
||||
except Exception as e:
|
||||
@@ -5091,7 +5245,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
if not _store_email_flag(conn, uid, "\\Seen", add=True):
|
||||
return {"success": False, "error": "Email not found"}
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
@@ -5111,7 +5265,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
if not _move_email_message(conn, uid, "Archive", role="archive"):
|
||||
return {"success": False, "error": "Email not found"}
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_index_delete(owner, account_id, folder, uid)
|
||||
_invalidate_list_cache(account_id)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
@@ -5142,7 +5296,7 @@ def setup_email_routes():
|
||||
# click or by another mailbox client; it is already gone
|
||||
# from the requested folder, so do not trap the UI on a
|
||||
# permanent “Email not found” error.
|
||||
_email_index_delete(owner, account_id, folder, str(uid))
|
||||
_index_delete(owner, account_id, folder, str(uid))
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True, "already_deleted": True}
|
||||
trash_folder = _resolve_mail_folder(conn, "Trash", "trash")
|
||||
@@ -5160,9 +5314,9 @@ def setup_email_routes():
|
||||
if not _move_email_message(conn, resolved_uid, trash_folder, role="trash"):
|
||||
logger.warning("Email delete Trash move failed uid=%s resolved_uid=%s folder=%s trash=%s", uid, resolved_uid, folder, trash_folder)
|
||||
return {"success": False, "error": "Could not move email to Trash"}
|
||||
_email_index_delete(owner, account_id, folder, resolved_uid)
|
||||
_index_delete(owner, account_id, folder, resolved_uid)
|
||||
if resolved_uid != str(uid):
|
||||
_email_index_delete(owner, account_id, folder, str(uid))
|
||||
_index_delete(owner, account_id, folder, str(uid))
|
||||
_invalidate_list_cache(account_id)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
@@ -5178,7 +5332,7 @@ def setup_email_routes():
|
||||
if not _store_email_flag(conn, uid, "\\Deleted", add=True):
|
||||
return {"success": False, "error": "Email not found"}
|
||||
conn.expunge()
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_index_delete(owner, account_id, folder, uid)
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
@@ -5246,7 +5400,7 @@ def setup_email_routes():
|
||||
pending_expunge.append(uid)
|
||||
continue
|
||||
deleted_uids.append(uid)
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_index_delete(owner, account_id, folder, uid)
|
||||
except Exception:
|
||||
failed_uids.append(uid)
|
||||
logger.debug("Bulk email delete failed for uid=%s", uid, exc_info=True)
|
||||
@@ -5268,7 +5422,7 @@ def setup_email_routes():
|
||||
if expunge_ok:
|
||||
for uid in pending_expunge:
|
||||
deleted_uids.append(uid)
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_index_delete(owner, account_id, folder, uid)
|
||||
else:
|
||||
# Prioritize preservation over cleanup: unflag \Deleted from our
|
||||
# copied messages so future EXPUNGE operations do not destroy them.
|
||||
@@ -5387,7 +5541,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
if not _move_email_message(conn, uid, dest):
|
||||
return {"success": False, "error": f"Failed to move to {dest}"}
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_index_delete(owner, account_id, folder, uid)
|
||||
_invalidate_list_cache(account_id)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
@@ -5473,7 +5627,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
if not _store_email_flag(conn, uid, "\\Answered", add=True):
|
||||
return {"success": False, "error": "Email not found"}
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Answered", True)
|
||||
_index_update_flags(owner, account_id, folder, uid, "\\Answered", True)
|
||||
_clear_done_response_tags(owner, account_id, folder, uid)
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True}
|
||||
@@ -5489,7 +5643,7 @@ def setup_email_routes():
|
||||
conn.select(_q(folder))
|
||||
if not _store_email_flag(conn, uid, "\\Answered", add=False):
|
||||
return {"success": False, "error": "Email not found"}
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Answered", False)
|
||||
_index_update_flags(owner, account_id, folder, uid, "\\Answered", False)
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
@@ -6156,7 +6310,7 @@ def setup_email_routes():
|
||||
try:
|
||||
st, _sel = imap.select(_q(_source_folder), readonly=False)
|
||||
if st == "OK" and _store_email_flag(imap, _source_uid, "\\Answered", add=True):
|
||||
_email_index_update_flags(owner, _account_id, _source_folder, _source_uid, "\\Answered", True)
|
||||
_index_update_flags(owner, _account_id, _source_folder, _source_uid, "\\Answered", True)
|
||||
_clear_done_response_tags(owner, _account_id, _source_folder, _source_uid)
|
||||
_invalidate_list_cache(_account_id, _source_folder)
|
||||
logger.info(f"Marked source UID {_source_uid} as \\Answered in {_source_folder}")
|
||||
@@ -6706,7 +6860,7 @@ def setup_email_routes():
|
||||
await queue.put({'type': 'result', **result})
|
||||
task = asyncio.create_task(run())
|
||||
try:
|
||||
while True:
|
||||
while not _shutdown.is_shutting_down():
|
||||
item = await queue.get()
|
||||
yield 'data: ' + json.dumps(item) + '\n\n'
|
||||
if item.get('type') == 'result':
|
||||
@@ -7139,6 +7293,12 @@ def setup_email_routes():
|
||||
if account_id:
|
||||
_assert_owns_account(account_id, owner)
|
||||
|
||||
for field, label in (("imap_host", "IMAP"), ("smtp_host", "SMTP")):
|
||||
if data.get(field):
|
||||
host_error = await run_in_threadpool(_check_mail_host, data[field], owner, label)
|
||||
if host_error:
|
||||
return {"ok": False, "error": host_error}
|
||||
|
||||
# Non-reply automation flags stay global. Away/auto-reply settings are
|
||||
# account-scoped when an account_id is supplied by the Email Settings UI.
|
||||
settings = _load_settings()
|
||||
@@ -7279,6 +7439,11 @@ def setup_email_routes():
|
||||
smtp_port, port_err = _coerce_port(data.get("smtp_port"), 465)
|
||||
if port_err:
|
||||
return {"ok": False, "error": port_err}
|
||||
for field, label in (("imap_host", "IMAP"), ("smtp_host", "SMTP")):
|
||||
if data.get(field):
|
||||
host_error = await run_in_threadpool(_check_mail_host, data[field], owner, label)
|
||||
if host_error:
|
||||
return {"ok": False, "error": host_error}
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_lock_email_account_owner_mutation(db, owner)
|
||||
@@ -7324,6 +7489,11 @@ def setup_email_routes():
|
||||
"""Update an email account. Passwords only overwrite if non-empty."""
|
||||
# Path param account_id — dep validated via Query, re-check the path-param value.
|
||||
_assert_owns_account(account_id, owner)
|
||||
for field, label in (("imap_host", "IMAP"), ("smtp_host", "SMTP")):
|
||||
if data.get(field):
|
||||
host_error = await run_in_threadpool(_check_mail_host, data[field], owner, label)
|
||||
if host_error:
|
||||
return {"ok": False, "error": host_error}
|
||||
from core.database import SessionLocal, EmailAccount
|
||||
db = SessionLocal()
|
||||
try:
|
||||
|
||||
@@ -19,9 +19,10 @@ def _split_family_token(token):
|
||||
def _derive_family(filename):
|
||||
"""Derive a font-family name from a filename like 'JetBrainsMono-Regular.woff2' → 'JetBrains Mono'."""
|
||||
name = os.path.splitext(filename)[0]
|
||||
# Strip common weight/style suffixes
|
||||
# Strip weight/style suffixes, including BoldItalic and Bold-Italic.
|
||||
name = re.sub(
|
||||
r'[-_ ]?(Thin|ExtraLight|UltraLight|Light|Regular|Medium|SemiBold|DemiBold|Bold|ExtraBold|UltraBold|Black|Heavy|Italic|Oblique|Variable|VF)$',
|
||||
r'[-_ ]?(Thin|ExtraLight|UltraLight|Light|Regular|Medium|SemiBold|DemiBold|Bold|ExtraBold|UltraBold|Black|Heavy|Italic|Oblique|Variable|VF)'
|
||||
r'(?:[-_ ]?(?:Italic|Oblique))?$',
|
||||
'', name, flags=re.IGNORECASE
|
||||
)
|
||||
# Replace dashes/underscores with spaces
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -825,6 +825,23 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
try:
|
||||
body = await request.json()
|
||||
keep_count = body.get("keep_count", 0)
|
||||
# A message id is exact; keep_count from the client counts only the
|
||||
# messages loaded in the page. before_msg_id forks before that
|
||||
# message, through_msg_id includes it.
|
||||
before_msg_id = str(body.get("before_msg_id") or "").strip()
|
||||
through_msg_id = str(body.get("through_msg_id") or "").strip()
|
||||
if before_msg_id or through_msg_id:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
all_db_messages = db.query(DbChatMessage).filter(
|
||||
DbChatMessage.session_id == session_id
|
||||
).order_by(DbChatMessage.timestamp).all()
|
||||
finally:
|
||||
db.close()
|
||||
resolved = _keep_count_before_message(all_db_messages, before_msg_id or through_msg_id)
|
||||
if resolved is None:
|
||||
raise HTTPException(404, "Message not found")
|
||||
keep_count = resolved + (0 if before_msg_id else 1)
|
||||
|
||||
# Get the source session. keep_count indexes into source.history,
|
||||
# so this must go through get_session — reading the cache directly
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
import uuid
|
||||
import urllib.parse
|
||||
import html
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Form, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
@@ -12,7 +13,7 @@ import logging
|
||||
import httpx
|
||||
|
||||
from core.database import McpServer, SessionLocal
|
||||
from core.middleware import require_admin
|
||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, require_admin
|
||||
from src.constants import DATA_DIR, MCP_OAUTH_DIR
|
||||
from src.mcp_manager import McpManager
|
||||
|
||||
@@ -424,6 +425,11 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]}
|
||||
"""
|
||||
require_admin(request)
|
||||
internal_token = request.headers.get(INTERNAL_TOOL_HEADER)
|
||||
if internal_token and secrets.compare_digest(internal_token, INTERNAL_TOOL_TOKEN):
|
||||
# Same boundary as POST /api/tools: an agent turn must not be able
|
||||
# to re-enable MCP tools an admin switched off.
|
||||
raise HTTPException(403, "Change MCP tool policy in Settings > Integrations, not from chat")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||
|
||||
+33
-10
@@ -7,6 +7,7 @@ import json
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import socket
|
||||
import secrets
|
||||
import time as _time
|
||||
import logging
|
||||
import threading
|
||||
@@ -473,7 +474,7 @@ def _truthy(value: str | None) -> bool:
|
||||
|
||||
_ENDPOINT_KINDS = {"auto", "local", "api", "proxy"}
|
||||
_REFRESH_MODES = {"auto", "manual", "disabled"}
|
||||
_MODEL_TOOL_MODES = {"none", "compact", "full"}
|
||||
_MODEL_TOOL_MODES = {"none", "compact", "full", "spec"}
|
||||
_MODEL_TOOL_MODE_ALIASES = {
|
||||
"regular": "full",
|
||||
"odysseus_compact": "compact",
|
||||
@@ -553,10 +554,10 @@ def _endpoint_refresh_timeout(ep: Any, category: str) -> float:
|
||||
val = 0
|
||||
if val > 0:
|
||||
return float(max(1, min(60, val)))
|
||||
# llama.cpp and other local OpenAI-compatible servers can block briefly
|
||||
# while warming/loading. A 2s local timeout makes working endpoints flicker
|
||||
# offline before /v1/models is ready.
|
||||
return 10.0 if category == "local" else 2.0
|
||||
# Keep the background refresh short so a single dead endpoint cannot stall
|
||||
# the model picker. A timed-out refresh never clears a non-empty model
|
||||
# cache, so a briefly-warming local server is simply retried next round.
|
||||
return 2.0
|
||||
|
||||
|
||||
def _manual_refresh_timeout(ep: Any, category: str, requested: Any = None) -> float:
|
||||
@@ -658,7 +659,7 @@ _NON_CHAT_CONTAINS = (
|
||||
"gliner", "riva", "-parse", "-embedqa", "-nemoretriever",
|
||||
"topic-control", "calibration",
|
||||
"ai-synthetic-video", "cosmos-reason2",
|
||||
"bge", "llama-guard",
|
||||
"bge", "llama-guard", "-embed", "rerank",
|
||||
)
|
||||
_NON_CHAT_EXACT_PREFIXES = (
|
||||
"gpt-audio", # gpt-audio, gpt-audio-mini etc. (not gpt-4o-audio-preview which is chat)
|
||||
@@ -1511,6 +1512,9 @@ def setup_model_routes(model_discovery):
|
||||
_refresh_inflight = {"v": False, "done": None} # coarse single-flight guard
|
||||
_REFRESH_FAILURE_BASE = 300.0
|
||||
_REFRESH_FAILURE_MAX = 3600.0
|
||||
# Even a user-triggered refresh must not re-probe an endpoint that just
|
||||
# failed/timed out on every click; keep a short cooldown.
|
||||
_REFRESH_FAILURE_MIN = 60.0
|
||||
|
||||
def _refresh_key(base: str, api_key: Optional[str], provider_auth_id: Optional[str] = None) -> str:
|
||||
# Session-backed endpoints carry no static key; include their auth id
|
||||
@@ -1558,14 +1562,20 @@ def setup_model_routes(model_discovery):
|
||||
if mode in ("manual", "disabled") and not force:
|
||||
return False, info
|
||||
fails = int(state.get("fail_count") or 0)
|
||||
if fails and not force:
|
||||
if fails:
|
||||
last_failure = float(state.get("last_failure") or 0.0)
|
||||
empty_local = (
|
||||
not cached
|
||||
and category == "local"
|
||||
and str(getattr(ep, "id", "") or "").startswith("local-")
|
||||
)
|
||||
if now - last_failure < _failure_delay(fails, empty_local=empty_local):
|
||||
delay = _failure_delay(fails, empty_local=empty_local)
|
||||
if force:
|
||||
# A user-triggered refresh gets a short cooldown instead of the
|
||||
# long background backoff, but still skips a just-failed
|
||||
# endpoint so repeated clicks cannot stall the picker.
|
||||
delay = min(delay, _REFRESH_FAILURE_MIN)
|
||||
if now - last_failure < delay:
|
||||
return False, info
|
||||
if cached and not force:
|
||||
interval = _endpoint_refresh_interval(ep, category)
|
||||
@@ -2836,11 +2846,12 @@ def setup_model_routes(model_discovery):
|
||||
return {"endpoint_id": "", "endpoint_url": "", "model": ""}
|
||||
base = _normalize_base(ep.base_url)
|
||||
chat_url = build_chat_url(base)
|
||||
if model and not _is_chat_model(model):
|
||||
model = ""
|
||||
if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)):
|
||||
try:
|
||||
visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None))
|
||||
if visible:
|
||||
model = visible[0]
|
||||
model = next((mid for mid in visible if _is_chat_model(mid)), "")
|
||||
except Exception:
|
||||
pass
|
||||
return {"endpoint_id": ep.id, "endpoint_url": chat_url, "model": model}
|
||||
@@ -3082,6 +3093,12 @@ def setup_model_routes(model_discovery):
|
||||
def update_tools(body: ToolsUpdate, request: Request):
|
||||
"""Update which tools are disabled."""
|
||||
require_admin(request)
|
||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||
internal_token = request.headers.get(INTERNAL_TOOL_HEADER)
|
||||
if internal_token and secrets.compare_digest(internal_token, INTERNAL_TOOL_TOKEN):
|
||||
# Owner impersonation can carry an admin username, so check the
|
||||
# authenticated transport rather than request.state.current_user.
|
||||
raise HTTPException(403, "Change tool policy in Settings > Agent Tools, not from chat")
|
||||
settings = _load_settings()
|
||||
settings["disabled_tools"] = body.disabled
|
||||
_save_settings(settings)
|
||||
@@ -3089,4 +3106,10 @@ def setup_model_routes(model_discovery):
|
||||
|
||||
router._should_refresh_endpoint = _should_refresh_endpoint
|
||||
router._search_endpoint_catalog = search_endpoint_catalog
|
||||
# Exposed for tests: the in-memory backoff state, its key function, and the
|
||||
# background refresh driver (probes endpoints in parallel with the short
|
||||
# per-endpoint timeout).
|
||||
router._refresh_state = _refresh_state
|
||||
router._refresh_key = _refresh_key
|
||||
router._refresh_caches_bg = _refresh_caches_bg
|
||||
return router
|
||||
|
||||
@@ -728,32 +728,35 @@ def setup_note_routes(task_scheduler=None, upload_handler=None):
|
||||
)
|
||||
if body.title is not None:
|
||||
note.title = body.title
|
||||
if body.content is not None:
|
||||
# Omitted fields preserve their values; explicit null clears the
|
||||
# nullable fields used by the editor's removal controls.
|
||||
supplied = body.model_fields_set
|
||||
if "content" in supplied:
|
||||
note.content = body.content
|
||||
if body.items is not None:
|
||||
note.items = json.dumps(body.items)
|
||||
if "items" in supplied:
|
||||
note.items = json.dumps(body.items) if body.items is not None else None
|
||||
flag_modified(note, "items")
|
||||
if body.note_type is not None:
|
||||
note.note_type = body.note_type
|
||||
if body.color is not None:
|
||||
if "color" in supplied:
|
||||
note.color = body.color
|
||||
if body.label is not None:
|
||||
if "label" in supplied:
|
||||
note.label = body.label
|
||||
if body.pinned is not None:
|
||||
note.pinned = body.pinned
|
||||
if body.archived is not None:
|
||||
note.archived = body.archived
|
||||
if body.due_date is not None:
|
||||
if "due_date" in supplied:
|
||||
note.due_date = body.due_date
|
||||
if body.image_url is not None:
|
||||
if "image_url" in supplied:
|
||||
note.image_url = body.image_url
|
||||
if body.gallery_id is not None:
|
||||
if "gallery_id" in supplied:
|
||||
note.gallery_id = body.gallery_id
|
||||
if body.repeat is not None:
|
||||
note.repeat = body.repeat
|
||||
if body.sort_order is not None:
|
||||
note.sort_order = body.sort_order
|
||||
if body.agent_session_id is not None:
|
||||
if "agent_session_id" in supplied:
|
||||
note.agent_session_id = body.agent_session_id
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -118,8 +118,12 @@ def setup_prefs_routes():
|
||||
async def set_pref(request: Request, key: str, body: dict):
|
||||
user = get_current_user(request)
|
||||
prefs = _load_for_user(user)
|
||||
prefs[key] = body.get("value")
|
||||
val = body.get("value")
|
||||
if val is None:
|
||||
prefs.pop(key, None)
|
||||
else:
|
||||
prefs[key] = val
|
||||
_save_for_user(user, prefs)
|
||||
return {"key": key, "value": prefs[key]}
|
||||
return {"key": key, "value": prefs.get(key)}
|
||||
|
||||
return router
|
||||
|
||||
@@ -17,6 +17,7 @@ from src.endpoint_resolver import resolve_endpoint
|
||||
from src.auth_helpers import _auth_disabled, get_current_user
|
||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
||||
from src.constants import DEEP_RESEARCH_DIR
|
||||
from src import shutdown as _shutdown
|
||||
|
||||
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
|
||||
|
||||
@@ -182,6 +183,7 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
|
||||
OAuth tokens in ProviderAuthSession, so ep.api_key is intentionally empty.
|
||||
"""
|
||||
from src.endpoint_resolver import (
|
||||
_endpoint_enabled_models,
|
||||
build_chat_url,
|
||||
build_headers,
|
||||
resolve_endpoint_runtime as resolve_model_endpoint_runtime,
|
||||
@@ -196,7 +198,7 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
|
||||
ep_model = (model or "").strip()
|
||||
if not ep_model:
|
||||
try:
|
||||
models = json.loads(ep.cached_models) if ep.cached_models else []
|
||||
models = _endpoint_enabled_models(ep)
|
||||
if models:
|
||||
ep_model = _first_chat_model(models)
|
||||
except Exception:
|
||||
@@ -285,11 +287,17 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
async def research_status(session_id: str, request: Request):
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
# No in-flight or persisted research for this session is a normal state
|
||||
# (most sessions have none), not an error. Return HTTP 200 with an
|
||||
# explicit empty status so loading a session does not log a spurious
|
||||
# 404; every client treats a non-"running" status as idle anyway.
|
||||
# Research owned by ANOTHER user answers identically, so the response
|
||||
# does not reveal whether that research exists.
|
||||
if not _owns_in_memory(session_id, user):
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
return {"status": "none"}
|
||||
status = research_handler.get_status(session_id)
|
||||
if status is None:
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
return {"status": "none"}
|
||||
try:
|
||||
source_state = research_handler.get_source_state(session_id)
|
||||
if isinstance(source_state, str) and source_state:
|
||||
@@ -657,7 +665,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
async def _generate():
|
||||
last_payload = None
|
||||
while True:
|
||||
while not _shutdown.is_shutting_down():
|
||||
status = research_handler.get_status(session_id)
|
||||
if status is None:
|
||||
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
|
||||
@@ -817,23 +825,13 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
if not ep_url or not ep_model:
|
||||
# Last resort: this user's enabled endpoint, plus legacy shared rows.
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = _owned_enabled_endpoint(db, user)
|
||||
if ep:
|
||||
base = normalize_base(ep.base_url)
|
||||
fallback_url = build_chat_url(base)
|
||||
fallback_headers = build_headers(ep.api_key, base)
|
||||
fallback_model = ""
|
||||
if ep.cached_models:
|
||||
try:
|
||||
models = json.loads(ep.cached_models)
|
||||
if models:
|
||||
fallback_model = _first_chat_model(models)
|
||||
except Exception:
|
||||
pass
|
||||
_merge(fallback_url, fallback_model, fallback_headers)
|
||||
resolved = _resolve_endpoint_runtime(ep, owner=user)
|
||||
if resolved:
|
||||
_merge(*resolved)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
+14
-44
@@ -1315,32 +1315,22 @@ def setup_session_routes(
|
||||
def auto_sort_sessions(request: Request, skip_llm: bool = False):
|
||||
"""Use AI to categorize all sessions into folders.
|
||||
|
||||
Phase 1 deletes empty/throwaway sessions and Phase 2 asks the LLM
|
||||
Phase 1 deletes empty/Incognito sessions and Phase 2 asks the LLM
|
||||
to assign folders. When `skip_llm=true` the endpoint returns
|
||||
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
|
||||
users can clean junk without spending tokens.
|
||||
"""
|
||||
from src.llm_core import llm_call
|
||||
from src.llm_core import llm_call, llm_call_with_fallback
|
||||
from src.utility_effort import effort_for_call
|
||||
user = effective_user(request)
|
||||
single_user_mode = not user and _auth_disabled()
|
||||
user_sessions = session_manager.get_sessions_for_user(user)
|
||||
|
||||
# Delete empty and throwaway sessions before sorting
|
||||
# Saved conversations are never disposable based on their title or prompt.
|
||||
from core.database import ChatMessage as DbMsg
|
||||
db = SessionLocal()
|
||||
deleted_empty = 0
|
||||
deleted_throwaway = 0
|
||||
# Names that indicate a throwaway/test session (case-insensitive exact or prefix match)
|
||||
_THROWAWAY_NAMES = {
|
||||
"test", "testing", "asdf", "asd", "hello", "hi", "hey",
|
||||
"yo", "sup", "hola", "hii", "hiii", "heyo",
|
||||
"foo", "bar", "baz", "tmp", "temp", "scratch", "untitled",
|
||||
"new chat", "delete", "remove", "junk", "trash", "xxx",
|
||||
"abc", "qwerty", "blah", "stuff", "whatever", "idk",
|
||||
"ok", "lol", "bruh", "hmm", "hm", "meh",
|
||||
}
|
||||
_THROWAWAY_MAX_MESSAGES = 4 # only delete if <= this many messages
|
||||
try:
|
||||
rows_q = db.query(DbSession).filter(DbSession.archived == False)
|
||||
if user:
|
||||
@@ -1349,15 +1339,11 @@ def setup_session_routes(
|
||||
rows_q = rows_q.filter(DbSession.owner == user)
|
||||
rows = rows_q.limit(2000).all()
|
||||
folder_map = {r.id: r.folder for r in rows}
|
||||
# Precompute per-session message counts in TWO aggregate queries
|
||||
# Precompute per-session message counts in one aggregate query
|
||||
# instead of 1–3 queries PER session — with many chats the per-row
|
||||
# loop was doing thousands of round-trips and blowing the timeout.
|
||||
from sqlalchemy import func as _sa_func
|
||||
_counts = dict(db.query(DbMsg.session_id, _sa_func.count(DbMsg.id)).group_by(DbMsg.session_id).all())
|
||||
_asst_counts = dict(
|
||||
db.query(DbMsg.session_id, _sa_func.count(DbMsg.id))
|
||||
.filter(DbMsg.role == "assistant").group_by(DbMsg.session_id).all()
|
||||
)
|
||||
cleanup_now = utcnow_naive()
|
||||
for row in rows:
|
||||
# Never delete important sessions
|
||||
@@ -1367,9 +1353,10 @@ def setup_session_routes(
|
||||
if (row.name or "").strip() == "Incognito":
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
db.delete(row)
|
||||
if hasattr(session_manager, 'delete_session'):
|
||||
session_manager.delete_session(row.id)
|
||||
else:
|
||||
db.delete(row)
|
||||
continue
|
||||
if is_session_recently_active(row, now=cleanup_now):
|
||||
continue
|
||||
@@ -1378,31 +1365,11 @@ def setup_session_routes(
|
||||
if msg_count == 0:
|
||||
should_delete = True
|
||||
deleted_empty += 1
|
||||
elif msg_count <= _THROWAWAY_MAX_MESSAGES:
|
||||
name = (row.name or "").strip().lower()
|
||||
# Check first user message content (AI renames sessions, so
|
||||
# "hi" becomes "Casual Greeting Exchange" — name alone won't match)
|
||||
first_msg = db.query(DbMsg.content).filter(
|
||||
DbMsg.session_id == row.id, DbMsg.role == "user"
|
||||
).order_by(DbMsg.timestamp).first()
|
||||
first_text = (first_msg[0] or "").strip().lower() if first_msg else ""
|
||||
# Count assistant messages — if user sent something but AI never replied, it's dead
|
||||
assistant_count = _asst_counts.get(row.id, 0)
|
||||
if name in _THROWAWAY_NAMES or name.startswith("chat:") or first_text in _THROWAWAY_NAMES:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
# Single user message with no AI response = dead session
|
||||
elif msg_count == 1 and assistant_count == 0:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
# Short phrase (1-3 words) with no real AI conversation (<=2 msgs)
|
||||
elif msg_count <= 2 and first_text and len(first_text.split()) <= 3 and len(first_text) <= 40:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
if should_delete:
|
||||
db.delete(row)
|
||||
if hasattr(session_manager, 'delete_session'):
|
||||
session_manager.delete_session(row.id)
|
||||
else:
|
||||
db.delete(row)
|
||||
if deleted_empty or deleted_throwaway:
|
||||
db.commit()
|
||||
logger.info(f"Auto-sort: deleted {deleted_empty} empty + {deleted_throwaway} throwaway sessions")
|
||||
@@ -1467,7 +1434,7 @@ def setup_session_routes(
|
||||
return {"status": "skipped", "reason": "No unfiled sessions to sort"}
|
||||
|
||||
# Pick an endpoint — prefer admin-configured task endpoint
|
||||
from src.task_endpoint import resolve_task_endpoint
|
||||
from src.task_endpoint import resolve_task_endpoint, resolve_task_candidates
|
||||
url, model, headers = resolve_task_endpoint(owner=user)
|
||||
if not url:
|
||||
url, model, headers = _pick_endpoint_for_sort(owner=user)
|
||||
@@ -1493,8 +1460,11 @@ def setup_session_routes(
|
||||
# 16384 (was 4096): with many chats the folder JSON is large, and a
|
||||
# reasoning model spends tokens thinking first — 4096 truncated the
|
||||
# JSON mid-output, so it never parsed ("invalid JSON for auto-sort").
|
||||
raw = llm_call(url, model, [{"role": "user", "content": prompt}],
|
||||
temperature=0.3, max_tokens=16384, headers=headers, timeout=120,
|
||||
raw = llm_call_with_fallback(
|
||||
resolve_task_candidates(fallback_url=url, fallback_model=model,
|
||||
fallback_headers=headers, owner=user),
|
||||
[{"role": "user", "content": prompt}],
|
||||
temperature=0.3, max_tokens=16384, timeout=120,
|
||||
reasoning_effort=effort_for_call(url, model, user))
|
||||
logger.info(f"Auto-sort raw response ({len(raw)} chars): {raw[:300]}")
|
||||
# Extract JSON from response — handle markdown fences, leading text,
|
||||
|
||||
@@ -27,6 +27,7 @@ from src.host_docker_access import (
|
||||
from src.optional_deps import prepare_optional_dependency_import
|
||||
from src.auth_helpers import _auth_disabled
|
||||
from src import process_lifecycle
|
||||
from src import shutdown as _shutdown
|
||||
|
||||
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
|
||||
# on Windows, so importing them unconditionally crashed app startup there
|
||||
@@ -1025,7 +1026,7 @@ async def _generate_tmux(cmd: str, request: Request):
|
||||
lines_sent = 0
|
||||
exit_code = None
|
||||
|
||||
while True:
|
||||
while not _shutdown.is_shutting_down():
|
||||
# Check client disconnect
|
||||
if await request.is_disconnected():
|
||||
# tmux keeps running — that's the whole point
|
||||
@@ -1142,7 +1143,7 @@ async def _generate_win_detached(cmd: str, request: Request):
|
||||
|
||||
lines_sent = 0
|
||||
exit_code = None
|
||||
while True:
|
||||
while not _shutdown.is_shutting_down():
|
||||
if await request.is_disconnected():
|
||||
yield f"data: {json.dumps({'stream': 'stdout', 'data': f'Disconnected. Background job {session_id} continues running.'})}\n\n"
|
||||
return
|
||||
@@ -1267,7 +1268,7 @@ def setup_shell_routes() -> APIRouter:
|
||||
"""Read chunks, split on \\n or \\r for progress bar support."""
|
||||
try:
|
||||
buf = b""
|
||||
while True:
|
||||
while not _shutdown.is_shutting_down():
|
||||
chunk = await stream.read(4096)
|
||||
if not chunk:
|
||||
if buf:
|
||||
|
||||
@@ -15,6 +15,7 @@ import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.memory.skill_format import slugify
|
||||
from services.memory.skills import SkillsManager
|
||||
from src.auth_helpers import get_current_user
|
||||
from services.skills import evaluation as _evaluation
|
||||
@@ -836,6 +837,10 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
||||
updates = body.dict(exclude_none=True)
|
||||
if not updates:
|
||||
return {"ok": True}
|
||||
if "name" in updates:
|
||||
new_name = slugify(updates["name"] or match.get("name"))
|
||||
if new_name != match.get("name") and skills_manager.has_skill(new_name, owner=user):
|
||||
raise HTTPException(409, f"A skill named '{new_name}' already exists")
|
||||
ok = skills_manager.update_skill(match.get("name"), updates, owner=user)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
|
||||
+11
-17
@@ -20,7 +20,7 @@ from src.task_action_policy import (
|
||||
is_admin_only_task_action,
|
||||
owner_has_admin_task_privileges,
|
||||
)
|
||||
from src.task_scheduler import compute_next_run, HOUSEKEEPING_DEFAULTS
|
||||
from src.task_scheduler import compute_next_run, is_valid_cron, HOUSEKEEPING_DEFAULTS
|
||||
from routes.prefs_routes import _load_for_user, _save_for_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -289,15 +289,15 @@ def _resolve_run_endpoint(db, task: ScheduledTask, run: TaskRun) -> str:
|
||||
|
||||
try:
|
||||
from core.database import ModelEndpoint
|
||||
eps = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
||||
from src.auth_helpers import owner_filter
|
||||
from src.endpoint_resolver import _endpoint_enabled_models
|
||||
|
||||
eps = owner_filter(
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
|
||||
ModelEndpoint, getattr(task, "owner", None),
|
||||
).all()
|
||||
for ep in eps:
|
||||
cached = []
|
||||
if ep.cached_models:
|
||||
try:
|
||||
cached = json.loads(ep.cached_models) or []
|
||||
except Exception:
|
||||
cached = []
|
||||
if model in cached:
|
||||
if model in _endpoint_enabled_models(ep):
|
||||
return ep.base_url or ""
|
||||
except Exception:
|
||||
pass
|
||||
@@ -475,10 +475,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
if req.trigger_type == "schedule" and req.schedule == "cron" and not req.cron_expression:
|
||||
raise HTTPException(400, "Cron expression is required for cron schedule")
|
||||
if req.trigger_type == "schedule" and req.schedule == "cron" and req.cron_expression:
|
||||
try:
|
||||
from croniter import croniter
|
||||
croniter(req.cron_expression)
|
||||
except Exception:
|
||||
if not is_valid_cron(req.cron_expression):
|
||||
raise HTTPException(400, "Invalid cron expression")
|
||||
if req.trigger_type == "event" and not req.trigger_event:
|
||||
raise HTTPException(400, "Event name is required for event-triggered tasks")
|
||||
@@ -788,10 +785,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
task.character_id = req.character_id or None
|
||||
if req.cron_expression is not None:
|
||||
if req.cron_expression:
|
||||
try:
|
||||
from croniter import croniter
|
||||
croniter(req.cron_expression)
|
||||
except Exception:
|
||||
if not is_valid_cron(req.cron_expression):
|
||||
raise HTTPException(400, "Invalid cron expression")
|
||||
task.cron_expression = req.cron_expression or None
|
||||
|
||||
|
||||
+73
-27
@@ -25,11 +25,12 @@ from src.auth_helpers import effective_user, is_delegated_credential
|
||||
from src.owner_identity import auth_disabled, is_request_sentinel_owner
|
||||
from src.attachment_refs import attachment_refs_from_metadata
|
||||
from src.constants import GENERATED_IMAGES_DIR
|
||||
from src.path_confinement import is_inside
|
||||
from src.path_confinement import PathEscape, canonical_root, confine, is_inside
|
||||
from src.upload_handler import (
|
||||
UploadCleanupSafetyError,
|
||||
count_recent_uploads,
|
||||
extract_upload_ids,
|
||||
secure_filename,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -153,30 +154,47 @@ def setup_upload_routes(upload_handler):
|
||||
|
||||
def _upload_root() -> str:
|
||||
from src.constants import UPLOAD_DIR
|
||||
return os.path.realpath(getattr(upload_handler, "upload_dir", UPLOAD_DIR))
|
||||
return canonical_root(getattr(upload_handler, "upload_dir", UPLOAD_DIR))
|
||||
|
||||
def _path_inside_upload_dir(path: str) -> bool:
|
||||
return is_inside(_upload_root(), path)
|
||||
|
||||
def _resolve_upload_path(file_id: str) -> str:
|
||||
from src.constants import UPLOAD_DIR
|
||||
upload_root = getattr(upload_handler, "upload_dir", UPLOAD_DIR)
|
||||
direct = os.path.join(upload_root, file_id)
|
||||
safe_id = secure_filename(os.path.basename(file_id))
|
||||
if not safe_id:
|
||||
raise HTTPException(400, "Invalid file ID")
|
||||
root = _upload_root()
|
||||
|
||||
direct = os.path.normpath(os.path.join(root, safe_id))
|
||||
if os.path.lexists(direct):
|
||||
if not _path_inside_upload_dir(direct):
|
||||
try:
|
||||
canonical_direct = confine(root, direct, allow_root=False)
|
||||
except (PathEscape, ValueError):
|
||||
raise HTTPException(403, "Access denied")
|
||||
if os.path.isfile(direct):
|
||||
return direct
|
||||
if os.path.isfile(canonical_direct):
|
||||
return canonical_direct
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
for root, _dirs, files in os.walk(upload_root, followlinks=False):
|
||||
if file_id not in files:
|
||||
continue
|
||||
path = os.path.join(root, file_id)
|
||||
if not _path_inside_upload_dir(path):
|
||||
info = _load_upload_info(safe_id)
|
||||
stored_path = (info or {}).get("path")
|
||||
if stored_path:
|
||||
try:
|
||||
canonical_stored = confine(root, stored_path, allow_root=False)
|
||||
if os.path.isfile(canonical_stored):
|
||||
return canonical_stored
|
||||
except (PathEscape, ValueError):
|
||||
raise HTTPException(403, "Access denied")
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
|
||||
for walk_root, _dirs, files in os.walk(root, followlinks=False):
|
||||
if safe_id not in files:
|
||||
continue
|
||||
cand = os.path.normpath(os.path.join(walk_root, safe_id))
|
||||
try:
|
||||
canonical_cand = confine(root, cand, allow_root=False)
|
||||
except (PathEscape, ValueError):
|
||||
raise HTTPException(403, "Access denied")
|
||||
if os.path.isfile(canonical_cand):
|
||||
return canonical_cand
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
raise HTTPException(404, "File not found")
|
||||
@@ -435,35 +453,62 @@ def setup_upload_routes(upload_handler):
|
||||
logger.error(f"Failed to get upload stats: {e}")
|
||||
raise HTTPException(500, "Failed to get upload statistics")
|
||||
|
||||
@router.get("/{file_id}")
|
||||
@router.api_route("/{file_id}", methods=["GET", "HEAD"])
|
||||
async def download_file(request: Request, file_id: str, thumb: int = 0):
|
||||
"""Serve an uploaded file by its ID. `?thumb=1` returns a small cached
|
||||
JPEG thumbnail for images (used by chat attachment previews) so the
|
||||
client isn't downloading the full-resolution photo just to show it tiny."""
|
||||
"""Serve an uploaded file by its ID or probe via HEAD. `?thumb=1` returns
|
||||
a small cached JPEG thumbnail for images (used by chat attachment previews)
|
||||
so the client isn't downloading the full-resolution photo just to show it tiny."""
|
||||
if not upload_handler.validate_upload_id(file_id):
|
||||
raise HTTPException(400, "Invalid file ID")
|
||||
safe_file_id = secure_filename(os.path.basename(file_id))
|
||||
import mimetypes as _mt
|
||||
# Look up original filename and owner from uploads.json
|
||||
original_name = file_id
|
||||
original_name = safe_file_id
|
||||
# _load_upload_index() tolerates a missing/corrupt uploads.json (it falls
|
||||
# back to the .bak sibling, then to {}), so a truncated DB degrades to
|
||||
# "no metadata" instead of a 500 from an unhandled JSONDecodeError.
|
||||
db = upload_handler._load_upload_index()
|
||||
info = next((fi for fi in db.values() if fi.get("id") == file_id), None)
|
||||
info = next((fi for fi in db.values() if fi.get("id") == safe_file_id), None)
|
||||
if info:
|
||||
original_name = info.get("name", file_id)
|
||||
original_name = info.get("name", safe_file_id)
|
||||
file_owner = info.get("owner") if info else None
|
||||
_verify_upload_access(request, file_owner)
|
||||
path = _resolve_upload_path(file_id)
|
||||
upload_root = _upload_root()
|
||||
path = _resolve_upload_path(safe_file_id)
|
||||
try:
|
||||
path = confine(upload_root, path, allow_root=False)
|
||||
except (PathEscape, ValueError):
|
||||
raise HTTPException(403, "Access denied")
|
||||
if not path.startswith(upload_root + os.sep) or not _path_inside_upload_dir(path):
|
||||
raise HTTPException(403, "Access denied")
|
||||
mime = (info or {}).get("mime") or _mt.guess_type(path)[0] or "application/octet-stream"
|
||||
from fastapi.responses import FileResponse
|
||||
# Downscaled thumbnail for image previews — generated once and cached.
|
||||
# Skip generation during HEAD probe to avoid loading/processing whole image.
|
||||
if thumb and mime.startswith("image/"):
|
||||
thumb_dir = os.path.normpath(os.path.join(upload_root, ".thumbs"))
|
||||
thumb_path = os.path.normpath(os.path.join(thumb_dir, safe_file_id + ".jpg"))
|
||||
try:
|
||||
thumb_path = confine(upload_root, thumb_path, allow_root=False)
|
||||
except (PathEscape, ValueError):
|
||||
raise HTTPException(403, "Access denied")
|
||||
if not thumb_path.startswith(thumb_dir + os.sep) or not _path_inside_upload_dir(thumb_path):
|
||||
raise HTTPException(403, "Access denied")
|
||||
if request.method == "HEAD":
|
||||
if (os.path.isfile(thumb_path)
|
||||
and os.path.getmtime(thumb_path) >= os.path.getmtime(path)):
|
||||
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
|
||||
# GET may generate a JPEG or fall back to the original on failure.
|
||||
# Neither representation is known without decoding the image. HEAD
|
||||
# must not advertise the original/stale file's headers as a thumb.
|
||||
from fastapi.responses import Response
|
||||
response = Response(status_code=200, headers=UPLOAD_RESPONSE_HEADERS)
|
||||
del response.headers["content-length"]
|
||||
return response
|
||||
else:
|
||||
try:
|
||||
from PIL import Image, ImageOps
|
||||
thumb_dir = os.path.join(_upload_root(), ".thumbs")
|
||||
os.makedirs(thumb_dir, exist_ok=True)
|
||||
thumb_path = os.path.join(thumb_dir, file_id + ".jpg")
|
||||
if (not os.path.exists(thumb_path)
|
||||
or os.path.getmtime(thumb_path) < os.path.getmtime(path)):
|
||||
im = Image.open(path)
|
||||
@@ -496,9 +541,10 @@ def setup_upload_routes(upload_handler):
|
||||
return next((fi for fi in db.values() if fi.get("id") == file_id), None)
|
||||
|
||||
def _vision_cache_path(file_id: str) -> str:
|
||||
cache_dir = os.path.join(_upload_root(), ".vision")
|
||||
safe_id = secure_filename(os.path.basename(file_id))
|
||||
cache_dir = os.path.normpath(os.path.join(_upload_root(), ".vision"))
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
return os.path.join(cache_dir, file_id + ".txt")
|
||||
return os.path.normpath(os.path.join(cache_dir, safe_id + ".txt"))
|
||||
|
||||
def _sync_gallery_caption_for_upload(info: dict | None, owner: str | None, text: str) -> None:
|
||||
"""Copy upload OCR/vision text onto the promoted gallery image row."""
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Fail image builds that select an unfixed Debian native Brotli decoder.
|
||||
|
||||
Debian Node links libbrotlidec rather than bundling upstream Node's decoder.
|
||||
Use Debian's backport revision, not BrotliDecoderVersion() or Python wheel
|
||||
metadata, to establish the package floor for CVE-2026-111497:
|
||||
https://security-tracker.debian.org/tracker/CVE-2026-111497
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
DEBIAN_FIXED = {"12": "1.0.9-2+deb12u1", "13": "1.1.0-2+deb13u1"}
|
||||
DECODER_SONAME = "libbrotlidec.so.1"
|
||||
|
||||
|
||||
def command(*args):
|
||||
return subprocess.check_output(args, text=True).strip()
|
||||
|
||||
|
||||
def check_package():
|
||||
release = platform.freedesktop_os_release()
|
||||
floor = DEBIAN_FIXED.get(release.get("VERSION_ID"))
|
||||
if release.get("ID") != "debian" or floor is None:
|
||||
raise ValueError("Brotli package floor is not defined for this distribution")
|
||||
record = command(
|
||||
"dpkg-query", "-W", "-f=${Status}\n${Version}", "libbrotli1"
|
||||
).splitlines()
|
||||
if len(record) != 2 or any(not value.strip() for value in record):
|
||||
raise ValueError("invalid libbrotli1 package record: expected status and version")
|
||||
status, version = record
|
||||
if status != "install ok installed":
|
||||
raise ValueError(f"libbrotli1 is not fully installed (status: {status})")
|
||||
if subprocess.run(
|
||||
["dpkg", "--compare-versions", version, "ge", floor], check=False
|
||||
).returncode != 0:
|
||||
raise ValueError(f"libbrotli1 {version} does not meet Debian's fixed floor {floor}")
|
||||
owned = {
|
||||
Path(line).resolve()
|
||||
for line in command("dpkg-query", "-L", "libbrotli1").splitlines()
|
||||
if Path(line).name == DECODER_SONAME
|
||||
}
|
||||
if not owned:
|
||||
raise ValueError("libbrotli1 has no packaged native decoder")
|
||||
return version, owned
|
||||
|
||||
|
||||
def loaded_path(library):
|
||||
# Inspect the library actually selected by the dynamic loader. An installed
|
||||
# fixed package alone would not catch LD_LIBRARY_PATH or /usr/local shadows.
|
||||
class DlInfo(ctypes.Structure):
|
||||
_fields_ = [("filename", ctypes.c_char_p), ("base", ctypes.c_void_p),
|
||||
("symbol", ctypes.c_char_p), ("address", ctypes.c_void_p)]
|
||||
|
||||
dladdr = ctypes.CDLL(None).dladdr
|
||||
dladdr.argtypes = [ctypes.c_void_p, ctypes.POINTER(DlInfo)]
|
||||
dladdr.restype = ctypes.c_int
|
||||
info = DlInfo()
|
||||
if not dladdr(ctypes.cast(library.BrotliDecoderDecompress, ctypes.c_void_p), ctypes.byref(info)):
|
||||
raise ValueError("cannot identify the loaded Brotli decoder")
|
||||
return Path(info.filename.decode()).resolve()
|
||||
|
||||
|
||||
def check_decoder(library):
|
||||
# The fix adds error -17. Older decoders return INVALID here, including
|
||||
# upstream 1.1.0 and 1.2.0. Fixed Debian 12 and 13 backports expose the same
|
||||
# marker with different error-string prefixes.
|
||||
# No malformed attacker-controlled stream is needed for this build check.
|
||||
library.BrotliDecoderErrorString.argtypes = [ctypes.c_int]
|
||||
library.BrotliDecoderErrorString.restype = ctypes.c_char_p
|
||||
if library.BrotliDecoderErrorString(-17) not in (b"BLOCK_SWITCH", b"_ERROR_FORMAT_BLOCK_SWITCH"):
|
||||
raise ValueError("native decoder lacks the CVE-2026-111497 BLOCK_SWITCH fix marker")
|
||||
|
||||
library.BrotliDecoderDecompress.argtypes = [
|
||||
ctypes.c_size_t, ctypes.c_void_p, ctypes.POINTER(ctypes.c_size_t), ctypes.c_void_p
|
||||
]
|
||||
library.BrotliDecoderDecompress.restype = ctypes.c_int
|
||||
# A small, valid uncompressed Brotli stream must still decode correctly.
|
||||
encoded = bytes.fromhex("0b058068656c6c6f20776f726c6403")
|
||||
output = ctypes.create_string_buffer(11)
|
||||
size = ctypes.c_size_t(len(output))
|
||||
result = library.BrotliDecoderDecompress(len(encoded), encoded, ctypes.byref(size), output)
|
||||
if result != 1 or size.value != 11 or output.raw != b"hello world":
|
||||
raise ValueError("native Brotli decoder failed the valid-stream control")
|
||||
|
||||
|
||||
def check_node(node, decoder):
|
||||
# ldd inspects the distro executable and its libnode dependency without
|
||||
# executing JavaScript or relying on process.versions.brotli.
|
||||
linked = command("ldd", str(node))
|
||||
match = re.search(r"libbrotlidec\.so\.1\s+=>\s+(/\S+)", linked)
|
||||
if not match or Path(match[1]).resolve() != decoder:
|
||||
raise ValueError(f"{node} does not select the checked Debian Brotli decoder")
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--node", type=Path, help="also verify this distro Node executable's decoder")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
version, owned = check_package()
|
||||
library = ctypes.CDLL(DECODER_SONAME)
|
||||
decoder = loaded_path(library)
|
||||
if decoder not in owned:
|
||||
raise ValueError(f"selected decoder is outside libbrotli1: {decoder}")
|
||||
check_decoder(library)
|
||||
if args.node:
|
||||
check_node(args.node, decoder)
|
||||
except (ValueError, OSError, AttributeError, subprocess.CalledProcessError) as exc:
|
||||
print(f"Brotli acceptance failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"Brotli accepted: Debian libbrotli1 {version}, decoder {decoder}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -914,6 +914,78 @@ VARIABLE_NOTES: dict[str, tuple[str, str, str]] = {
|
||||
"Overrides the source commit reported for runtime provenance, for builds "
|
||||
"that ship without a git directory.",
|
||||
),
|
||||
|
||||
# -- Photo editor and image operations -----------------------------------
|
||||
"ODYSSEUS_EDITOR_AGENT_BASES": (
|
||||
"Model routing and providers", USER,
|
||||
"Comma-separated base URLs for the image-editor agent backends; a single "
|
||||
"base URL in the model endpoint config wins over this list.",
|
||||
),
|
||||
"ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS": (
|
||||
"Model routing and providers", USER,
|
||||
"Set to 1, true, yes or on to allow local image-generation and editing "
|
||||
"backends to be reachable as image providers.",
|
||||
),
|
||||
"ODYSSEUS_IMAGE_OPS_BASE": (
|
||||
"Model routing and providers", USER,
|
||||
"Base URL of the image-operations backend (inpaint, outpainting and style "
|
||||
"transfer); a trailing slash is stripped before use.",
|
||||
),
|
||||
"ODYSSEUS_IMAGE_EDIT_MAX_SIDE": (
|
||||
"Upload and media limits", USER,
|
||||
"Maximum pixel side (width or height) an edited image is downscaled to "
|
||||
"before it is sent to the image backend.",
|
||||
),
|
||||
"ODYSSEUS_IMAGE_EDIT_MAX_STEPS": (
|
||||
"Upload and media limits", USER,
|
||||
"Maximum number of iterative edit steps the image backend may perform in "
|
||||
"a single request.",
|
||||
),
|
||||
"ODYSSEUS_IMAGE_OUTPAINT_TIMEOUT": (
|
||||
"Upload and media limits", USER,
|
||||
"Seconds the outpainting proxy waits for the image backend before giving "
|
||||
"up on a single request.",
|
||||
),
|
||||
"ODYSSEUS_JEFF_": ("Agent loop and tool execution", INTERNAL, "Prefix for per-job decision adapter opt-ins; each job is disabled by default."),
|
||||
"ODYSSEUS_JEFF_TIMEOUT": ("Agent loop and tool execution", INTERNAL, "Timeout in seconds for a decision adapter request."),
|
||||
"ODYSSEUS_JEFF_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional decision adapter service."),
|
||||
"ODYSSEUS_PRIVATE_BROWSER_LEGACY": ("Agent loop and tool execution", INTERNAL, "Explicit opt-in to the legacy browser compatibility transport; disabled by default."),
|
||||
"ODYSSEUS_SPEC_BROWSER_FALLBACK": ("Agent loop and tool execution", INTERNAL, "Optional browser routing fallback: mcp, web, or 0 to disable."),
|
||||
"ODYSSEUS_SPEC_MODE": ("Agent loop and tool execution", INTERNAL, "Spec routing mode for explicitly configured models, including strict and driver."),
|
||||
"ODYSSEUS_SPEC_ROUTER_MIN_CONF": ("Agent loop and tool execution", INTERNAL, "Minimum tool-selection confidence accepted from the router."),
|
||||
"ODYSSEUS_SPEC_ROUTER_MODEL": ("Agent loop and tool execution", INTERNAL, "Decision adapter model used for tool routing."),
|
||||
"ODYSSEUS_SPEC_ROUTER_SECOND": ("Agent loop and tool execution", INTERNAL, "Minimum confidence for adding a secondary tool candidate."),
|
||||
"ODYSSEUS_SPEC_ROUTER_TIMEOUT": ("Agent loop and tool execution", INTERNAL, "Time budget in seconds for a router decision."),
|
||||
"ODYSSEUS_SPEC_ROUTER_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional Spec router; an empty URL disables requests."),
|
||||
"ODYSSEUS_SPEC_THINK_MIN_P": ("Agent loop and tool execution", INTERNAL, "Minimum router confidence for enabling thinking."),
|
||||
"ODYSSEUS_SPEC_THINK_MODEL": ("Agent loop and tool execution", INTERNAL, "Optional thinking decision adapter model, falling back to the tool router model."),
|
||||
"ODYSSEUS_SPEC_THINK_QUESTION": ("Agent loop and tool execution", INTERNAL, "Thinking classifier question format: binary or kind5."),
|
||||
"ODYSSEUS_SPEC_THINK_ROUTER": ("Agent loop and tool execution", INTERNAL, "Enable router thinking decisions for explicitly configured Spec models."),
|
||||
"ODYSSEUS_SPEC_TOOL_FORMAT": ("Agent loop and tool execution", INTERNAL, "Use names for compact router tool signatures, otherwise include descriptions."),
|
||||
"ODYSSEUS_SPEC_TRIM_PROMPT": ("Agent loop and tool execution", INTERNAL, "Enable prompt trimming for the selected Spec tool menu."),
|
||||
"ODYSSEUS_VISION_MAX_SIDE": ("Upload and media limits", USER, "Maximum pixel side for image attachments sent directly to a vision model."),
|
||||
"ODYSSEUS_VISION_MODELS": ("Model routing and providers", USER, "Comma-separated model-name fragments that explicitly support inline images."),
|
||||
"ODYSSEUS_VISION_PROBE": ("Model routing and providers", USER, "Allow a cached vision-capability probe against local model endpoints."),
|
||||
"ODYSSEUS_SPEC_DIRECT_MODE": (
|
||||
"Agent loop and tool execution", INTERNAL,
|
||||
"Controls how the spec router answers high-confidence direct-answer turns; "
|
||||
"either tool_choice_none (default) or empty_menu.",
|
||||
),
|
||||
"ODYSSEUS_SPEC_DIRECT_NOTOOLS": (
|
||||
"Agent loop and tool execution", INTERNAL,
|
||||
"Set to 1, true, yes or on to enable the spec router's direct-answer "
|
||||
"no-tools path for high-confidence answer_directly decisions.",
|
||||
),
|
||||
"ODYSSEUS_SPEC_DIRECT_MIN_P": (
|
||||
"Agent loop and tool execution", INTERNAL,
|
||||
"Minimum confidence probability a spec-router direct-answer decision must "
|
||||
"reach before the no-tools path is taken.",
|
||||
),
|
||||
"ODYSSEUS_GRACEFUL_SHUTDOWN_TIMEOUT": (
|
||||
"Deployment and first run", INTERNAL,
|
||||
"Seconds the server waits for in-flight requests and background loops to "
|
||||
"finish during a graceful shutdown.",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ that are awkward at the shell). Filters images by tag, prompt, favorite,
|
||||
album, and surfaces EXIF metadata.
|
||||
|
||||
odysseus-gallery list --limit 20 --favorites
|
||||
odysseus-gallery list --tag "sunset,beach" # all tags, manual or AI
|
||||
odysseus-gallery show IMAGE_ID
|
||||
odysseus-gallery albums | jq -r '.[] | .name'
|
||||
odysseus-gallery search "sunset" # matches prompt + tags
|
||||
@@ -83,7 +84,13 @@ def cmd_list(args):
|
||||
fail(f"no album named {args.album!r}")
|
||||
q = q.filter(GalleryImage.album_id == al.id)
|
||||
if args.tag:
|
||||
q = q.filter(GalleryImage.tags.ilike(f"%{args.tag}%"))
|
||||
for tag in (value.strip() for value in args.tag.split(",")):
|
||||
if not tag:
|
||||
continue
|
||||
q = q.filter(
|
||||
GalleryImage.tags.ilike(f"%{tag}%") |
|
||||
GalleryImage.ai_tags.ilike(f"%{tag}%")
|
||||
)
|
||||
q = q.order_by(GalleryImage.created_at.desc()).limit(args.limit)
|
||||
emit([_serialize_image(i) for i in q.all()], args)
|
||||
finally:
|
||||
@@ -159,7 +166,7 @@ def _build_parser():
|
||||
pl.add_argument("--limit", type=int, default=50)
|
||||
pl.add_argument("--favorites", action="store_true")
|
||||
pl.add_argument("--album", help="filter by album name")
|
||||
pl.add_argument("--tag", help="substring match against tags column")
|
||||
pl.add_argument("--tag", help="comma-separated tags; each matches manual or AI tags by substring")
|
||||
pl.set_defaults(func=cmd_list)
|
||||
|
||||
psh = sub.add_parser("show", parents=[common])
|
||||
|
||||
+12
-6
@@ -43,7 +43,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Anchor the path so the script works when invoked from anywhere.
|
||||
@@ -136,7 +136,7 @@ def cmd_list(args) -> None:
|
||||
emit([], args)
|
||||
return
|
||||
all_uids = data[0].split()
|
||||
# Newest first, limit
|
||||
# Keep a bounded arrival batch; order its decoded rows by Date below.
|
||||
uids = list(reversed(all_uids))[: args.limit]
|
||||
out = []
|
||||
for uid in uids:
|
||||
@@ -165,10 +165,15 @@ def cmd_list(args) -> None:
|
||||
sender_name, sender_addr = parseaddr(sender)
|
||||
date_raw = msg.get("Date", "")
|
||||
try:
|
||||
iso = parsedate_to_datetime(date_raw).isoformat() if date_raw else ""
|
||||
parsed_date = parsedate_to_datetime(date_raw) if date_raw else None
|
||||
iso = parsed_date.isoformat() if parsed_date else ""
|
||||
if parsed_date is not None and parsed_date.tzinfo is None:
|
||||
parsed_date = parsed_date.replace(tzinfo=timezone.utc)
|
||||
date_epoch = parsed_date.timestamp() if parsed_date else float("-inf")
|
||||
except Exception:
|
||||
iso = ""
|
||||
out.append({
|
||||
date_epoch = float("-inf")
|
||||
out.append((date_epoch, {
|
||||
"uid": uid.decode(),
|
||||
"date": iso,
|
||||
"from_addr": sender_addr,
|
||||
@@ -176,10 +181,11 @@ def cmd_list(args) -> None:
|
||||
"subject": subject,
|
||||
"is_read": "\\Seen" in flags,
|
||||
"is_answered": "\\Answered" in flags,
|
||||
})
|
||||
}))
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"warn: skipping uid {uid!r}: {e}\n")
|
||||
emit(out, args)
|
||||
out.sort(key=lambda item: item[0], reverse=True)
|
||||
emit([row for _, row in out], args)
|
||||
|
||||
|
||||
# ─── read ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sanitize Playwright trace archives to strip sensitive credentials and tokens.
|
||||
|
||||
Structured, format-aware fail-closed security:
|
||||
Replaces naive regex-based replacement with structured JSON / HAR parsing,
|
||||
recursive key/value redaction, URL credential scrubbing, DOM snapshot sanitization,
|
||||
safe source code masking, and strict fail-closed purge on corruption or error.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
import zipfile
|
||||
from typing import Any, Dict, List, Set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BINARY_IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.ico')
|
||||
|
||||
SENSITIVE_KEY_PATTERN = re.compile(
|
||||
r'(?i)(password|passwd|secret|token|api_key|apikey|access_token|refresh_token|private_key|auth_token|client_secret|session|session_id|sid|csrf|xsrf|jwt|credential|authorization)'
|
||||
)
|
||||
|
||||
SENSITIVE_PARAM_NAMES = frozenset({
|
||||
"token", "session", "auth", "password", "passwd", "key", "secret", "api_key",
|
||||
"apikey", "access_token", "refresh_token", "jwt", "sid", "code", "sig",
|
||||
"signature", "client_secret", "credential"
|
||||
})
|
||||
|
||||
SENSITIVE_HEADER_NAMES = frozenset({
|
||||
"authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key",
|
||||
"api-key", "x-auth-token", "x-access-token"
|
||||
})
|
||||
|
||||
|
||||
def sanitize_url(url: str) -> str:
|
||||
"""Sanitize query parameters and authority credentials in URLs."""
|
||||
if not isinstance(url, str) or not url.strip():
|
||||
return url
|
||||
try:
|
||||
parts = urllib.parse.urlsplit(url)
|
||||
except Exception:
|
||||
return url
|
||||
|
||||
netloc = parts.netloc
|
||||
if "@" in netloc:
|
||||
userinfo, host = netloc.split("@", 1)
|
||||
if ":" in userinfo:
|
||||
user, _ = userinfo.split(":", 1)
|
||||
netloc = f"{user}:[REDACTED]@{host}"
|
||||
else:
|
||||
netloc = f"[REDACTED]@{host}"
|
||||
|
||||
query = parts.query
|
||||
if query:
|
||||
q_pairs = urllib.parse.parse_qsl(query, keep_blank_values=True)
|
||||
sanitized_pairs = []
|
||||
for k, v in q_pairs:
|
||||
k_lower = k.lower().replace("-", "_")
|
||||
if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower):
|
||||
sanitized_pairs.append((k, "[REDACTED]"))
|
||||
else:
|
||||
sanitized_pairs.append((k, v))
|
||||
query = urllib.parse.urlencode(sanitized_pairs)
|
||||
|
||||
return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, query, parts.fragment))
|
||||
|
||||
|
||||
def sanitize_body_text(text: str) -> str:
|
||||
"""Sanitize request bodies formatted as JSON or URL-encoded form data."""
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
return text
|
||||
|
||||
# 1. Try JSON
|
||||
trimmed = text.strip()
|
||||
if (trimmed.startswith("{") and trimmed.endswith("}")) or (trimmed.startswith("[") and trimmed.endswith("]")):
|
||||
try:
|
||||
parsed = json.loads(trimmed)
|
||||
sanitized = sanitize_structured_obj(parsed)
|
||||
return json.dumps(sanitized, ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Try URL-encoded form data
|
||||
if "=" in trimmed and not trimmed.startswith("<"):
|
||||
try:
|
||||
pairs = urllib.parse.parse_qsl(trimmed, keep_blank_values=True)
|
||||
if pairs and any(k.lower() in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k) for k, _ in pairs):
|
||||
out_pairs = []
|
||||
for k, v in pairs:
|
||||
k_lower = k.lower().replace("-", "_")
|
||||
if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower):
|
||||
out_pairs.append((k, "[REDACTED]"))
|
||||
else:
|
||||
out_pairs.append((k, v))
|
||||
return urllib.parse.urlencode(out_pairs)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def sanitize_structured_obj(obj: Any) -> Any:
|
||||
"""Recursively sanitize Python objects decoded from JSON or HAR payloads."""
|
||||
if isinstance(obj, dict):
|
||||
new_dict: Dict[str, Any] = {}
|
||||
|
||||
# Check for header entry with inverted or standard keys: {"name": ..., "value": ...}
|
||||
name_val = obj.get("name")
|
||||
val_val = obj.get("value")
|
||||
if isinstance(name_val, str) and isinstance(val_val, str):
|
||||
name_lower = name_val.lower().strip()
|
||||
if name_lower in SENSITIVE_HEADER_NAMES or SENSITIVE_KEY_PATTERN.search(name_lower):
|
||||
for k, v in obj.items():
|
||||
if k == "value":
|
||||
new_dict[k] = "[REDACTED]"
|
||||
else:
|
||||
new_dict[k] = sanitize_structured_obj(v)
|
||||
return new_dict
|
||||
|
||||
# Process standard dictionary keys
|
||||
for k, v in obj.items():
|
||||
k_lower = str(k).lower().replace("-", "_")
|
||||
|
||||
# Action parameters in Playwright traces (e.g. page.fill, page.type)
|
||||
if k == "params" and isinstance(v, dict):
|
||||
sanitized_params = sanitize_structured_obj(v)
|
||||
# If this action involves fill, type, or sensitive inputs, redact value/text
|
||||
if "value" in sanitized_params:
|
||||
sanitized_params["value"] = "[REDACTED]"
|
||||
if "text" in sanitized_params:
|
||||
sanitized_params["text"] = "[REDACTED]"
|
||||
new_dict[k] = sanitized_params
|
||||
continue
|
||||
|
||||
# DOM snapshot nodes
|
||||
if k == "attributes" and isinstance(v, (dict, list)):
|
||||
if isinstance(v, dict):
|
||||
attr_dict = dict(v)
|
||||
type_val = str(attr_dict.get("type") or "").lower()
|
||||
if type_val == "password" or "password" in str(attr_dict.get("name") or "").lower():
|
||||
if "value" in attr_dict:
|
||||
attr_dict["value"] = "[REDACTED]"
|
||||
new_dict[k] = attr_dict
|
||||
continue
|
||||
elif isinstance(v, list):
|
||||
# List of attribute strings or pairs
|
||||
new_list = []
|
||||
is_pw = any("password" in str(x).lower() for x in v)
|
||||
for x in v:
|
||||
if is_pw and isinstance(x, str) and not x.startswith("type="):
|
||||
new_list.append("[REDACTED]")
|
||||
else:
|
||||
new_list.append(sanitize_structured_obj(x))
|
||||
new_dict[k] = new_list
|
||||
continue
|
||||
|
||||
# Request postData / text
|
||||
if k in ("postData", "postDataText") and isinstance(v, str):
|
||||
new_dict[k] = sanitize_body_text(v)
|
||||
continue
|
||||
|
||||
# URL fields
|
||||
if k == "url" and isinstance(v, str):
|
||||
new_dict[k] = sanitize_url(v)
|
||||
continue
|
||||
|
||||
# Cookie arrays
|
||||
if k == "cookies" and isinstance(v, list):
|
||||
sanitized_cookies = []
|
||||
for c in v:
|
||||
if isinstance(c, dict):
|
||||
c_copy = dict(c)
|
||||
if "value" in c_copy:
|
||||
c_copy["value"] = "[REDACTED]"
|
||||
sanitized_cookies.append(c_copy)
|
||||
else:
|
||||
sanitized_cookies.append(sanitize_structured_obj(c))
|
||||
new_dict[k] = sanitized_cookies
|
||||
continue
|
||||
|
||||
# Direct sensitive key matches
|
||||
if SENSITIVE_KEY_PATTERN.search(k_lower) and isinstance(v, str):
|
||||
new_dict[k] = "[REDACTED]"
|
||||
continue
|
||||
|
||||
new_dict[k] = sanitize_structured_obj(v)
|
||||
|
||||
return new_dict
|
||||
|
||||
elif isinstance(obj, list):
|
||||
return [sanitize_structured_obj(item) for item in obj]
|
||||
|
||||
elif isinstance(obj, str):
|
||||
# Check for inline credentials/tokens
|
||||
if obj.startswith("Bearer ") or obj.startswith("bearer "):
|
||||
return "Bearer [REDACTED]"
|
||||
if obj.startswith("Basic ") or obj.startswith("basic "):
|
||||
return "Basic [REDACTED]"
|
||||
return obj
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
def sanitize_jsonl_content(data: bytes) -> bytes:
|
||||
"""Sanitize newline-delimited JSON Lines (.trace, .network, .stacks)."""
|
||||
lines = data.split(b"\n")
|
||||
out_lines = []
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
out_lines.append(line)
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(line.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
# Not valid JSON, keep as-is
|
||||
out_lines.append(line)
|
||||
continue
|
||||
sanitized = sanitize_structured_obj(parsed)
|
||||
out_lines.append(json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
|
||||
return b"\n".join(out_lines)
|
||||
|
||||
|
||||
def sanitize_trace_archive(zp: str) -> bool:
|
||||
"""Sanitize a single zip archive in a strict fail-closed manner.
|
||||
|
||||
If the archive is malformed, partial, unreadable, or encounters an exception
|
||||
during processing, the archive is permanently deleted so that raw traces
|
||||
cannot leak.
|
||||
Returns True if successfully sanitized, False if deleted due to error.
|
||||
"""
|
||||
if not os.path.exists(zp):
|
||||
return False
|
||||
|
||||
temp_zp = zp + ".tmp"
|
||||
try:
|
||||
if os.path.getsize(zp) == 0:
|
||||
raise zipfile.BadZipFile("Empty archive")
|
||||
|
||||
with zipfile.ZipFile(zp, "r") as zin:
|
||||
infolist = zin.infolist()
|
||||
if not infolist:
|
||||
raise zipfile.BadZipFile("Archive has no entries")
|
||||
|
||||
corrupt_file = zin.testzip()
|
||||
if corrupt_file is not None:
|
||||
raise zipfile.BadZipFile(f"Corrupt entry detected: {corrupt_file}")
|
||||
|
||||
with zipfile.ZipFile(temp_zp, "w", compression=zipfile.ZIP_DEFLATED) as zout:
|
||||
for item in infolist:
|
||||
filename_lower = item.filename.lower()
|
||||
|
||||
# 1. Binary image screenshots/diagnostics: preserve as-is
|
||||
if filename_lower.endswith(BINARY_IMAGE_EXTENSIONS):
|
||||
content = zin.read(item.filename)
|
||||
zout.writestr(item, content)
|
||||
continue
|
||||
|
||||
# 2. Embedded test source code (e.g. resources/src@... or .js/.ts):
|
||||
# Mask safely without syntax corruption!
|
||||
if "src@" in filename_lower or filename_lower.endswith((".js", ".ts", ".mjs", ".py")):
|
||||
safe_placeholder = b"// [SOURCE CODE REDACTED FOR TRACE CONFIDENTIALITY]\n"
|
||||
zout.writestr(item, safe_placeholder)
|
||||
continue
|
||||
|
||||
# 3. JSON Lines and structured JSON/HAR traces
|
||||
content = zin.read(item.filename)
|
||||
if filename_lower.endswith((".trace", ".network", ".stacks")):
|
||||
sanitized_content = sanitize_jsonl_content(content)
|
||||
zout.writestr(item, sanitized_content)
|
||||
elif filename_lower.endswith((".json", ".har")):
|
||||
try:
|
||||
parsed = json.loads(content.decode("utf-8"))
|
||||
sanitized = sanitize_structured_obj(parsed)
|
||||
zout.writestr(item, json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
|
||||
except Exception:
|
||||
# If invalid JSON, treat as text line-by-line
|
||||
zout.writestr(item, sanitize_jsonl_content(content))
|
||||
else:
|
||||
# Unknown binary or text member: exclude to fail closed
|
||||
continue
|
||||
|
||||
# Atomic replace
|
||||
os.replace(temp_zp, zp)
|
||||
return True
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("Sanitizing trace %s failed (%s); removing fail-closed", zp, exc)
|
||||
for path_to_remove in (temp_zp, zp):
|
||||
if os.path.exists(path_to_remove):
|
||||
try:
|
||||
os.remove(path_to_remove)
|
||||
except OSError as remove_err:
|
||||
logger.error("Failed to delete unredacted trace %s: %s", path_to_remove, remove_err)
|
||||
return False
|
||||
|
||||
|
||||
def sanitize_directory(results_dir: str) -> int:
|
||||
"""Walk results_dir and sanitize every .zip archive fail-closed.
|
||||
|
||||
Returns the number of archives processed. Exits with 1 if any archive fails.
|
||||
"""
|
||||
if not os.path.exists(results_dir):
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
all_ok = True
|
||||
for root, _, files in os.walk(results_dir):
|
||||
for f in files:
|
||||
if f.endswith(".zip"):
|
||||
zp = os.path.join(root, f)
|
||||
ok = sanitize_trace_archive(zp)
|
||||
if not ok:
|
||||
all_ok = False
|
||||
count += 1
|
||||
|
||||
if not all_ok:
|
||||
sys.exit(1)
|
||||
return count
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <results_directory>")
|
||||
sys.exit(1)
|
||||
|
||||
target_dir = sys.argv[1]
|
||||
sanitize_directory(target_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -11,6 +11,38 @@ import time
|
||||
from urllib.request import urlopen
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
def _stop_owned_server(server, identity):
|
||||
"""Close only identity-bound descendants, including MCP's new sessions.
|
||||
|
||||
Capture before signalling the parent: an orphaned stderr holder can no
|
||||
longer be attributed by ancestry. Never search by executable name or kill
|
||||
the caller's process group.
|
||||
"""
|
||||
from src import process_lifecycle as lifecycle
|
||||
|
||||
descendants = (lifecycle.bind_descendants([identity.pid])
|
||||
if identity.verdict() == lifecycle.OWNED else [])
|
||||
if os.name == "nt":
|
||||
if identity.verdict() == lifecycle.OWNED:
|
||||
lifecycle.taskkill_tree(identity.pid)
|
||||
else:
|
||||
outcome = lifecycle.terminate_identities(
|
||||
[*(entry.identity for entry in reversed(descendants)), identity],
|
||||
steps=lifecycle.term_kill_steps(10),
|
||||
)
|
||||
if outcome.survivors or outcome.unverified:
|
||||
raise RuntimeError(f"Disposable Notes cleanup could not verify exit: {outcome}")
|
||||
try:
|
||||
logs, _ = server.communicate(timeout=2)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
# Do not turn an unidentified pipe holder into an unbounded wait.
|
||||
server.stdout.close()
|
||||
raise RuntimeError("Disposable Notes output pipe remained open after owned cleanup") from error
|
||||
return logs
|
||||
|
||||
|
||||
def main():
|
||||
@@ -36,7 +68,10 @@ def main():
|
||||
server = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app:app", "--host", "127.0.0.1", "--port", str(port)],
|
||||
cwd=target, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
start_new_session=(os.name != "nt"),
|
||||
)
|
||||
from src.process_lifecycle import ProcessIdentity
|
||||
server_identity = ProcessIdentity.capture(server.pid)
|
||||
result = None
|
||||
try:
|
||||
origin = f"http://127.0.0.1:{port}"
|
||||
@@ -62,12 +97,7 @@ def main():
|
||||
print(result.stderr, file=sys.stderr)
|
||||
return result.returncode
|
||||
finally:
|
||||
server.terminate()
|
||||
try:
|
||||
logs, _ = server.communicate(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
logs, _ = server.communicate()
|
||||
logs = _stop_owned_server(server, server_identity)
|
||||
if result is None or result.returncode:
|
||||
print(logs[-8000:], file=sys.stderr)
|
||||
|
||||
|
||||
@@ -202,17 +202,35 @@ def _infer_use_case(repo_id, pipeline_tag):
|
||||
return "general"
|
||||
|
||||
|
||||
# HF collections mix in models no Cookbook LLM backend can serve (vision
|
||||
# encoders, time-series forecasters, image/video generators). Image models
|
||||
# have their own registry, so keep these out of the LLM catalog.
|
||||
_NON_LLM_PIPELINE_TAGS = frozenset({
|
||||
"time-series-forecasting",
|
||||
"zero-shot-image-classification",
|
||||
"image-classification",
|
||||
"image-feature-extraction",
|
||||
"text-to-image",
|
||||
"image-to-video",
|
||||
"text-to-video",
|
||||
})
|
||||
|
||||
|
||||
def _entry_from_collection_item(collection, item, source):
|
||||
repo_id = item.get("id") or ""
|
||||
if item.get("type") != "model" or not repo_id:
|
||||
return None
|
||||
if (item.get("pipeline_tag") or "").strip().lower() in _NON_LLM_PIPELINE_TAGS:
|
||||
return None
|
||||
repo_prefix = source.get("repo_prefix")
|
||||
if repo_prefix and not repo_id.startswith(repo_prefix):
|
||||
return None
|
||||
raw_params = item.get("numParameters") or 0
|
||||
active = None
|
||||
# Read the name even when HF gives a total: it is the only source of the
|
||||
# MoE active size (e.g. "-35B-A3B"), which the speed estimate depends on.
|
||||
name_params, active = _parse_params_from_name(repo_id)
|
||||
if not raw_params:
|
||||
raw_params, active = _parse_params_from_name(repo_id)
|
||||
raw_params = name_params
|
||||
param_label, raw_params = _format_params(raw_params)
|
||||
if not raw_params:
|
||||
return None
|
||||
|
||||
@@ -15,6 +15,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from src.memory import MemoryStoreUnreadable
|
||||
@@ -78,11 +79,13 @@ EXTRACT_SYSTEM_PROMPT = (
|
||||
"things the assistant said, one-off tasks, opinions on the current topic.\n\n"
|
||||
"Rules:\n"
|
||||
"- MAX 2 facts per conversation — only the most important\n"
|
||||
"- Only extract facts the USER stated or clearly implied\n"
|
||||
"- Only extract facts the USER explicitly stated about themselves\n"
|
||||
"- Questions, requests to inspect records, and tool/assistant output are not evidence\n"
|
||||
"- Each fact must be a single short sentence (under 15 words)\n"
|
||||
"- If a fact is similar to something likely already known, skip it\n"
|
||||
"- If nothing durable was revealed, return []\n\n"
|
||||
"Return a JSON array of objects with 'text' and 'category' fields.\n"
|
||||
"Return a JSON array of objects with 'text', 'category', and 'evidence' fields. "
|
||||
"Evidence must be an exact quote from a user's own factual statement.\n"
|
||||
"Categories: 'identity', 'preference', 'fact', 'contact', 'project', 'goal'\n\n"
|
||||
"Return ONLY valid JSON, no markdown fences."
|
||||
)
|
||||
@@ -213,7 +216,10 @@ def _fallback_memory_candidates(messages) -> list[dict]:
|
||||
if not text:
|
||||
continue
|
||||
|
||||
m = re.search(r"\bmy name is\s+([A-Za-z][A-Za-z0-9 .'\-]{1,50})\b", text, re.I)
|
||||
m = re.search(
|
||||
r"\bmy name is\s+([A-Za-z][A-Za-z0-9 '\-]{0,49}?)"
|
||||
r"(?=\s+(?:and|but|please)\b|[.!?;,\n]|$)", text, re.I,
|
||||
)
|
||||
if m:
|
||||
name = _clean_memory_value(m.group(1), 50)
|
||||
if name:
|
||||
@@ -259,22 +265,183 @@ def _fallback_memory_candidates(messages) -> list[dict]:
|
||||
return candidates[:2]
|
||||
|
||||
|
||||
_USER_FACT_ASSERTION = re.compile(
|
||||
r"\b(?:i(?:'m| am| live| work| use| have| prefer| like| love| hate| own| study| grew up)\b"
|
||||
r"|my\s+[a-z][a-z -]{0,40}\s+(?:is|are|was|works|lives)\b"
|
||||
r"|we\s+(?:are|live|work|have|own)\b"
|
||||
r"|our\s+[a-z][a-z -]{0,40}\s+(?:is|are|was)\b)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _has_user_fact_evidence(fact: dict, messages) -> bool:
|
||||
"""Require an exact user assertion before persisting an LLM-inferred memory."""
|
||||
evidence = fact.get("evidence")
|
||||
if not isinstance(evidence, str):
|
||||
return False
|
||||
evidence = re.sub(r"\s+", " ", evidence).strip()
|
||||
if len(evidence.split()) < 3 or not _USER_FACT_ASSERTION.match(evidence):
|
||||
return False
|
||||
for message in messages:
|
||||
if _message_role(message) != "user":
|
||||
continue
|
||||
utterance = re.sub(r"\s+", " ", _message_text(message)).strip()
|
||||
position = utterance.casefold().find(evidence.casefold())
|
||||
if position < 0:
|
||||
continue
|
||||
# Judge the clause containing the quote, so a later question does not
|
||||
# erase an earlier factual statement in the same user turn.
|
||||
clause_start = max(
|
||||
utterance.rfind(mark, 0, position) for mark in (".", "?", "!", ";")
|
||||
) + 1
|
||||
clause_end = min(
|
||||
[end for mark in (".", "?", "!", ";")
|
||||
if (end := utterance.find(mark, position)) >= 0] or [len(utterance)]
|
||||
)
|
||||
clause = utterance[clause_start:clause_end].strip()
|
||||
# A question about one's records is not a statement of the answer.
|
||||
if utterance[clause_end:clause_end + 1] == "?" or re.match(
|
||||
r"^(?:who|what|where|when|why|how|do|does|did|can|could|would|"
|
||||
r"show|list|find|search|tell|summarize|open|read)\b",
|
||||
clause, re.IGNORECASE,
|
||||
):
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
||||
|
||||
# Function words carry no distinguishing content. Dropping them before
|
||||
# comparing two facts keeps punctuation and harmless rephrasing from defeating
|
||||
# the duplicate check.
|
||||
_STOPWORDS = frozenset(
|
||||
"a an the and or but if then of to in on at for with by from as is are was "
|
||||
"were be been being do does did has have had it its this that these those "
|
||||
"there here user users someone somebody they them their he she his her him i "
|
||||
"we you your my our me us who whom whose what when where why how also "
|
||||
"currently now still about into over under again more most very can could "
|
||||
"may might must will would should".split()
|
||||
)
|
||||
|
||||
# Words that frame a name/username statement; excluded when reading the value
|
||||
# so the actual name tokens can be compared.
|
||||
_NAME_TEMPLATE_WORDS = frozenset(
|
||||
"name names named called call appears appear seems seem the user users his "
|
||||
"her their it is are was were be been being username usernames login "
|
||||
"account handle".split()
|
||||
)
|
||||
|
||||
_NAME_KEYWORDS = frozenset({"name", "named", "called"})
|
||||
_USERNAME_KEYWORDS = frozenset({"username", "login"})
|
||||
|
||||
|
||||
def _content_tokens(text: str) -> set:
|
||||
"""Lowercased content tokens with punctuation and stopwords removed."""
|
||||
toks = _TOKEN_RE.findall((text or "").lower())
|
||||
return {t for t in toks if t not in _STOPWORDS and len(t) >= 2}
|
||||
|
||||
|
||||
def _name_slot(text: str):
|
||||
"""Return (slot, value_tokens) when text states a name/username, else None.
|
||||
|
||||
Tokens are scanned so "username" is never mistaken for "name", and the
|
||||
first keyword decides the slot (a sentence about a server *named* X that
|
||||
also mentions a *username* Y is a 'name' fact, not a username fact)."""
|
||||
words = _TOKEN_RE.findall((text or "").lower())
|
||||
slot = None
|
||||
for w in words:
|
||||
if w in _NAME_KEYWORDS:
|
||||
slot = "name"
|
||||
break
|
||||
if w in _USERNAME_KEYWORDS:
|
||||
slot = "username"
|
||||
break
|
||||
if not slot:
|
||||
return None
|
||||
value = {
|
||||
w for w in words
|
||||
if w not in _NAME_TEMPLATE_WORDS and w not in _STOPWORDS and len(w) >= 2
|
||||
}
|
||||
return (slot, value) if value else None
|
||||
|
||||
|
||||
def _name_slot_tokens(text: str):
|
||||
slot = _name_slot(text)
|
||||
return slot[1] if slot else None
|
||||
|
||||
|
||||
def _name_vals_close(a: set, b: set) -> bool:
|
||||
"""True when two name values state the same name: identical, or one is a
|
||||
strict refinement of the other where the larger value is a clean name
|
||||
(<=4 alphabetic tokens). Guards against merging a username into a fact
|
||||
like 'a server named Odysseus ... with username pewds'."""
|
||||
if a == b:
|
||||
return True
|
||||
if not a or not b:
|
||||
return False
|
||||
bigger = a if len(a) >= len(b) else b
|
||||
clean = len(bigger) <= 4 and all(t.isalpha() for t in bigger)
|
||||
return clean and (a <= b or b <= a)
|
||||
|
||||
|
||||
def _is_text_duplicate(new_text: str, existing: list, threshold: float = 0.6) -> bool:
|
||||
"""Check if new_text is too similar to any existing memory (Jaccard similarity)."""
|
||||
new_tokens = set(new_text.lower().split())
|
||||
"""Check if new_text states the same fact as an existing memory.
|
||||
|
||||
Three tests, cheapest first: symmetric Jaccard over content tokens,
|
||||
containment (one fact is a strict refinement of the other), and a
|
||||
name/username-slot match. The naive whole-string comparison this replaces
|
||||
kept punctuation (so "Alex." != "alex") and missed short-vs-full names
|
||||
("Felix" vs "FELIX ARVID ULF KJELLBERG" scored 0.43).
|
||||
"""
|
||||
new_tokens = _content_tokens(new_text)
|
||||
if not new_tokens:
|
||||
return False
|
||||
new_slot = _name_slot(new_text)
|
||||
for entry in _memory_dicts(existing):
|
||||
old_tokens = set(entry.get("text", "").lower().split())
|
||||
old_text = entry.get("text", "")
|
||||
old_tokens = _content_tokens(old_text)
|
||||
if not old_tokens:
|
||||
continue
|
||||
intersection = new_tokens & old_tokens
|
||||
if not intersection:
|
||||
continue
|
||||
union = new_tokens | old_tokens
|
||||
if len(intersection) / len(union) >= threshold:
|
||||
return True
|
||||
# Containment: one fact is a strict refinement of the other. Guard on
|
||||
# the shorter side so two-token facts ("Likes Python") are not merged
|
||||
# into longer sentences that merely start the same way.
|
||||
if len(intersection) >= 3 and len(intersection) / min(len(new_tokens), len(old_tokens)) >= 0.85:
|
||||
return True
|
||||
# Name slot: both state the same kind of name and the values match
|
||||
# (short vs full name, or the same name behind different phrasing).
|
||||
old_slot = _name_slot(old_text)
|
||||
if new_slot and old_slot and new_slot[0] == old_slot[0]:
|
||||
if _name_vals_close(new_slot[1], old_slot[1]):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _name_enrichment_target(new_text: str, existing: list):
|
||||
"""Return an existing entry whose name value is a strict subset of the
|
||||
new fact's, so the fuller name replaces it in place; else None."""
|
||||
ns = _name_slot(new_text)
|
||||
if not ns:
|
||||
return None
|
||||
slot, new_val = ns
|
||||
best = None
|
||||
for entry in _memory_dicts(existing):
|
||||
old_slot = _name_slot(entry.get("text", ""))
|
||||
if not old_slot or old_slot[0] != slot:
|
||||
continue
|
||||
old_val = old_slot[1]
|
||||
if old_val < new_val and _name_vals_close(old_val, new_val) and len(new_val) <= 4 and all(t.isalpha() for t in new_val):
|
||||
if best is None or len(entry.get("text", "")) < len(best.get("text", "")):
|
||||
best = entry
|
||||
return best
|
||||
|
||||
|
||||
def _parse_extraction_json(raw: str) -> list:
|
||||
"""Parse the extraction LLM's reply into a list of facts, tolerating
|
||||
reasoning-model noise.
|
||||
@@ -420,8 +587,13 @@ async def extract_and_store(
|
||||
if not isinstance(facts, list):
|
||||
facts = []
|
||||
|
||||
# The extraction model sees assistant/tool text and can mistake a
|
||||
# failed lookup or a one-off request for a durable personal fact.
|
||||
# Only its claims with a quoted user assertion may enter memory.
|
||||
facts = [fact for fact in facts if isinstance(fact, dict)
|
||||
and _has_user_fact_evidence(fact, stripped_recent)]
|
||||
if fallback_facts:
|
||||
facts = list(facts) + fallback_facts
|
||||
facts.extend(fallback_facts)
|
||||
|
||||
if not facts:
|
||||
logger.info("Auto memory extraction ran: 0 candidates")
|
||||
@@ -456,6 +628,14 @@ async def extract_and_store(
|
||||
if not fact_text or len(fact_text) < 5:
|
||||
continue
|
||||
|
||||
# A fuller owner-specific name must not rewrite a shared legacy
|
||||
# entry, but that less-specific entry must not suppress it either.
|
||||
legacy_refinements = {
|
||||
id(entry) for entry in existing
|
||||
if _owner and entry.get("owner") is None
|
||||
and _name_enrichment_target(fact_text, [entry]) is entry
|
||||
}
|
||||
|
||||
# Dedup: check vector similarity first (fast), then exact text match.
|
||||
# A runtime embedding/ChromaDB failure (backend OOM, model evicted,
|
||||
# remote endpoint down) must not abort the whole batch — fall through
|
||||
@@ -477,12 +657,38 @@ async def extract_and_store(
|
||||
# silently dropped. Mirror the owner predicate used by the
|
||||
# text dedup below; cross-tenant/stale matches fall through.
|
||||
_match = next((e for e in existing if e.get("id") == existing_id), None)
|
||||
if _match is not None and (_match.get("owner") == _owner or _match.get("owner") is None):
|
||||
if (_match is not None and id(_match) not in legacy_refinements
|
||||
and (_match.get("owner") == _owner or _match.get("owner") is None)):
|
||||
logger.debug(f"Memory dedup (vector): '{fact_text[:50]}' matches {existing_id}")
|
||||
continue
|
||||
|
||||
# Text dedup fallback: exact match + fuzzy similarity
|
||||
user_existing = [e for e in existing if e.get("owner") == _owner or e.get("owner") is None] if _owner else existing
|
||||
user_existing = [e for e in existing
|
||||
if (e.get("owner") == _owner or e.get("owner") is None)
|
||||
and id(e) not in legacy_refinements] if _owner else existing
|
||||
|
||||
# Name enrichment: if an existing name fact is a strict prefix of
|
||||
# this more complete one, upgrade it in place instead of adding a
|
||||
# near-duplicate ("name is Felix" -> "name is Felix Arvid Ulf
|
||||
# Kjellberg"). The vector store upserts by id, so re-adding is safe.
|
||||
# Legacy unowned entries may be visible to multiple owners; never
|
||||
# rewrite one with a private fact from the current owner.
|
||||
_enrich = _name_enrichment_target(
|
||||
fact_text, [e for e in user_existing if e.get("owner") == _owner]
|
||||
)
|
||||
if _enrich is not None:
|
||||
_enrich["text"] = fact_text
|
||||
_enrich["timestamp"] = int(time.time())
|
||||
if category:
|
||||
_enrich["category"] = category
|
||||
if memory_vector and memory_vector.healthy:
|
||||
try:
|
||||
memory_vector.add(_enrich["id"], fact_text)
|
||||
except Exception as e:
|
||||
logger.warning(f"Memory vector update failed for {_enrich['id']}: {e}")
|
||||
added += 1
|
||||
continue
|
||||
|
||||
if memory_manager.find_duplicates(fact_text, user_existing):
|
||||
continue
|
||||
# Fuzzy text similarity check (catches rephrased duplicates when vector index is unavailable)
|
||||
|
||||
@@ -84,10 +84,14 @@ _FM_KEY_RE = re.compile(r"^([a-z_][a-z0-9_]*):\s*(.*)$", re.IGNORECASE)
|
||||
_FM_BLOCK_LIST_RE = re.compile(r"^\s*-\s*(.*)$")
|
||||
|
||||
|
||||
def _parse_scalar(raw: str) -> Any:
|
||||
def _parse_scalar(raw: str, *, string_only: bool = False) -> Any:
|
||||
raw = raw.strip()
|
||||
if raw == "":
|
||||
return ""
|
||||
# String fields keep their spelling; quoted values still use the decoder
|
||||
# below so existing escapes round-trip as before.
|
||||
if string_only and not (raw[0] == raw[-1] and raw[0] in ("'", '"')):
|
||||
return raw
|
||||
if raw.startswith("[") and raw.endswith("]"):
|
||||
inner = raw[1:-1].strip()
|
||||
if not inner:
|
||||
@@ -126,10 +130,15 @@ def _parse_scalar(raw: str) -> Any:
|
||||
def _split_top_level(s: str, sep: str) -> List[str]:
|
||||
"""Split `s` on `sep` ignoring separators inside [] or quotes."""
|
||||
out, buf, depth, quote = [], [], 0, None
|
||||
escaped = False
|
||||
for ch in s:
|
||||
if quote:
|
||||
buf.append(ch)
|
||||
if ch == quote:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif ch == "\\" and quote == '"':
|
||||
escaped = True
|
||||
elif ch == quote:
|
||||
quote = None
|
||||
continue
|
||||
if ch in ("'", '"'):
|
||||
@@ -171,7 +180,7 @@ def parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
|
||||
pending_key = key
|
||||
fm[key] = []
|
||||
else:
|
||||
fm[key] = _parse_scalar(val)
|
||||
fm[key] = _parse_scalar(val, string_only=(key == "version"))
|
||||
pending_key = None
|
||||
continue
|
||||
m2 = _FM_BLOCK_LIST_RE.match(line)
|
||||
|
||||
@@ -500,6 +500,19 @@ class SkillsManager:
|
||||
sk.path = self._skill_file(cat, nm)
|
||||
return sk.to_dict()
|
||||
|
||||
def has_skill(self, name: str, owner: Optional[str] = None) -> bool:
|
||||
"""Whether `owner` already has a skill called `name`, in any category.
|
||||
|
||||
Update and delete find a skill by name within its owner, so two of
|
||||
one owner's skills sharing a name would make them act on whichever
|
||||
file is walked first.
|
||||
"""
|
||||
for path in self._iter_skill_files():
|
||||
sk = self._read_skill(path)
|
||||
if sk and sk.name == name and (sk.owner or "") == (owner or ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_skill(self, skill_id: str, updates: Dict, owner: Optional[str] = None) -> bool:
|
||||
"""`skill_id` is the slug name. Allows updating any field plus
|
||||
renames if `name` changes (file is moved on disk).
|
||||
@@ -551,6 +564,9 @@ class SkillsManager:
|
||||
# Rename
|
||||
new_name = slugify(updates.get("name") or sk.name)
|
||||
if new_name != sk.name:
|
||||
if self.has_skill(new_name, owner=owner):
|
||||
logger.warning(f"Skill rename target name in use: {new_name}")
|
||||
return False
|
||||
sk.name = new_name
|
||||
|
||||
# Write to potentially new path
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""In-flight activity counters (heavy image ops) for safe restarts/observability.
|
||||
|
||||
A deploy restart kills any in-flight long image op (a diffusion edit is 60-120 s),
|
||||
so the operator needs to know whether the process is busy before issuing
|
||||
``systemctl --user restart``. These counters are process-local and cheap.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from typing import Dict
|
||||
|
||||
_lock = threading.Lock()
|
||||
_image_ops = 0
|
||||
_image_ops_by_name: Dict[str, int] = {}
|
||||
_started_at = time.time()
|
||||
|
||||
|
||||
def begin_image_op(name: str = "image") -> None:
|
||||
global _image_ops
|
||||
with _lock:
|
||||
_image_ops += 1
|
||||
_image_ops_by_name[name] = _image_ops_by_name.get(name, 0) + 1
|
||||
|
||||
|
||||
def end_image_op(name: str = "image") -> None:
|
||||
global _image_ops
|
||||
with _lock:
|
||||
_image_ops = max(0, _image_ops - 1)
|
||||
if name in _image_ops_by_name:
|
||||
_image_ops_by_name[name] -= 1
|
||||
if _image_ops_by_name[name] <= 0:
|
||||
_image_ops_by_name.pop(name, None)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def image_op(name: str = "image"):
|
||||
begin_image_op(name)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
end_image_op(name)
|
||||
|
||||
|
||||
def activity() -> Dict[str, object]:
|
||||
with _lock:
|
||||
return {
|
||||
"image_ops": _image_ops,
|
||||
"image_ops_by_name": dict(_image_ops_by_name),
|
||||
"uptime_s": round(time.time() - _started_at, 1),
|
||||
}
|
||||
|
||||
|
||||
def heavy_image_ops_active() -> int:
|
||||
with _lock:
|
||||
return _image_ops
|
||||
|
||||
|
||||
def busy() -> bool:
|
||||
return heavy_image_ops_active() > 0
|
||||
@@ -240,11 +240,13 @@ def _artifact_match_is_callable(instruction: str, match: re.Match[str], path: st
|
||||
|
||||
|
||||
def _artifact_match_is_email_host(instruction: str, match: re.Match[str]) -> bool:
|
||||
"""Reject the domain portion of an email address as an output path."""
|
||||
"""Reject either dotted portion of an email address as an output path."""
|
||||
|
||||
start = match.start("path")
|
||||
prefix = instruction[max(0, start - 80):start]
|
||||
return bool(re.search(r"[A-Za-z0-9_.+-]+@$", prefix))
|
||||
return instruction[match.end("path"):].startswith("@") or bool(
|
||||
re.search(r"[A-Za-z0-9_.+-]+@$", prefix)
|
||||
)
|
||||
|
||||
|
||||
def _workspace_path_identity(value: str) -> str:
|
||||
|
||||
+375
-11
@@ -162,7 +162,7 @@ def _resolved_tool_call_id(
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MODEL_TOOL_SURFACES = {"none", "compact", "full"}
|
||||
_MODEL_TOOL_SURFACES = {"none", "compact", "full", "spec"}
|
||||
_ROUTE_THINKING_MODES = {"auto", "on", "off"}
|
||||
_NO_THINKING_COMPACT_DOMAINS = {
|
||||
"email",
|
||||
@@ -2834,7 +2834,7 @@ def _parse_qwen_explicit_admin_request(text: str) -> Optional[tuple[str, str]]:
|
||||
if re.search(r"\b(?:disabled\s+tools?|tools?\s+(?:are\s+)?(?:currently\s+)?disabled)\b", q):
|
||||
return "manage_settings", json.dumps({"action": "list_tools"})
|
||||
if re.search(r"\b(?:turn|enable|re-enable|reenable)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
|
||||
return "manage_settings", json.dumps({"action": "enable_tool", "tool": "images"})
|
||||
return "manage_settings", json.dumps({"action": "list_tools"})
|
||||
if re.search(r"\b(?:disable|turn\s+off)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
|
||||
return "manage_settings", json.dumps({"action": "disable_tool", "tool": "images"})
|
||||
if listish and re.search(r"\bapi\s+tokens?\b|\btokens?\b", q):
|
||||
@@ -8057,7 +8057,7 @@ Generate an image. Line 1 = description, line 2 = model name, line 3 = WxH (e.g.
|
||||
"manage_tokens": "- ```manage_tokens``` — Generate or revoke API access tokens for external integrations. Args (JSON): {\"action\": \"list|create|delete\", ...}",
|
||||
"manage_documents": "- ```manage_documents``` — List, read/open, delete, or tidy documents in the editor panel. Args (JSON): {\"action\": \"list|read|delete|tidy\", ...}. `list` returns rows like `[Title](#document-<id>) — lang, size, updated 5m ago` sorted MOST-RECENT FIRST; the user clicks the anchor to open. `read` (aliases: view/open/get) takes `document_id` and returns the content. When the user asks \"open/show/read my notes\" or \"what documents do I have\", use this — do NOT shell out, do NOT curl.",
|
||||
"manage_research": "- ```manage_research``` — List, read/open, or delete saved DEEP RESEARCH results from the Library. Args (JSON): {\"action\": \"list|read|delete\", \"id\": \"<id>\", \"search\": \"...\"}. `list` returns rows like `[query](#research-<id>) — N sources` MOST-RECENT FIRST; the user clicks to open. `read` (aliases: open/view/get) takes `id` and returns the report text + sources. Use when the user says \"open/read/find/delete my research\" or \"that report\". This IS how you read a finished report: when the user refers to a just-completed deep-research job (\"check it out\", \"read that report\", \"summarize the research\") WITHOUT giving an id, call `manage_research` with `action:list` to get the most-recent id, then `action:read` with that id, and answer from the returned text. Do NOT `web_fetch`/`app_api` the `/api/research/report/{id}` URL — that endpoint renders HTML for the browser, not clean text — and do NOT start a fresh `web_search`/`trigger_research` just to read an existing report. To START new research, use trigger_research instead.",
|
||||
"manage_settings": "- ```manage_settings``` — View/change the REAL app settings (same ones the Settings panel writes) AND turn tools on/off. Change a setting: `{\"action\":\"set\",\"key\":\"...\",\"value\":\"...\"}` — keys accept friendly aliases, e.g. voice→tts_voice, \"search engine\"→search_provider, \"default model\"→default_model, \"teacher model\"→teacher_model, \"task/background model\"→task_model, \"image quality\"→image_quality, \"reminder channel\"→reminder_channel (browser|email|ntfy), \"agent timeout\"/\"max tool calls\"/\"token budget\". Read: `{\"action\":\"get\",\"key\":\"...\"}`; see all: `{\"action\":\"list\"}`; reset one: `{\"action\":\"reset\",\"key\":\"...\"}`. Use this when the user asks to change ANY preference instead of making them open Settings. Secrets/API keys are read-only (tell them to set those in the panel). Tool toggles: `{\"action\":\"disable_tool|enable_tool\",\"tool\":\"shell\"}` (aliases: shell/search/browser/documents/memory/skills/images/tasks/notes/calendar/email), list disabled: `{\"action\":\"list_tools\"}`.",
|
||||
"manage_settings": "- ```manage_settings``` — View/change the REAL app settings (same ones the Settings panel writes) AND turn tools on/off. Change a setting: `{\"action\":\"set\",\"key\":\"...\",\"value\":\"...\"}` — keys accept friendly aliases, e.g. voice→tts_voice, \"search engine\"→search_provider, \"default model\"→default_model, \"teacher model\"→teacher_model, \"task/background model\"→task_model, \"image quality\"→image_quality, \"reminder channel\"→reminder_channel (browser|email|ntfy), \"agent timeout\"/\"max tool calls\"/\"token budget\". Read: `{\"action\":\"get\",\"key\":\"...\"}`; see all: `{\"action\":\"list\"}`; reset one: `{\"action\":\"reset\",\"key\":\"...\"}`. Use this when the user asks to change ANY preference instead of making them open Settings. Secrets/API keys are read-only (tell them to set those in the panel). Tool toggles: `{\"action\":\"disable_tool\",\"tool\":\"shell\"}` (aliases: shell/search/browser/documents/memory/skills/images/tasks/notes/calendar/email), list disabled: `{\"action\":\"list_tools\"}`. You cannot re-enable a disabled tool: that is done by the user in Settings > Agent Tools.",
|
||||
"manage_notes": """\
|
||||
```manage_notes
|
||||
{"action": "add", "title": "<short todo>", "due_date": "<natural language or ISO datetime>"}
|
||||
@@ -8472,7 +8472,7 @@ def _agent_route_tool_mode(
|
||||
return is_api_model, is_ollama_native, ollama_openai_compat
|
||||
|
||||
|
||||
def _configured_model_tool_surface(
|
||||
def _configured_model_tool_surface_raw(
|
||||
endpoint_url: str,
|
||||
model: str,
|
||||
owner: Optional[str] = None,
|
||||
@@ -8561,6 +8561,19 @@ _ADMIN_KEYWORDS = [
|
||||
"note", "notes", "todo", "todos", "reminder", "reminders",
|
||||
]
|
||||
|
||||
|
||||
def _configured_model_tool_surface(
|
||||
endpoint_url: str,
|
||||
model: str,
|
||||
owner: Optional[str] = None,
|
||||
headers: Optional[Dict] = None,
|
||||
endpoint_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Tool surface as the harness sees it: Ajax Spec runs the compact runtime
|
||||
(same dispatch, prompts, completions); only Spec hooks read the raw mode."""
|
||||
value = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers, endpoint_id=endpoint_id)
|
||||
return "compact" if value == "spec" else value
|
||||
|
||||
def _detect_admin_intent(messages: List[Dict]) -> bool:
|
||||
"""Check if the last user message suggests admin/management tool usage."""
|
||||
for msg in reversed(messages):
|
||||
@@ -13565,6 +13578,20 @@ def _ody_qwen_terminal_tool_summary(tool_event: dict[str, Any], user_text: str =
|
||||
"""Return a deterministic user-facing answer for tools we can render safely."""
|
||||
tool_name = _resolved_tool_event_name(tool_event)
|
||||
output = str(tool_event.get("output") or "")
|
||||
if tool_name in {
|
||||
"mcp__email__list_email_accounts", "mcp__email__list_emails",
|
||||
"mcp__email__search_emails", "mcp__email__read_email",
|
||||
} and output.lstrip().startswith("{"):
|
||||
# The external email bridge wraps stdout in a JSON result. Render its
|
||||
# successful read payload, never the wrapper or a failed result.
|
||||
try:
|
||||
email_result = json.loads(output)
|
||||
except (TypeError, ValueError):
|
||||
email_result = None
|
||||
if isinstance(email_result, dict) and isinstance(email_result.get("stdout"), str):
|
||||
if email_result.get("exit_code") not in (None, 0) or email_result.get("error"):
|
||||
return ""
|
||||
output = email_result["stdout"]
|
||||
command = str(tool_event.get("command") or "")
|
||||
action = ""
|
||||
try:
|
||||
@@ -15204,7 +15231,7 @@ _ADMIN_TOOLS = {
|
||||
"manage_documents", "manage_settings", "create_session", "list_sessions",
|
||||
"send_to_session", "search_chats", "pipeline", "ask_teacher", "list_models",
|
||||
"list_cached_models", "list_downloads", "list_cookbook_servers",
|
||||
"list_serve_presets", "list_served_models",
|
||||
"list_serve_presets", "list_served_models", "tail_serve_output", "search_hf_models",
|
||||
}
|
||||
|
||||
def _build_base_prompt(
|
||||
@@ -21019,9 +21046,179 @@ def _blocks_before_inference(turn_contract) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _spec_unavailable_reply(unavailable) -> str:
|
||||
blocked = set(unavailable or ())
|
||||
if blocked & (set(WEB_TOOL_NAMES) | {"private_browser", "youtube_tool"}):
|
||||
return ("Web or browser access is unavailable for this chat. Enable Web search "
|
||||
"in the chat controls, if available, and ask me to check again. "
|
||||
"I have not opened the page or verified its contents.")
|
||||
return ("The tool needed for this request is unavailable in this chat. "
|
||||
"Enable the required capability, if available, and try again. "
|
||||
"I have not performed the requested lookup or action.")
|
||||
|
||||
|
||||
from src.agent_runtime.authority import MISSING_AUTHORITY, active_request_authority, with_request_authority
|
||||
|
||||
|
||||
|
||||
def _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy):
|
||||
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
|
||||
disabled = set(disabled_tools or ())
|
||||
from src.spec_router import latest_decision
|
||||
_spec_decision = latest_decision() or {}
|
||||
_spec_choice = _spec_decision.get("choice")
|
||||
_spec_direct = (os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
||||
and decided and _spec_choice == "answer_directly")
|
||||
try:
|
||||
_mgr = get_mcp_manager()
|
||||
mcp_schemas = _mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else []
|
||||
except Exception:
|
||||
mcp_schemas = []
|
||||
mcp_names = {(sc.get("function") or {}).get("name") for sc in mcp_schemas}
|
||||
if _spec_choice == "ask_user":
|
||||
# ask_user = a tool is needed but a detail may be missing: offer Spec's top tool too, so the model
|
||||
# can act when the detail is there or ask when it is not.
|
||||
_top = _spec_decision.get("top") or []
|
||||
if _top:
|
||||
picks = set(picks) | {_top[0][0]}
|
||||
picks = {(f"mcp__email__{n}" if f"mcp__email__{n}" in mcp_names else n) for n in picks}
|
||||
strict = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
||||
base_offered = set(turn_contract.offered)
|
||||
# The router sees the full catalog, while this request's policy may
|
||||
# deny a tool (for example, a browser turn with Web access switched
|
||||
# off). Preserve that conflict as an unavailable contract. Keeping the
|
||||
# old empty menu instead lets the model invent a tool result in prose.
|
||||
_top = _spec_decision.get("top") or []
|
||||
_primary = str(_top[0][0]) if _spec_choice and str(_spec_choice).startswith("t") and _top else ""
|
||||
if _primary and f"mcp__email__{_primary}" in mcp_names:
|
||||
_primary = f"mcp__email__{_primary}"
|
||||
# A policy denial is surfaced even if the router chose outside the
|
||||
# base inventory; it never grants access to that tool.
|
||||
def _denied(name):
|
||||
return (name in disabled
|
||||
or (tool_policy is not None and (
|
||||
tool_policy.blocks(name)
|
||||
or (tool_policy.disable_mcp and name.startswith("mcp__")))))
|
||||
_unavailable_pick = ""
|
||||
if _primary in picks and _denied(_primary):
|
||||
_unavailable_pick = _primary
|
||||
elif picks and all(_denied(name) for name in picks):
|
||||
_unavailable_pick = sorted(picks)[0]
|
||||
if _unavailable_pick:
|
||||
blocked = replace(
|
||||
turn_contract, required=frozenset(), offered=frozenset(),
|
||||
unavailable=frozenset({_unavailable_pick}), schema_json=(),
|
||||
required_read_operation=None, routing_experiment="spec-policy-denied",
|
||||
)
|
||||
_spec_active_contract.set(blocked)
|
||||
logger.info("[spec-harness] router pick %s unavailable under request policy", _unavailable_pick)
|
||||
return blocked
|
||||
want = set(picks) - disabled
|
||||
# Always-on basics only when the base turn offers them, so the Spec
|
||||
# arm never offers a tool the base arm would not.
|
||||
if not _spec_direct:
|
||||
want |= {"ask_user", "update_plan"} & base_offered
|
||||
if not (strict and decided):
|
||||
want |= base_offered
|
||||
want &= base_offered
|
||||
if not want:
|
||||
logger.info("[spec-harness] router picks %s outside base offered %s; base contract kept",
|
||||
sorted(picks), sorted(base_offered))
|
||||
return turn_contract
|
||||
offer = want & base_offered & set(turn_contract.executable)
|
||||
# A router may narrow the menu only when every existing obligation
|
||||
# remains satisfiable. It cannot redefine the user's requested work.
|
||||
required_read = getattr(turn_contract, "required_read_operation", None)
|
||||
if (not set(turn_contract.required) <= offer or turn_contract.unavailable
|
||||
or (required_read is not None and required_read.tool not in offer)):
|
||||
return turn_contract
|
||||
by_name = {}
|
||||
for sc in list(FUNCTION_TOOL_SCHEMAS) + list(mcp_schemas):
|
||||
nm = (sc.get("function") or {}).get("name")
|
||||
if nm in offer and nm not in by_name:
|
||||
by_name[nm] = sc
|
||||
for sc in turn_contract.schemas():
|
||||
nm = (sc.get("function") or {}).get("name")
|
||||
if nm in offer:
|
||||
by_name[nm] = sc
|
||||
if not by_name and not _spec_direct:
|
||||
logger.info("[spec-harness] router picks %s not executable this turn; contract kept", sorted(picks))
|
||||
return turn_contract
|
||||
# Required tools and the required read carry over unchanged: harness
|
||||
# completion logic keys on them (e.g. a calendar list is a read, not an
|
||||
# effect that may end with "Done."). If one lost its schema, contract
|
||||
# validation fails and the original contract is retained below.
|
||||
new = replace(
|
||||
turn_contract, offered=frozenset(by_name),
|
||||
schema_json=tuple(json.dumps(by_name[n], ensure_ascii=False, sort_keys=True) for n in sorted(by_name)),
|
||||
routing_experiment="spec-router")
|
||||
_spec_active_contract.set(new)
|
||||
logger.info("[spec-harness] contract replaced by router: offered=%s (was %s, decided=%s)",
|
||||
sorted(by_name), sorted(turn_contract.offered), decided)
|
||||
return new
|
||||
|
||||
|
||||
def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
||||
tool_policy=None):
|
||||
"""Ajax Spec: return a copy of the turn contract whose offered tools are the
|
||||
router's picks (plus ask_user/update_plan), or the contract unchanged.
|
||||
|
||||
Runs before any runtime dispatch so the compact preview runtime and the
|
||||
main loop both serve the router's set; only the offered tools change.
|
||||
"""
|
||||
# Contexts can be inherited by a child task; a turn must never inherit a
|
||||
# previous turn's decision when routing is disabled or unavailable.
|
||||
from src import spec_router as _router_context
|
||||
if hasattr(_router_context, "clear_decision"):
|
||||
_router_context.clear_decision()
|
||||
if (turn_contract is None
|
||||
or getattr(turn_contract, "routing_experiment", "") in {"spec-router", "spec-policy-denied"}
|
||||
or (tool_policy is not None and tool_policy.block_all_tool_calls)):
|
||||
return turn_contract
|
||||
if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() not in {"strict", "additive"}:
|
||||
return turn_contract
|
||||
try:
|
||||
if _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) != "spec":
|
||||
return turn_contract
|
||||
except Exception:
|
||||
return turn_contract
|
||||
try:
|
||||
from src.spec_router import spec_route_tools as _spec_route_tools_early
|
||||
picks, decided = _spec_route_tools_early(
|
||||
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
|
||||
return _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy)
|
||||
except Exception as exc:
|
||||
logger.warning("[spec-harness] router failed; retaining original contract: %s", exc)
|
||||
return turn_contract
|
||||
|
||||
|
||||
async def _spec_router_contract_async(turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
||||
tool_policy=None):
|
||||
"""Asynchronous non-blocking turn contract routing."""
|
||||
from src import spec_router as _router_context
|
||||
if hasattr(_router_context, "clear_decision"):
|
||||
_router_context.clear_decision()
|
||||
if (turn_contract is None
|
||||
or getattr(turn_contract, "routing_experiment", "") in {"spec-router", "spec-policy-denied"}
|
||||
or (tool_policy is not None and tool_policy.block_all_tool_calls)):
|
||||
return turn_contract
|
||||
if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() not in {"strict", "additive"}:
|
||||
return turn_contract
|
||||
try:
|
||||
if _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) != "spec":
|
||||
return turn_contract
|
||||
except Exception:
|
||||
return turn_contract
|
||||
try:
|
||||
from src.spec_router import spec_route_tools_async as _spec_route_tools_early_async
|
||||
picks, decided = await _spec_route_tools_early_async(
|
||||
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
|
||||
return _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy)
|
||||
except Exception as exc:
|
||||
logger.warning("[spec-harness] router failed; retaining original contract: %s", exc)
|
||||
return turn_contract
|
||||
|
||||
|
||||
@with_request_authority
|
||||
@with_turn_contract
|
||||
@with_teacher_takeover
|
||||
@@ -21099,6 +21296,41 @@ async def stream_agent_loop(
|
||||
set(tool_policy.all_disabled_names()) if tool_policy else set()
|
||||
))
|
||||
|
||||
# Ajax Spec driver: the Spec router picks every step (src/spec_driver.py).
|
||||
if (os.getenv("ODYSSEUS_SPEC_MODE", "").strip().lower() == "driver"
|
||||
and not (tool_policy and tool_policy.block_all_tool_calls)
|
||||
and _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"):
|
||||
yield 'data: ' + json.dumps({"delta": "Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed."}) + '\n\n'
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
|
||||
turn_contract = await _spec_router_contract_async(
|
||||
turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
||||
tool_policy=tool_policy)
|
||||
if (turn_contract is not None
|
||||
and getattr(turn_contract, "routing_experiment", "") == "spec-policy-denied"):
|
||||
# A selected but disallowed tool is a policy outcome, not an invitation
|
||||
# to generate an ungrounded answer with an empty tool menu.
|
||||
reply = _spec_unavailable_reply(turn_contract.unavailable)
|
||||
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
|
||||
yield 'data: ' + json.dumps({"delta": reply}) + '\n\n'
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
|
||||
try:
|
||||
_spec_turn = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"
|
||||
except Exception:
|
||||
_spec_turn = False
|
||||
# Without a turn contract (scheduled, skill and monitor runs) no Spec
|
||||
# routing applies; the turn runs on the compact surface as usual.
|
||||
if _spec_turn and _blocks_before_inference(turn_contract):
|
||||
unavailable = ", ".join(sorted(turn_contract.unavailable))
|
||||
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
|
||||
yield 'data: ' + json.dumps({"delta": "I can’t perform this request with the currently permitted tools "
|
||||
f"(unavailable: {unavailable}). I haven’t substituted another tool."}) + '\n\n'
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
|
||||
if is_compact_preview_contract(turn_contract):
|
||||
from src.clean_agent_preview import stream_preview
|
||||
async for chunk in stream_preview(
|
||||
@@ -23961,11 +24193,14 @@ async def stream_agent_loop(
|
||||
budget_is_explicit,
|
||||
hard_max=hard_max,
|
||||
)
|
||||
if candidate_context <= 8192:
|
||||
if 0 < candidate_context <= 8192:
|
||||
# Small local servers often tokenize chat wrappers and tool
|
||||
# results much more generously than our rough estimator. Keep
|
||||
# substantial headroom for those wrappers and generation so a
|
||||
# follow-up tool round cannot exceed the server's n_ctx.
|
||||
# 0 means the window is unknown, which already gets the
|
||||
# conservative default budget; clamping it as well cut every
|
||||
# request to 1200 tokens and dropped open documents (#6678).
|
||||
effective_budget = min(
|
||||
effective_budget,
|
||||
max(1200, int(candidate_context * 0.40)),
|
||||
@@ -24126,6 +24361,15 @@ async def stream_agent_loop(
|
||||
headers=candidate_headers,
|
||||
endpoint_id=(route_descriptor or {}).get("endpoint_id"),
|
||||
)
|
||||
# "spec" = compact tools plus a small-model router; the harness-facing
|
||||
# surface already reads "compact", so detect spec from the raw mode.
|
||||
try:
|
||||
_spec_surface = getattr(turn_contract, "routing_experiment", "") == "spec-router" and _configured_model_tool_surface_raw(
|
||||
candidate_url, candidate_model, owner, headers=candidate_headers,
|
||||
endpoint_id=(route_descriptor or {}).get("endpoint_id")) == "spec"
|
||||
except Exception:
|
||||
_spec_surface = False
|
||||
_spec_offered = None
|
||||
textual_tools = (
|
||||
force_textual_tool_transport
|
||||
or force_textual_tools
|
||||
@@ -24159,6 +24403,57 @@ async def stream_agent_loop(
|
||||
_last_user,
|
||||
_intent_domains,
|
||||
)
|
||||
if _spec_surface:
|
||||
from src.spec_router import spec_route_tools as _spec_route_tools
|
||||
|
||||
if turn_contract is not None and getattr(turn_contract, "routing_experiment", "") == "spec-router":
|
||||
_spec_added, _spec_decided = set(turn_contract.offered) - {"ask_user", "update_plan"}, True
|
||||
else:
|
||||
_spec_added, _spec_decided = _spec_route_tools(
|
||||
_last_user,
|
||||
FUNCTION_TOOL_SCHEMAS,
|
||||
disabled_tools,
|
||||
compacted_source,
|
||||
return_decision=True,
|
||||
)
|
||||
_spec_added = _spec_added - set(disabled_tools)
|
||||
# The router catalog uses the legacy native email names; the
|
||||
# live tools are the MCP ones, and the legacy alias schemas are
|
||||
# dropped when the MCP server is connected. Map picks over.
|
||||
try:
|
||||
from src.tool_utils import get_mcp_manager as _spec_mcp_mgr
|
||||
_mgr = _spec_mcp_mgr()
|
||||
_mcp_names = {
|
||||
(schema.get("function") or {}).get("name")
|
||||
for schema in (_mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else [])
|
||||
}
|
||||
except Exception:
|
||||
_mcp_names = set()
|
||||
_spec_added = {
|
||||
(f"mcp__email__{name}" if f"mcp__email__{name}" in _mcp_names else name)
|
||||
for name in _spec_added
|
||||
}
|
||||
_spec_mode = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower()
|
||||
_compact_before = set(route_tools)
|
||||
if _spec_mode == "strict" and _spec_decided:
|
||||
# The router decides which tools are injected: its pick(s),
|
||||
# tools already used in this chat, and the always-on basics.
|
||||
_spec_strict = (_spec_added | {"ask_user", "update_plan"}) & set(turn_contract.offered) - set(disabled_tools)
|
||||
if _spec_strict:
|
||||
route_tools = _spec_strict
|
||||
_spec_offered = frozenset(route_tools)
|
||||
else:
|
||||
logger.info("[spec-harness] strict set empty after disabled-tool filter; keeping compact set")
|
||||
route_tools = set(route_tools) | _spec_added
|
||||
else:
|
||||
route_tools = set(route_tools) | _spec_added
|
||||
logger.info("[spec-debug] disabled_tools (%d): %s | policy block_all=%s disable_mcp=%s",
|
||||
len(set(disabled_tools)), sorted(set(disabled_tools))[:80],
|
||||
getattr(tool_policy, "block_all_tool_calls", None), getattr(tool_policy, "disable_mcp", None))
|
||||
logger.info(
|
||||
"[spec-harness] mode=%s decided=%s compact=%s final=%s",
|
||||
_spec_mode, _spec_decided, sorted(_compact_before), sorted(route_tools),
|
||||
)
|
||||
# The compact router only receives user text, while native task
|
||||
# inputs may be declared out-of-band by the runner. Reapply that
|
||||
# concrete input contract after compaction so an implicit video
|
||||
@@ -24199,7 +24494,7 @@ async def stream_agent_loop(
|
||||
_irrelevant_local_media_web_tools.add("private_browser")
|
||||
route_tools.difference_update(_irrelevant_local_media_web_tools)
|
||||
if turn_contract is not None and tool_surface != "full":
|
||||
route_tools = set(turn_contract.offered)
|
||||
route_tools = set(_spec_offered) if _spec_offered else set(turn_contract.offered)
|
||||
if prompt_compact:
|
||||
route_tools.update(
|
||||
_blocked_network_recovery_tools(tool_events)
|
||||
@@ -24299,7 +24594,7 @@ async def stream_agent_loop(
|
||||
disabled_tools,
|
||||
needs_admin=_needs_admin,
|
||||
relevant_tools=prompt_route_tools,
|
||||
preserve_conversation=turn_contract is not None,
|
||||
preserve_conversation=(turn_contract is not None) or _spec_surface,
|
||||
mcp_disabled_map=_mcp_disabled_map,
|
||||
compact=prompt_compact,
|
||||
owner=owner,
|
||||
@@ -24511,6 +24806,13 @@ async def stream_agent_loop(
|
||||
_prepend_agent_directive(route_messages, build_active_plan_note(approved_plan))
|
||||
if guide_only:
|
||||
_prepend_agent_directive(route_messages, GUIDE_ONLY_DIRECTIVE)
|
||||
if _spec_offered and os.getenv("ODYSSEUS_SPEC_TRIM_PROMPT", "1") != "0":
|
||||
from src.spec_router import trim_system_prompt as _spec_trim
|
||||
|
||||
route_messages = _spec_trim(route_messages, _spec_offered)
|
||||
from src.spec_router import drop_skills_index as _spec_drop_skills
|
||||
|
||||
route_messages = _spec_drop_skills(route_messages, _spec_offered)
|
||||
return {
|
||||
"messages": route_messages,
|
||||
"mcp_schemas": route_mcp_schemas,
|
||||
@@ -24519,6 +24821,8 @@ async def stream_agent_loop(
|
||||
"is_ollama_native": is_native_ollama,
|
||||
"ollama_openai_compat": is_ollama_compat,
|
||||
"tool_surface": tool_surface,
|
||||
"spec_offered": _spec_offered,
|
||||
"spec_surface": _spec_surface,
|
||||
"ody_qwen_finetune_model": is_ody,
|
||||
"qwen38_tool_router": _is_qwen38_tool_router(candidate_model) and not _full_inventory_mode,
|
||||
"ody_doc_finetune_mode": doc_mode,
|
||||
@@ -25076,6 +25380,15 @@ async def stream_agent_loop(
|
||||
if (schema.get("function", {}).get("name") or schema.get("name"))
|
||||
not in disabled_tools
|
||||
]
|
||||
_spec_names = route_state.get("spec_offered")
|
||||
if _spec_names:
|
||||
# Ajax Spec: the router, not the turn contract, decides which
|
||||
# tools are injected for this turn.
|
||||
contract_schemas = [
|
||||
schema for schema in contract_schemas
|
||||
if (schema.get("function", {}).get("name") or schema.get("name"))
|
||||
in _spec_names
|
||||
]
|
||||
return _filter_route_tool_schemas(_apply_tool_surface_to_schemas(contract_schemas, tool_surface))
|
||||
if route_state["is_api_model"]:
|
||||
if tool_surface == "full":
|
||||
@@ -25137,6 +25450,9 @@ async def stream_agent_loop(
|
||||
and schema.get("name") not in disabled_tools
|
||||
]
|
||||
if _pure_web_turn and tool_surface != "full":
|
||||
strict_spec = (route_state.get("spec_surface") and
|
||||
os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict")
|
||||
if not strict_spec:
|
||||
allowed = _web_only_route_tools(_last_user, disabled_tools)
|
||||
schemas = [
|
||||
schema for schema in schemas
|
||||
@@ -25997,6 +26313,8 @@ async def stream_agent_loop(
|
||||
"is_ollama_native": _is_ollama_native,
|
||||
"ollama_openai_compat": _ollama_openai_compat,
|
||||
"tool_surface": _route_state.get("tool_surface", ""),
|
||||
"spec_offered": _route_state.get("spec_offered"),
|
||||
"spec_surface": _route_state.get("spec_surface"),
|
||||
"ody_qwen_finetune_model": _ody_qwen_finetune_model,
|
||||
"qwen38_tool_router": _qwen38_tool_router,
|
||||
"ody_doc_finetune_mode": _ody_doc_finetune_mode,
|
||||
@@ -26012,10 +26330,19 @@ async def stream_agent_loop(
|
||||
_active_route_state["request_messages"] = _initial_route_request_messages
|
||||
all_tool_schemas = _tool_schemas_for_route(_active_route_state)
|
||||
if turn_contract is not None:
|
||||
_relevant_tools = set(turn_contract.offered)
|
||||
_relevant_tools = (
|
||||
set(_route_state.get("spec_offered"))
|
||||
if _route_state.get("spec_offered")
|
||||
else set(turn_contract.offered)
|
||||
)
|
||||
agent_stream_timeout = int(get_setting("agent_stream_timeout_seconds", 300) or 300)
|
||||
|
||||
_tool_names_sent = [t.get("function", {}).get("name") for t in (all_tool_schemas or []) if t.get("function")]
|
||||
if round_num == 1:
|
||||
try:
|
||||
logger.info("[spec-debug] prompt composition (chars): %s", [(m.get("role"), len(str(m.get("content") or ""))) for m in messages])
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"[agent-debug] round={round_num} model={model} _is_api_model={_is_api_model} tools_sent={len(_tool_names_sent)} tool_names={_tool_names_sent} relevant_tools={sorted(_relevant_tools)[:50] if _relevant_tools else 'ALL'}")
|
||||
_routing_intentional_exclusions = set(disabled_tools)
|
||||
if (
|
||||
@@ -27509,7 +27836,16 @@ async def stream_agent_loop(
|
||||
if _streamed_tool_markup or _streamed_tool_markup_starts(_delta_text):
|
||||
_streamed_tool_markup += _delta_text
|
||||
if not _streamed_tool_markup_complete(_streamed_tool_markup):
|
||||
if _streamed_tool_markup_starts(_streamed_tool_markup):
|
||||
continue
|
||||
# False alarm: a bare "<" (e.g. ``x < 2``) looked like
|
||||
# a tag prefix, but the buffer can no longer become a
|
||||
# tool tag. Release it as prose instead of holding the
|
||||
# rest of the answer forever.
|
||||
_delta_text = _streamed_tool_markup
|
||||
_streamed_tool_markup = ""
|
||||
data["delta"] = _delta_text
|
||||
else:
|
||||
_visible_markup_tail = strip_tool_blocks(
|
||||
_streamed_tool_markup,
|
||||
skip_fenced=True,
|
||||
@@ -27605,7 +27941,11 @@ async def stream_agent_loop(
|
||||
),
|
||||
active_document=active_document,
|
||||
last_user=_last_user,
|
||||
offered_tool_names=(set(turn_contract.offered) if turn_contract is not None else set(_tool_names_sent)),
|
||||
offered_tool_names=(
|
||||
set(turn_contract.offered)
|
||||
if turn_contract is not None and not _active_route_state.get("spec_offered")
|
||||
else set(_tool_names_sent)
|
||||
),
|
||||
recover_unoffered_tool_names=_recover_unoffered_local_tools,
|
||||
passthrough_tool_names={
|
||||
schema["function"]["name"]
|
||||
@@ -32169,6 +32509,29 @@ async def stream_agent_loop(
|
||||
budget_hit = False
|
||||
local_network_budget_hit = False
|
||||
local_inspection_budget_hit = False
|
||||
if _active_route_state.get("spec_offered") and os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" and tool_blocks and not used_native and _is_api_model:
|
||||
# Ajax Spec with a native-API model: a round without a native call
|
||||
# carries only harness-synthesized recovery blocks. Drop them all.
|
||||
logger.info("[spec-harness] dropped %d synthesized tool block(s) (no native call): %s",
|
||||
len(tool_blocks), [b.tool_type for b in tool_blocks])
|
||||
tool_blocks = []; converted_calls = []
|
||||
if (
|
||||
_active_route_state.get("spec_offered") and os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" and used_native and tool_blocks
|
||||
and len(converted_calls) == len(tool_blocks)
|
||||
):
|
||||
# Ajax Spec: only the model's own native calls run; harness-
|
||||
# synthesized recovery blocks (no native call) are dropped. When
|
||||
# converted_calls was reset (singleton collapse) keep every block.
|
||||
_spec_keep = [
|
||||
i for i in range(len(tool_blocks))
|
||||
if i < len(converted_calls) and converted_calls[i] is not None
|
||||
]
|
||||
if len(_spec_keep) != len(tool_blocks):
|
||||
logger.info("[spec-harness] dropped %d synthesized tool block(s): %s",
|
||||
len(tool_blocks) - len(_spec_keep),
|
||||
[tool_blocks[i].tool_type for i in range(len(tool_blocks)) if i not in _spec_keep])
|
||||
tool_blocks = [tool_blocks[i] for i in _spec_keep]
|
||||
converted_calls = [converted_calls[i] for i in _spec_keep]
|
||||
for i, block in enumerate(tool_blocks):
|
||||
native_call = converted_calls[i] if i < len(converted_calls) else None
|
||||
tool_call_id = _resolved_tool_call_id(
|
||||
@@ -33463,7 +33826,8 @@ async def stream_agent_loop(
|
||||
),
|
||||
}
|
||||
if (
|
||||
(_qwen38_tool_router or _full_inventory_mode) and (_pure_web_turn or _contextual_public_web_followup)
|
||||
not (_active_route_state.get("spec_offered") and os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" and block.tool_type == "private_browser")
|
||||
and (_qwen38_tool_router or _full_inventory_mode) and (_pure_web_turn or _contextual_public_web_followup)
|
||||
and not _artifact_creation_requested
|
||||
and (block.tool_type == "web_search" or _web_execution_budget.searches)
|
||||
and not _web_execution_budget.admit(
|
||||
|
||||
@@ -7,6 +7,7 @@ from dataclasses import dataclass, replace
|
||||
from functools import wraps
|
||||
from inspect import signature
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from uuid import uuid4
|
||||
@@ -140,9 +141,18 @@ class RequestAuthority:
|
||||
raise ValueError("Malformed request authority")
|
||||
if self.resource_roots is None:
|
||||
roots = ()
|
||||
if self.workspace:
|
||||
# With no workspace, bash/python run in the agent workspace
|
||||
# (agent_cwd), so the file tools get that same root (#6679).
|
||||
# Imported here: authority -> execution is a new edge, and
|
||||
# tool_execution imports this module at load time.
|
||||
from src import tool_execution
|
||||
try:
|
||||
roots = (FilesystemRoot.seal(self.workspace, owner=self.owner),)
|
||||
root = self.workspace
|
||||
if not root:
|
||||
# Vetted like agent_cwd(): a symlinked agent workspace
|
||||
# raises and so grants no root.
|
||||
root = tool_execution.default_agent_workspace()
|
||||
roots = (FilesystemRoot.seal(root, owner=self.owner),)
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
pass # An unresolved workspace grants no filesystem root.
|
||||
object.__setattr__(self, "resource_roots", roots)
|
||||
|
||||
@@ -230,6 +230,18 @@ def classify(capture: DispatchCapture) -> dict[str, Any] | None:
|
||||
if capture.backend is not None:
|
||||
from src.agent_runtime.resources import ExternalResource
|
||||
if isinstance(capture.backend.resource, ExternalResource):
|
||||
# A read through an external backend is still a read. The tool
|
||||
# capability registry is sealed by the dispatcher, and unknown or
|
||||
# mixed-effect calls continue to receive an external effect claim.
|
||||
if all(bound is None for bound in (capture.browser, capture.filesystem,
|
||||
capture.process, capture.owned)):
|
||||
from src.tool_capabilities import ToolEffect, capabilities_for_action
|
||||
capabilities = capabilities_for_action(capture.backend.transport_tool,
|
||||
capture.backend.exact_input)
|
||||
reads = frozenset({ToolEffect.READ_PUBLIC, ToolEffect.READ_WORKSPACE,
|
||||
ToolEffect.READ_PRIVATE, ToolEffect.BROKERED_NETWORK_READ})
|
||||
if capabilities.known and capabilities.effects and capabilities.effects <= reads:
|
||||
return None
|
||||
external = True
|
||||
impact = (*impact, resource_ref(capture.backend.resource, "backend"))
|
||||
return {"impact_scope": impact, "dependencies": dependencies, "obligations": obligations, "external": external}
|
||||
|
||||
@@ -29,6 +29,7 @@ from .interaction_tools import AskUserTool, UpdatePlanTool
|
||||
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
|
||||
from .bg_job_tools import ManageBgJobsTool
|
||||
from .session_tools import CreateSessionTool, ListSessionsTool, SendToSessionTool, ManageSessionTool
|
||||
from .editor_tools import PlanEditTool, RunEditorCommandTool, GetEditorScreenshotTool, AiEditImageTool
|
||||
from .admin_tools import (
|
||||
ADMIN_TOOL_HANDLERS,
|
||||
do_manage_endpoints, do_manage_mcp, do_manage_webhooks,
|
||||
@@ -72,6 +73,10 @@ TOOL_HANDLERS = {
|
||||
"list_sessions": ListSessionsTool().execute,
|
||||
"send_to_session": SendToSessionTool().execute,
|
||||
"manage_session": ManageSessionTool().execute,
|
||||
"plan_edit": PlanEditTool().execute,
|
||||
"run_editor_command": RunEditorCommandTool().execute,
|
||||
"get_editor_screenshot": GetEditorScreenshotTool().execute,
|
||||
"ai_edit_image": AiEditImageTool().execute,
|
||||
}
|
||||
# Config/integration admin tools (manage_endpoints/mcp/webhooks/tokens/settings).
|
||||
TOOL_HANDLERS.update(ADMIN_TOOL_HANDLERS)
|
||||
|
||||
@@ -585,7 +585,9 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
||||
return _ALIASES_SET.get(k2, (k or "").strip())
|
||||
|
||||
def _is_managed_key(key):
|
||||
return key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
|
||||
# Tool policy is not a general setting, even if a future default
|
||||
# is added: resetting it must never re-enable disabled tools.
|
||||
return key != "disabled_tools" and key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
|
||||
|
||||
_ENUMS = {
|
||||
"image_quality": ["low", "medium", "high"],
|
||||
@@ -713,13 +715,35 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
||||
save_settings(s)
|
||||
return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0}
|
||||
|
||||
elif action in ("disable_tool", "enable_tool", "list_tools"):
|
||||
elif action == "enable_tool":
|
||||
# The global disabled_tools denylist is the one hard permission
|
||||
# boundary the Settings -> Agent Tools panel offers admins. It
|
||||
# must only ever be *narrowed* from inside an agent turn: a
|
||||
# model that can call enable_tool can undo the boundary itself,
|
||||
# and any web page, email, or document the agent reads can ask
|
||||
# it to (prompt injection re-enabling bash/email/etc.). Widening
|
||||
# the tool set stays a human action in the Settings panel.
|
||||
tool_name = (args.get("tool") or args.get("name") or "").strip().lower()
|
||||
return {
|
||||
"error": (
|
||||
f"Re-enabling tools is not available from chat"
|
||||
f"{f' (requested: {tool_name})' if tool_name else ''}. "
|
||||
"Disabled tools are an admin policy: ask the user to turn the tool "
|
||||
"back on in Settings > Agent Tools. You can still disable tools "
|
||||
"(disable_tool) or list the current state (list_tools)."
|
||||
),
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
elif action in ("disable_tool", "list_tools"):
|
||||
# Tool-toggle actions. These edit settings.json:disabled_tools
|
||||
# (the global list read on every chat request) rather than
|
||||
# prefs.json. Friendly aliases accepted: "shell" -> "bash",
|
||||
# "search" -> "web_search", "browser" -> browser tools,
|
||||
# "documents" -> the document tool set, "memory" ->
|
||||
# manage_memory, etc.
|
||||
# manage_memory, etc. Only disable_tool mutates: see the
|
||||
# enable_tool branch above for why the agent can never widen
|
||||
# the denylist.
|
||||
from src.settings import get_setting, save_settings, load_settings
|
||||
_ALIASES = {
|
||||
"shell": ["bash"],
|
||||
@@ -753,7 +777,8 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"response": (
|
||||
f"Currently disabled: {', '.join(current) if current else '(none)'}.\n"
|
||||
"Common toggles: shell (bash), search (web_search), browser, documents, "
|
||||
"memory, skills, images, tasks, notes, calendar, email."
|
||||
"memory, skills, images, tasks, notes, calendar, email. "
|
||||
"To re-enable a tool, open Settings > Agent Tools."
|
||||
),
|
||||
"disabled": list(current),
|
||||
"exit_code": 0,
|
||||
@@ -767,22 +792,19 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
||||
settings = load_settings()
|
||||
current = list(settings.get("disabled_tools") or [])
|
||||
before = set(current)
|
||||
if action == "disable_tool":
|
||||
for t in targets:
|
||||
if t not in current:
|
||||
current.append(t)
|
||||
else: # enable_tool
|
||||
current = [t for t in current if t not in targets]
|
||||
after = set(current)
|
||||
settings["disabled_tools"] = current
|
||||
save_settings(settings)
|
||||
|
||||
verb = "Disabled" if action == "disable_tool" else "Enabled"
|
||||
changed = sorted(after.symmetric_difference(before))
|
||||
return {
|
||||
"response": (
|
||||
f"{verb} {tool_name} ({', '.join(targets)}). "
|
||||
f"Now disabled: {', '.join(current) if current else '(none)'}."
|
||||
f"Disabled {tool_name} ({', '.join(targets)}). "
|
||||
f"Now disabled: {', '.join(current) if current else '(none)'}. "
|
||||
"Re-enabling is done by the user in Settings > Agent Tools."
|
||||
),
|
||||
"changed": changed,
|
||||
"disabled": list(current),
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
"""In-process agent tools for the scoped ``photo_editor`` capability family.
|
||||
|
||||
These handlers run as the logged-in owner and drive the image editor over the
|
||||
owner-scoped in-process bridge (``/api/editor/bridge/*``) plus the app's
|
||||
existing image transforms (``/api/image/*``). They are only offered to the
|
||||
model when the ``photo_editor`` family is active (the Quick Edit popup or an
|
||||
explicit photo-edit chat turn), never in the general tool list.
|
||||
|
||||
Loopback calls carry the internal token and impersonate ``owner`` (middleware
|
||||
app.py), so the editor bridge resolves the same ``owner:session`` scope the
|
||||
browser subscriber uses.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from src.tools._common import _INTERNAL_BASE, _internal_headers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _provider_message() -> str:
|
||||
try:
|
||||
from src.ai_interaction import IMAGE_PROVIDER_REQUIRED_MESSAGE
|
||||
|
||||
return IMAGE_PROVIDER_REQUIRED_MESSAGE
|
||||
except Exception:
|
||||
return (
|
||||
"Removing/adding objects needs an image generation provider; enable one "
|
||||
"in Settings > Models."
|
||||
)
|
||||
|
||||
|
||||
def _provider_available(owner: Optional[str]) -> bool:
|
||||
try:
|
||||
from src.ai_interaction import image_editing_provider_available
|
||||
|
||||
return bool(image_editing_provider_available(owner))
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
# Local diffusion/image servers can take 90-110s per image (8 steps) and
|
||||
# longer on a cold start; the previous 120s default timed out mid-generation.
|
||||
_EDITOR_TIMEOUT = 300.0
|
||||
# A mask covering less than this fraction of the image cannot drive a useful
|
||||
# regional inpaint (tiny figures, empty SAM output): fall back to grounding or
|
||||
# whole-image instruction mode instead.
|
||||
_MIN_MASK_FRACTION = 0.002
|
||||
_AI_OPS = {
|
||||
"remove-bg": "/api/image/remove-bg",
|
||||
"upscale": "/api/image/upscale-local",
|
||||
"denoise": "/api/image/denoise",
|
||||
"enhance-face": "/api/image/enhance-face",
|
||||
"inpaint": "/api/image/inpaint",
|
||||
"generative": "/api/image/inpaint",
|
||||
}
|
||||
_PLACE_OP = "layers.placeImage"
|
||||
|
||||
|
||||
def _parse(content: Any) -> Dict[str, Any]:
|
||||
raw = content if isinstance(content, str) else json.dumps(content or {})
|
||||
try:
|
||||
parsed = json.loads(raw) if raw.strip() else {}
|
||||
except (ValueError, TypeError):
|
||||
parsed = {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def _session(args: Dict[str, Any], ctx: Dict[str, Any]) -> str:
|
||||
# Explicit editor session bound in context is authoritative;
|
||||
# otherwise fallback to caller/tool args, then default.
|
||||
# Note: ctx.get("session_id") is the chat/turn UUID, NOT the editor canvas session.
|
||||
return str(ctx.get("editor_session") or args.get("session") or args.get("editor_session") or "default")
|
||||
|
||||
|
||||
def _incognito(ctx: Dict[str, Any]) -> bool:
|
||||
runtime = ctx.get("client_runtime_context")
|
||||
state = runtime.get("web_ui_state") if isinstance(runtime, dict) else None
|
||||
return ctx.get("incognito") is True or (isinstance(state, dict) and state.get("incognito") is True)
|
||||
|
||||
|
||||
async def _post(path: str, payload: Dict[str, Any], owner: Optional[str], timeout: float = _EDITOR_TIMEOUT) -> Dict[str, Any]:
|
||||
headers = {**_internal_headers(owner), "Content-Type": "application/json"}
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(f"{_INTERNAL_BASE}{path}", json=payload, headers=headers)
|
||||
resp.raise_for_status()
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError:
|
||||
return {"raw": resp.text}
|
||||
|
||||
|
||||
def _as_data_url(value: Any) -> Optional[str]:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
if value.startswith("data:"):
|
||||
return value
|
||||
if value.startswith("/"):
|
||||
return None
|
||||
return f"data:image/png;base64,{value}"
|
||||
|
||||
|
||||
def _data_url_bytes(data_url: str) -> bytes:
|
||||
_, _, b64 = data_url.partition(",")
|
||||
return base64.b64decode(b64)
|
||||
|
||||
|
||||
def _image_reference_path(value: str) -> bool:
|
||||
# Never use the internal tool credential to fetch an arbitrary app route.
|
||||
generated = re.fullmatch(r"/api/generated-image/[a-f0-9]{8,64}\.(?:png|jpg|jpeg|webp|gif)", value)
|
||||
upload = re.fullmatch(r"/api/upload/[a-fA-F0-9]{32}(?:\.[A-Za-z0-9]+)?", value)
|
||||
return bool(generated or upload)
|
||||
|
||||
|
||||
def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[str], prompt: str, model: str) -> Dict[str, Any]:
|
||||
"""Save an edited/generated PNG and return {image_id, image_url, image_size}."""
|
||||
from core.database import GalleryImage, SessionLocal
|
||||
from src.constants import GENERATED_IMAGES_DIR
|
||||
|
||||
db = SessionLocal()
|
||||
image_path = None
|
||||
try:
|
||||
root = Path(GENERATED_IMAGES_DIR).resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||
image_path = root / filename
|
||||
image_path.write_bytes(image_bytes)
|
||||
new_id = str(uuid.uuid4())
|
||||
width = height = 0
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(io.BytesIO(image_bytes)) as img:
|
||||
width, height = img.size
|
||||
except Exception:
|
||||
pass
|
||||
size = f"{width}x{height}" if width and height else ""
|
||||
# The editor bridge uses an owner-scoped session id (e.g. "pewds:3")
|
||||
# that is not a chat-sessions row. GalleryImage.session_id is a FK to
|
||||
# sessions.id, so drop an unknown id instead of raising IntegrityError.
|
||||
if session_id:
|
||||
from core.database import Session as _ChatSession
|
||||
|
||||
try:
|
||||
known = db.query(_ChatSession.id).filter(_ChatSession.id == session_id).first()
|
||||
except Exception:
|
||||
known = None
|
||||
if known is None:
|
||||
session_id = None
|
||||
derived = GalleryImage(
|
||||
id=new_id,
|
||||
filename=filename,
|
||||
prompt=prompt or None,
|
||||
caption=None,
|
||||
model=model,
|
||||
size=size or None,
|
||||
quality=None,
|
||||
tags=None,
|
||||
ai_tags=None,
|
||||
session_id=session_id,
|
||||
album_id=None,
|
||||
owner=owner,
|
||||
file_hash=hashlib.sha256(image_bytes).hexdigest(),
|
||||
file_size=len(image_bytes),
|
||||
width=width or None,
|
||||
height=height or None,
|
||||
)
|
||||
db.add(derived)
|
||||
db.commit()
|
||||
return {"image_id": new_id, "image_url": f"/api/generated-image/{filename}", "image_size": size or None}
|
||||
except Exception:
|
||||
db.rollback()
|
||||
if image_path is not None:
|
||||
try:
|
||||
image_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
logger.warning("Failed to remove uncommitted editor image", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def _place_layer(data_url: str, name: str, owner: Optional[str], session: str, *, masked: bool = False, blend: str = "normal", opacity: float = 100.0, mode: Optional[str] = None, native_size: bool = False) -> bool:
|
||||
params: Dict[str, Any] = {
|
||||
"dataUrl": data_url,
|
||||
"name": name,
|
||||
"masked": bool(masked),
|
||||
"blend": blend or "normal",
|
||||
"opacity": opacity,
|
||||
}
|
||||
if native_size:
|
||||
params["nativeSize"] = True
|
||||
# Forward the edit mode so the client can build a difference mask for
|
||||
# instruction-mode results: the whole image was regenerated under an
|
||||
# instruction prompt, so untouched pixels must stay pixel-identical (the
|
||||
# client keeps only the changed region) instead of the new layer overwriting
|
||||
# the entire canvas. Omit the keys entirely when no mode is given so the
|
||||
# client keeps its current behavior (e.g. generate_image must not diff-mask).
|
||||
if mode:
|
||||
params["mode"] = mode
|
||||
params["differenceMask"] = mode == "instruction"
|
||||
if mode == "instruction":
|
||||
# Exclude a thin edge band from the client difference mask: full-frame
|
||||
# regenerations can shift pixels along the border.
|
||||
params["edgeExcludePx"] = 3
|
||||
payload = {
|
||||
"session": session,
|
||||
"op": _PLACE_OP,
|
||||
"params": params,
|
||||
"wait": True,
|
||||
"timeout": 60,
|
||||
}
|
||||
try:
|
||||
data = await _post("/api/editor/bridge/command", payload, owner, timeout=90)
|
||||
return bool(data.get("ok") is True and data.get("completed", True) is not False)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning("editor layer placement failed: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
async def _fetch_selection_mask(owner: Optional[str], session: str) -> Optional[str]:
|
||||
"""Return the editor's current selection as a data-URL mask, or None.
|
||||
|
||||
Generative ops (inpaint/generative) need a mask; the planner/judge may not
|
||||
have supplied one, so fall back to whatever is selected in the editor.
|
||||
"""
|
||||
payload = {
|
||||
"session": session,
|
||||
"op": "selection.exportMask",
|
||||
"params": {},
|
||||
"wait": True,
|
||||
"timeout": 30,
|
||||
}
|
||||
try:
|
||||
data = await _post("/api/editor/bridge/command", payload, owner, timeout=45)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning("editor selection mask fetch failed: %s", exc)
|
||||
return None
|
||||
result = data.get("result") if isinstance(data, dict) else None
|
||||
if isinstance(result, dict):
|
||||
return _as_data_url(result.get("mask"))
|
||||
return None
|
||||
|
||||
|
||||
def _mask_white_fraction(data_url: Optional[str]) -> float:
|
||||
"""Fraction of a grayscale mask that is selected (white, >=128)."""
|
||||
if not data_url:
|
||||
return 0.0
|
||||
try:
|
||||
from PIL import Image as _PILImage
|
||||
|
||||
with _PILImage.open(io.BytesIO(_data_url_bytes(data_url))).convert("L") as im:
|
||||
total = im.width * im.height
|
||||
if not total:
|
||||
return 0.0
|
||||
hist = im.histogram()
|
||||
return sum(hist[128:]) / total
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _image_is_blank(data_url: str) -> bool:
|
||||
"""True when a result has an alpha channel that is essentially transparent."""
|
||||
try:
|
||||
from PIL import Image as _PILImage
|
||||
|
||||
with _PILImage.open(io.BytesIO(_data_url_bytes(data_url))) as im:
|
||||
if im.mode not in ("RGBA", "LA") and "transparency" not in im.info:
|
||||
return False
|
||||
alpha = im.convert("RGBA").getchannel("A")
|
||||
hist = alpha.histogram()
|
||||
total = sum(hist)
|
||||
return total > 0 and (sum(hist[1:]) / total) < 0.001
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _rect_mask_data_url(image_data_url: str, box: List[float], *, pad_frac: float = 0.15) -> Optional[str]:
|
||||
"""Build a rectangular mask data URL from a [x1,y1,x2,y2] box, dilated.
|
||||
|
||||
Used when the grounding detector finds an object but SAM returns an empty
|
||||
mask (tiny figures): a dilated box guarantees a usable region.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image as _PILImage, ImageDraw
|
||||
|
||||
with _PILImage.open(io.BytesIO(_data_url_bytes(image_data_url))) as im:
|
||||
w, h = im.size
|
||||
x1, y1, x2, y2 = (float(v) for v in box)
|
||||
bw, bh = x2 - x1, y2 - y1
|
||||
x1, y1 = max(0.0, x1 - bw * pad_frac), max(0.0, y1 - bh * pad_frac)
|
||||
x2, y2 = min(float(w), x2 + bw * pad_frac), min(float(h), y2 + bh * pad_frac)
|
||||
mask = _PILImage.new("L", (w, h), 0)
|
||||
ImageDraw.Draw(mask).rectangle([x1, y1, x2, y2], fill=255)
|
||||
buf = io.BytesIO()
|
||||
mask.save(buf, "PNG")
|
||||
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _ground_box(owner: Optional[str], image_data_url: str, label: str) -> Optional[List[float]]:
|
||||
"""Use the in-app grounding detector (OWL-ViT via /api/image/mask) for a box."""
|
||||
try:
|
||||
data = await _post("/api/image/mask", {"image": image_data_url, "text": label}, owner, timeout=90)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning("editor grounding box failed: %s", exc)
|
||||
return None
|
||||
box = data.get("bbox") if isinstance(data, dict) else None
|
||||
if isinstance(box, (list, tuple)) and len(box) == 4:
|
||||
try:
|
||||
return [float(v) for v in box]
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_keep_clause(text: str) -> str:
|
||||
"""Append a keep-the-subject clause when the prompt lacks one."""
|
||||
body = str(text or "").strip()
|
||||
if not body:
|
||||
return body
|
||||
if re.search(r"\b(keep|preserve|leave|unchanged|identical|same)\b", body, re.I):
|
||||
return body
|
||||
return body.rstrip(". ") + ". Keep the main subject and everything else in the image exactly the same."
|
||||
|
||||
|
||||
def _mask_label(args: Dict[str, Any], prompt: str) -> str:
|
||||
"""Best-effort object noun for grounding a regional removal."""
|
||||
label = str(args.get("label") or args.get("object") or "").strip()
|
||||
if label:
|
||||
return label
|
||||
m = re.search(
|
||||
r"\b(?:remove|delete|erase|eliminate|get\s+rid\s+of|take\s+out)\s+(?:the\s+|a\s+|an\s+)?"
|
||||
r"([a-z][a-z \-]{1,30}?)(?:\s+(?:on|in|at|from|to|near|behind|beside|by)\b|[.,;!?]|$)",
|
||||
prompt or "",
|
||||
re.I,
|
||||
)
|
||||
if m:
|
||||
noun = m.group(1).strip().lower()
|
||||
if noun and noun not in {"background", "bg"}:
|
||||
return noun
|
||||
for noun in ("person", "people", "man", "woman", "car", "dog", "sign", "logo"):
|
||||
if re.search(rf"\b{re.escape(noun)}\b", prompt or "", re.I):
|
||||
return "person" if noun in {"person", "people", "man", "woman"} else noun
|
||||
return "object"
|
||||
|
||||
|
||||
class PlanEditTool:
|
||||
"""Plan an image edit as ordered editor operations (no side effects)."""
|
||||
|
||||
async def execute(self, content: Any, ctx: Dict[str, Any]):
|
||||
args = _parse(content)
|
||||
prompt = str(args.get("prompt") or "").strip()
|
||||
if not prompt:
|
||||
return "plan_edit: invalid", {"error": "plan_edit needs a non-empty `prompt`.", "exit_code": 1}
|
||||
state = args.get("state") if isinstance(args.get("state"), dict) else {}
|
||||
try:
|
||||
data = await _post(
|
||||
"/api/editor/plan",
|
||||
{"prompt": prompt, "state": state, "mode": "plan", "session": _session(args, ctx)},
|
||||
ctx.get("owner"),
|
||||
)
|
||||
except Exception as exc:
|
||||
return "plan_edit: error", {"error": f"plan_edit failed: {exc}", "exit_code": 1}
|
||||
steps = data.get("steps") or []
|
||||
reply = str(data.get("reply") or "")
|
||||
return f"plan_edit: {len(steps)} step(s)", {
|
||||
"output": reply or f"Planned {len(steps)} editor step(s).",
|
||||
"reply": reply,
|
||||
"planner": data.get("planner"),
|
||||
"steps": steps,
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
|
||||
class RunEditorCommandTool:
|
||||
"""Run a single validated editor op in the browser via the owner bridge."""
|
||||
|
||||
async def execute(self, content: Any, ctx: Dict[str, Any]):
|
||||
args = _parse(content)
|
||||
op = str(args.get("op") or "").strip()
|
||||
if not op:
|
||||
return "run_editor_command: invalid", {"error": "run_editor_command needs `op`.", "exit_code": 1, "completed": False}
|
||||
params = args.get("params") if isinstance(args.get("params"), dict) else {}
|
||||
wait = bool(args.get("wait", True))
|
||||
payload = {
|
||||
"session": _session(args, ctx),
|
||||
"op": op,
|
||||
"params": params,
|
||||
"wait": wait,
|
||||
"timeout": float(args.get("timeout") or 60),
|
||||
}
|
||||
try:
|
||||
data = await _post("/api/editor/bridge/command", payload, ctx.get("owner"))
|
||||
except Exception as exc:
|
||||
return f"run_editor_command: {op}", {"error": f"run_editor_command failed: {exc}", "exit_code": 1, "completed": False, "status": "failed"}
|
||||
|
||||
is_queued = bool(data.get("queued")) or data.get("status") == "queued"
|
||||
if is_queued and (not wait or not data.get("ok")):
|
||||
summary = json.dumps({"status": "queued", "queued": data.get("queued"), "session": data.get("session")}, ensure_ascii=False)
|
||||
return f"run_editor_command: {op}", {
|
||||
"output": summary,
|
||||
"queued": data.get("queued"),
|
||||
"status": "queued",
|
||||
"pending": True,
|
||||
"completed": False,
|
||||
"ok": False,
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
ok = bool(data.get("ok") is True and data.get("completed", True) is not False)
|
||||
status = data.get("status") or ("completed" if ok else "failed")
|
||||
result = data.get("result")
|
||||
summary = json.dumps({"ok": ok, "status": status, "error": data.get("error"), "result": result}, ensure_ascii=False)
|
||||
return f"run_editor_command: {op}", {
|
||||
"output": summary[:4000],
|
||||
"result": result,
|
||||
"screenshot": data.get("screenshot"),
|
||||
"status": status,
|
||||
"completed": ok,
|
||||
"ok": ok,
|
||||
"exit_code": 0 if ok else 1,
|
||||
}
|
||||
|
||||
|
||||
class GetEditorScreenshotTool:
|
||||
"""Capture the current editor canvas as a data URL."""
|
||||
|
||||
async def execute(self, content: Any, ctx: Dict[str, Any]):
|
||||
args = _parse(content)
|
||||
payload = {
|
||||
"session": _session(args, ctx),
|
||||
"op": "screenshot",
|
||||
"params": {},
|
||||
"wait": True,
|
||||
"timeout": float(args.get("timeout") or 30),
|
||||
}
|
||||
try:
|
||||
data = await _post("/api/editor/bridge/command", payload, ctx.get("owner"), timeout=45)
|
||||
except Exception as exc:
|
||||
return "get_editor_screenshot: error", {"error": f"get_editor_screenshot failed: {exc}", "exit_code": 1}
|
||||
result = data.get("result") if isinstance(data.get("result"), dict) else {}
|
||||
shot = data.get("screenshot") or result.get("dataUrl")
|
||||
if not shot:
|
||||
return "get_editor_screenshot: unavailable", {
|
||||
"error": "No editor screenshot was returned (is the editor open?).",
|
||||
"exit_code": 1,
|
||||
}
|
||||
return "get_editor_screenshot", {"output": "Captured the editor canvas.", "screenshot": shot, "exit_code": 0}
|
||||
|
||||
|
||||
class AiEditImageTool:
|
||||
"""Apply an AI image transform (remove-bg/upscale/denoise/enhance/inpaint)."""
|
||||
|
||||
async def execute(self, content: Any, ctx: Dict[str, Any]):
|
||||
args = _parse(content)
|
||||
_t = {"start": time.perf_counter()}
|
||||
op = str(args.get("op") or "").strip().lower()
|
||||
if op not in _AI_OPS:
|
||||
return "ai_edit_image: invalid", {
|
||||
"error": f"ai_edit_image `op` must be one of {sorted(_AI_OPS)}.",
|
||||
"exit_code": 1,
|
||||
}
|
||||
owner = ctx.get("owner")
|
||||
if op in ("inpaint", "generative") and not _provider_available(owner):
|
||||
return "ai_edit_image: provider required", {
|
||||
"error": _provider_message(),
|
||||
"failure_kind": "image_provider_unavailable",
|
||||
"needs_provider": True,
|
||||
"exit_code": 1,
|
||||
}
|
||||
session = _session(args, ctx)
|
||||
image = _as_data_url(args.get("image"))
|
||||
if image is None and isinstance(args.get("image"), str) and args["image"].startswith("/"):
|
||||
# A same-origin gallery/attachment URL: fetch it (owner-scoped) as bytes.
|
||||
if not _image_reference_path(args["image"]):
|
||||
return "ai_edit_image: invalid image", {"error": "Invalid image reference.", "exit_code": 1}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.get(f"{_INTERNAL_BASE}{args['image']}", headers=_internal_headers(owner))
|
||||
resp.raise_for_status()
|
||||
if not resp.headers.get("content-type", "").lower().startswith("image/"):
|
||||
return "ai_edit_image: invalid image", {"error": "Image reference is not an image.", "exit_code": 1}
|
||||
image = _as_data_url("data:image/png;base64," + base64.b64encode(resp.content).decode())
|
||||
except Exception:
|
||||
image = None
|
||||
used_canvas = False
|
||||
if not image:
|
||||
shot = await GetEditorScreenshotTool().execute(json.dumps({"session": session}), ctx)
|
||||
image = (shot[1] or {}).get("screenshot") if isinstance(shot, tuple) else None
|
||||
used_canvas = bool(image)
|
||||
if not image:
|
||||
return "ai_edit_image: no image", {
|
||||
"error": "ai_edit_image needs an `image` (data URL or same-origin URL) or an open editor.",
|
||||
"exit_code": 1,
|
||||
}
|
||||
_t["image"] = time.perf_counter()
|
||||
payload: Dict[str, Any] = {"image": image}
|
||||
if args.get("_use_image_settings") is True:
|
||||
payload["_use_image_settings"] = True
|
||||
model_choice = str(args.get("model") or "").strip()
|
||||
mask_used = False
|
||||
place_mode: Optional[str] = None
|
||||
if op == "upscale":
|
||||
payload["scale"] = int(args.get("scale") or 2)
|
||||
elif op == "denoise":
|
||||
payload["strength"] = float(args.get("strength") or 0.5)
|
||||
elif op in ("inpaint", "generative"):
|
||||
payload["prompt"] = str(args.get("prompt") or "").strip()
|
||||
if model_choice:
|
||||
# inpaint_proxy reads `_model` to route to a specific model.
|
||||
payload["_model"] = model_choice
|
||||
mode = str(args.get("mode") or "instruction").strip().lower()
|
||||
_steps = int(args.get("steps") or 0)
|
||||
if _steps > 0:
|
||||
payload["steps"] = _steps
|
||||
mask_used = False
|
||||
mask = None
|
||||
if mode == "masked":
|
||||
mask = _as_data_url(args.get("mask"))
|
||||
if not mask:
|
||||
mask = await _fetch_selection_mask(owner, session)
|
||||
# An empty/tiny SAM mask on small figures is useless: drop it.
|
||||
if mask and _mask_white_fraction(mask) < _MIN_MASK_FRACTION:
|
||||
mask = None
|
||||
if not mask:
|
||||
# Fall back to the grounding detector (OWL-ViT): a dilated
|
||||
# box mask around the matching object.
|
||||
box = await _ground_box(owner, image, _mask_label(args, payload["prompt"]))
|
||||
if box:
|
||||
candidate = _rect_mask_data_url(image, box, pad_frac=0.15)
|
||||
if candidate and _mask_white_fraction(candidate) >= _MIN_MASK_FRACTION:
|
||||
mask = candidate
|
||||
if mask:
|
||||
payload["mask"] = mask
|
||||
payload["mode"] = "masked"
|
||||
mask_used = True
|
||||
else:
|
||||
# Nothing usable to mask -> whole-image instruction fallback
|
||||
# rather than placing a transparent/empty layer.
|
||||
mode = "instruction"
|
||||
if mode != "masked":
|
||||
# Instruction mode: whole-image edit with no tight mask. Send a
|
||||
# full-white mask so the edit endpoint accepts the request and
|
||||
# regenerates the whole frame under the instruction prompt, which
|
||||
# names what must stay unchanged. This avoids the mask-compositing
|
||||
# halos that loose selections caused.
|
||||
payload["mode"] = "instruction"
|
||||
payload.pop("mask", None)
|
||||
payload["prompt"] = _ensure_keep_clause(payload["prompt"])
|
||||
try:
|
||||
from PIL import Image as _PILImage
|
||||
|
||||
_raw = _data_url_bytes(image)
|
||||
_w, _h = _PILImage.open(io.BytesIO(_raw)).size
|
||||
_mbuf = io.BytesIO()
|
||||
_PILImage.new("L", (_w, _h), 255).save(_mbuf, "PNG")
|
||||
payload["mask"] = "data:image/png;base64," + base64.b64encode(_mbuf.getvalue()).decode()
|
||||
except Exception:
|
||||
pass
|
||||
place_mode = "masked" if mask_used else "instruction"
|
||||
_t["prep"] = time.perf_counter()
|
||||
try:
|
||||
data = await _post(_AI_OPS[op], payload, owner)
|
||||
except Exception as exc:
|
||||
logger.info(
|
||||
"image-op ai_edit_image op=%s stage=provider FAILED image_ms=%.1f prep_ms=%.1f total_ms=%.1f err=%s",
|
||||
op, (_t["image"] - _t["start"]) * 1000.0, (_t["prep"] - _t["image"]) * 1000.0,
|
||||
(time.perf_counter() - _t["start"]) * 1000.0, exc,
|
||||
)
|
||||
return f"ai_edit_image: {op}", {"error": f"ai_edit_image ({op}) failed: {exc}", "exit_code": 1}
|
||||
_t["provider"] = time.perf_counter()
|
||||
result = data.get("image") or data.get("data")
|
||||
data_url = _as_data_url(result)
|
||||
if not data_url:
|
||||
return f"ai_edit_image: {op}", {"error": f"ai_edit_image ({op}) returned no image.", "exit_code": 1}
|
||||
if _image_is_blank(data_url):
|
||||
return f"ai_edit_image: {op}", {
|
||||
"error": f"ai_edit_image ({op}) produced an empty layer.",
|
||||
"failure_kind": "empty_result",
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
info: Dict[str, Any] = {
|
||||
"output": f"Applied AI edit '{op}'" + (" to the current canvas." if used_canvas else "."),
|
||||
"op": op,
|
||||
"model": data.get("model") or model_choice or "",
|
||||
"exit_code": 0,
|
||||
}
|
||||
if _incognito(ctx):
|
||||
info["image_url"] = data_url
|
||||
else:
|
||||
try:
|
||||
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), f"ai_edit_image:{op}", f"ai_edit_image:{op}"))
|
||||
except Exception as exc:
|
||||
logger.warning("ai_edit_image persist failed: %s", exc)
|
||||
_t["persist"] = time.perf_counter()
|
||||
if bool(args.get("place", True)):
|
||||
placed = await _place_layer(
|
||||
data_url,
|
||||
f"AI {op}",
|
||||
owner,
|
||||
session,
|
||||
masked=mask_used and data.get("full_frame") is not True,
|
||||
blend=str(args.get("blend") or "normal"),
|
||||
opacity=float(args.get("opacity") or 100),
|
||||
mode=place_mode,
|
||||
native_size=data.get("full_frame") is True,
|
||||
)
|
||||
info["placed"] = placed
|
||||
if placed:
|
||||
info["output"] += " Placed the result as a new layer."
|
||||
_t["place"] = time.perf_counter()
|
||||
logger.info(
|
||||
"image-op ai_edit_image op=%s mode=%s canvas=%s image_ms=%.1f mask_ms=%.1f "
|
||||
"model_ms=%.1f persist_ms=%.1f place_ms=%.1f total_ms=%.1f",
|
||||
op, place_mode or "instruction", used_canvas,
|
||||
(_t["image"] - _t["start"]) * 1000.0,
|
||||
(_t["prep"] - _t["image"]) * 1000.0,
|
||||
(_t["provider"] - _t["prep"]) * 1000.0,
|
||||
(_t["persist"] - _t["provider"]) * 1000.0,
|
||||
(_t["place"] - _t["persist"]) * 1000.0,
|
||||
(_t["place"] - _t["start"]) * 1000.0,
|
||||
)
|
||||
return f"ai_edit_image: {op}", info
|
||||
|
||||
|
||||
class GenerateImageLayerTool:
|
||||
"""Generate an image with the configured image model and place it as a layer."""
|
||||
|
||||
async def execute(self, content: str, ctx: dict):
|
||||
args = _parse(content)
|
||||
prompt = str(args.get("prompt") or "").strip()
|
||||
if not prompt:
|
||||
return "generate_image: invalid", {"error": "generate_image needs a non-empty `prompt`.", "exit_code": 1}
|
||||
owner = ctx.get("owner")
|
||||
if not _provider_available(owner):
|
||||
return "generate_image: provider required", {
|
||||
"error": _provider_message(),
|
||||
"failure_kind": "image_provider_unavailable",
|
||||
"needs_provider": True,
|
||||
"exit_code": 1,
|
||||
}
|
||||
session = _session(args, ctx)
|
||||
payload: Dict[str, Any] = {"prompt": prompt}
|
||||
for key in ("model", "size", "quality"):
|
||||
if args.get(key):
|
||||
payload[key] = args[key]
|
||||
try:
|
||||
from src.ai_interaction import do_generate_image
|
||||
|
||||
data = await do_generate_image(
|
||||
json.dumps(payload, ensure_ascii=False),
|
||||
session_id=session,
|
||||
owner=owner,
|
||||
incognito=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
return "generate_image: error", {"error": f"generate_image failed: {exc}", "exit_code": 1}
|
||||
result = data.get("image_url") or data.get("image") or data.get("data")
|
||||
data_url = _as_data_url(result)
|
||||
if not data_url:
|
||||
return "generate_image", {"error": "generate_image returned no image.", "exit_code": 1}
|
||||
info: Dict[str, Any] = {
|
||||
"output": f"Generated an image for: {prompt[:120]}",
|
||||
"image_size": data.get("image_size"),
|
||||
"model": data.get("image_model") or str(args.get("model") or ""),
|
||||
"exit_code": 0,
|
||||
}
|
||||
if _incognito(ctx):
|
||||
info["image_url"] = data_url
|
||||
else:
|
||||
try:
|
||||
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), prompt, "generate_image"))
|
||||
except Exception as exc:
|
||||
logger.warning("generate_image persist failed: %s", exc)
|
||||
if bool(args.get("place", True)):
|
||||
placed = await _place_layer(
|
||||
data_url,
|
||||
str(args.get("name") or "Generated image"),
|
||||
owner,
|
||||
session,
|
||||
native_size=True,
|
||||
masked=bool(args.get("mask")),
|
||||
blend=str(args.get("blend") or "normal"),
|
||||
opacity=float(args.get("opacity") or 100),
|
||||
)
|
||||
info["placed"] = placed
|
||||
if placed:
|
||||
info["output"] += " Placed it as a new layer."
|
||||
return "generate_image", info
|
||||
File diff suppressed because it is too large
Load Diff
+205
-40
@@ -179,8 +179,13 @@ def _resolve_model(spec: str, owner: Optional[str] = None, model_type: Optional[
|
||||
# served internal id. Include pinned/cached ids in the match set
|
||||
# so chat sessions using the HF repo id still resolve. Do not use
|
||||
# stale cached aliases when the endpoint itself is unreachable.
|
||||
if model_type == "image" and endpoint_reachable:
|
||||
for extra in _json_list(getattr(ep, "pinned_models", None)) + _json_list(getattr(ep, "cached_models", None)):
|
||||
if endpoint_reachable:
|
||||
# Gateway image models may live in a dedicated catalogue,
|
||||
# so explicitly pinned IDs are valid on mixed endpoints too.
|
||||
extras = _json_list(getattr(ep, "pinned_models", None))
|
||||
if model_type == "image":
|
||||
extras += _json_list(getattr(ep, "cached_models", None))
|
||||
for extra in extras:
|
||||
if extra not in model_ids:
|
||||
model_ids.append(extra)
|
||||
|
||||
@@ -965,7 +970,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
|
||||
elif action == "open_panel":
|
||||
# Open a top-level panel/modal: documents/library, gallery,
|
||||
# calendar, email, sessions, notes, memories, skills, settings, theme, cookbook.
|
||||
# calendar, email, sessions, notes, tasks, memories, skills, settings, theme, cookbook.
|
||||
panel = parts[1].lower() if len(parts) > 1 else ""
|
||||
view = ""
|
||||
view_label = ""
|
||||
@@ -993,6 +998,8 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
"note": "notes",
|
||||
"todo": "notes",
|
||||
"todos": "notes",
|
||||
"tasks": "tasks",
|
||||
"task": "tasks",
|
||||
"memories": "memories",
|
||||
"memory": "memories",
|
||||
"brain": "memories",
|
||||
@@ -1010,7 +1017,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
}
|
||||
target = _panel_aliases.get(panel)
|
||||
if not target:
|
||||
return {"error": f"Unknown panel '{panel}'. Valid: documents, gallery, calendar, email, sessions, notes, memories, skills, settings, theme, cookbook."}
|
||||
return {"error": f"Unknown panel '{panel}'. Valid: documents, gallery, calendar, email, sessions, notes, tasks, memories, skills, settings, theme, cookbook."}
|
||||
if target == "cookbook":
|
||||
cookbook_views = {
|
||||
"models": ("Search", "models"), "model": ("Search", "models"),
|
||||
@@ -1155,14 +1162,90 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
# Image generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||
# Message used whenever a generative image edit (object add/remove/replace,
|
||||
# image generation) is requested but no image provider is configured. The
|
||||
# photo-editor agent and the image tools surface this verbatim so the user gets
|
||||
# a plain, actionable explanation instead of a silent substitute edit.
|
||||
IMAGE_PROVIDER_REQUIRED_MESSAGE = (
|
||||
"Removing/adding objects needs an image generation provider; enable one in "
|
||||
"Settings > Models."
|
||||
)
|
||||
|
||||
|
||||
def image_editing_provider_available(owner: Optional[str] = None) -> bool:
|
||||
"""True when an enabled, owner-visible image endpoint can serve AI edits.
|
||||
|
||||
Generative operations (object add/remove/replace, image generation) require
|
||||
a configured image model. Local transforms (remove-bg, upscale, denoise,
|
||||
enhance-face) do not, but the photo-editor agent gates the whole generative
|
||||
request on this so it never falls back to deterministic approximations.
|
||||
"""
|
||||
try:
|
||||
from src.settings import get_user_setting, load_settings
|
||||
|
||||
if not load_settings().get("image_gen_enabled", True) or not get_user_setting("image_gen_enabled", owner=owner or "", default=True):
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from src.database import SessionLocal, ModelEndpoint
|
||||
from src.auth_helpers import owner_filter
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.is_enabled == True, # noqa: E712
|
||||
ModelEndpoint.model_type == "image",
|
||||
)
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
return q.first() is not None
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _uses_openrouter_chat_images(model_id: str) -> bool:
|
||||
# GPT-5 Image is an LLM wrapper, unlike native gpt-image-* image models.
|
||||
leaf = model_id_leaf(model_id).lower()
|
||||
return leaf.startswith("gpt-") and "-image" in leaf and not leaf.startswith("gpt-image-")
|
||||
|
||||
|
||||
def _chat_image_results(data: dict) -> list:
|
||||
images = []
|
||||
for choice in data.get("choices") or []:
|
||||
message = choice.get("message") or {}
|
||||
for item in message.get("images") or []:
|
||||
url = (item.get("image_url") or {}).get("url")
|
||||
if not isinstance(url, str):
|
||||
continue
|
||||
if url.startswith("data:image/") and ";base64," in url:
|
||||
images.append({"b64_json": url.split(",", 1)[1]})
|
||||
elif url.startswith(("https://", "http://")):
|
||||
images.append({"url": url})
|
||||
return images
|
||||
|
||||
|
||||
def _image_response_error(data: dict, model_id: str) -> Optional[str]:
|
||||
errors = [data.get("error")]
|
||||
errors.extend(choice.get("error") for choice in data.get("choices") or [])
|
||||
for error in errors:
|
||||
if not error:
|
||||
continue
|
||||
message = str(error.get("message") or "Image provider failed") if isinstance(error, dict) else str(error)
|
||||
hint = " Choose a currently supported image model in AI settings." if "deprecated" in message.lower() else ""
|
||||
return f"Image provider failed for '{model_id}': {message}{hint}"
|
||||
return None
|
||||
|
||||
|
||||
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None, *, incognito: bool = False) -> Dict:
|
||||
"""Generate an image using an image-capable model (e.g. gpt-image-1).
|
||||
|
||||
Content format:
|
||||
Line 1: prompt describing the image
|
||||
Line 2: model name (optional, default auto-detects: prefers gpt-image-1.5 > gpt-image-1)
|
||||
Line 3: size (optional, defaults to 1024x1024)
|
||||
Line 4: quality (optional, defaults to medium — options: low, medium, high, auto)
|
||||
Line 4: quality (optional, defaults to configured quality or medium — low, medium, high, auto)
|
||||
"""
|
||||
import base64
|
||||
import httpx
|
||||
@@ -1180,32 +1263,38 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
prompt = str(args.get('prompt') or '').strip()
|
||||
model_spec = str(args.get('model') or '').strip()
|
||||
size = str(args.get('size') or '1024x1024')
|
||||
quality = str(args.get('quality') or 'medium')
|
||||
quality = str(args.get('quality') or '')
|
||||
else:
|
||||
lines = content.strip().split("\n")
|
||||
prompt = lines[0].strip() if lines else ""
|
||||
model_spec = lines[1].strip() if len(lines) > 1 and lines[1].strip() else ""
|
||||
size = lines[2].strip() if len(lines) > 2 and lines[2].strip() else "1024x1024"
|
||||
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else "medium"
|
||||
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else ""
|
||||
|
||||
if not prompt:
|
||||
return {"error": "Image prompt is required (line 1)"}
|
||||
|
||||
# Load admin settings for defaults
|
||||
# Load settings for defaults
|
||||
try:
|
||||
from src.settings import load_settings
|
||||
from src.settings import get_user_setting, load_settings
|
||||
_settings = load_settings()
|
||||
except Exception:
|
||||
_settings = {}
|
||||
def get_user_setting(key, owner="", default=None):
|
||||
return default
|
||||
|
||||
# The administrator's global image-generation disable switch is authoritative.
|
||||
# Non-admin preferences cannot re-enable generation when disabled globally.
|
||||
if not _settings.get("image_gen_enabled", True):
|
||||
return {"error": "Image generation is disabled by the administrator."}
|
||||
if not get_user_setting("image_gen_enabled", owner=owner or "", default=True):
|
||||
return {"error": "Image generation is disabled by user preference."}
|
||||
|
||||
# Use admin-configured model/quality if not specified by the tool call
|
||||
# Use user- or admin-configured model/quality if not specified by the tool call
|
||||
if not model_spec:
|
||||
model_spec = _settings.get("image_model", "")
|
||||
if quality == "medium" and _settings.get("image_quality"):
|
||||
quality = _settings["image_quality"]
|
||||
model_spec = get_user_setting("image_model", owner=owner, default="") or ""
|
||||
if not quality:
|
||||
quality = get_user_setting("image_quality", owner=owner, default="medium") or "medium"
|
||||
|
||||
# Auto-detect best available image model if still not set
|
||||
if not model_spec:
|
||||
@@ -1255,6 +1344,14 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
|
||||
async def _resolve_image_model(model_name: str):
|
||||
def _call():
|
||||
# Try a plain by-name lookup first. Gateway endpoints (OpenRouter)
|
||||
# register as `llm` yet serve image models, and the image-typed
|
||||
# query's last-resort fallback would otherwise hijack a cloud model
|
||||
# onto the local image endpoint (gpt-5-image -> qwen-image-edit).
|
||||
try:
|
||||
return _resolve_model(model_name, owner=owner)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return _resolve_model(model_name, owner=owner, model_type="image")
|
||||
except TypeError as exc:
|
||||
@@ -1289,8 +1386,19 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
|
||||
images_url = base_url + "/images/generations"
|
||||
from src.model_capability_readers.base import detect_vendor
|
||||
chat_images = detect_vendor(url) == "openrouter" and _uses_openrouter_chat_images(model_id)
|
||||
if detect_vendor(url) == "openrouter":
|
||||
images_url = base_url + "/images"
|
||||
if chat_images:
|
||||
images_url = base_url + "/chat/completions"
|
||||
|
||||
# Capture the requested geometry before older OpenAI size restrictions.
|
||||
from src.image_model_ids import image_aspect_ratio
|
||||
try:
|
||||
requested_width, requested_height = (int(value) for value in size.lower().split("x"))
|
||||
requested_ratio = image_aspect_ratio(requested_width, requested_height)
|
||||
except (TypeError, ValueError):
|
||||
requested_ratio = "1:1"
|
||||
|
||||
# Validate size for cloud image models (local diffusion accepts any WxH)
|
||||
valid_gpt_sizes = {"1024x1024", "1024x1536", "1536x1024", "auto"}
|
||||
@@ -1306,6 +1414,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
"n": 1,
|
||||
"size": size,
|
||||
}
|
||||
if detect_vendor(url) == "openrouter":
|
||||
payload.pop("size", None)
|
||||
payload["aspect_ratio"] = requested_ratio
|
||||
|
||||
# GPT image models and local diffusion support quality; DALL-E does not
|
||||
if is_gpt_image or is_local_diffusion:
|
||||
@@ -1314,12 +1425,21 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
else:
|
||||
payload["quality"] = "medium"
|
||||
|
||||
logger.info(f"Image generation: model={model_id}, size={size}, quality={quality}, prompt={prompt[:80]}")
|
||||
logger.info(f"Image generation: model={model_id}, size={size}, quality={quality}, prompt={'[private]' if incognito else prompt[:80]}")
|
||||
|
||||
try:
|
||||
# GPT image models can take 30-120s+ depending on quality
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0)) as client:
|
||||
resp = await client.post(images_url, json=payload, headers=headers)
|
||||
request_payload = payload
|
||||
if chat_images:
|
||||
request_payload = {
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"modalities": ["image", "text"],
|
||||
"stream": False,
|
||||
"image_config": {"aspect_ratio": requested_ratio},
|
||||
}
|
||||
resp = await client.post(images_url, json=request_payload, headers=headers)
|
||||
|
||||
if resp.status_code != 200:
|
||||
error_text = resp.text[:500]
|
||||
@@ -1334,9 +1454,15 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
}
|
||||
|
||||
data = resp.json()
|
||||
images = data.get("data", [])
|
||||
provider_error = _image_response_error(data, model_id)
|
||||
if provider_error:
|
||||
return {"error": provider_error, "untrusted_content": True}
|
||||
images = _chat_image_results(data) if chat_images else data.get("data", [])
|
||||
if not images:
|
||||
return {"error": "No images returned from API"}
|
||||
return {"error": (
|
||||
f"Image model '{model_id}' returned no image. "
|
||||
"Choose another image generation model in AI settings."
|
||||
)}
|
||||
|
||||
img = images[0]
|
||||
image_url = None
|
||||
@@ -1365,18 +1491,22 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
logger.warning(f"Failed to save gallery record: {_ge}")
|
||||
return ""
|
||||
|
||||
# GPT image models always return b64_json; DALL-E may return url
|
||||
if img.get("b64_json"):
|
||||
def _store_image(image_bytes):
|
||||
if incognito:
|
||||
return 'data:image/png;base64,' + base64.b64encode(image_bytes).decode('ascii'), None
|
||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||
img_path = img_dir / filename
|
||||
img_path.write_bytes(base64.b64decode(img.get("b64_json")))
|
||||
img_path.write_bytes(image_bytes)
|
||||
if owner:
|
||||
from src.generated_images import register_generated_image
|
||||
register_generated_image(filename, owner)
|
||||
image_url = f"/api/generated-image/{filename}"
|
||||
image_id = _save_to_gallery(filename)
|
||||
return f"/api/generated-image/{filename}", _save_to_gallery(filename)
|
||||
|
||||
# Private results stay in the response, never in the gallery or on disk.
|
||||
if img.get("b64_json"):
|
||||
image_url, image_id = _store_image(base64.b64decode(img['b64_json']))
|
||||
|
||||
elif img.get("url"):
|
||||
# Download external URL and save locally (DALL-E returns temp URLs)
|
||||
@@ -1390,16 +1520,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
try:
|
||||
dl_resp = httpx.get(result_url, timeout=60)
|
||||
if dl_resp.status_code == 200:
|
||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||
img_path = img_dir / filename
|
||||
img_path.write_bytes(dl_resp.content)
|
||||
if owner:
|
||||
from src.generated_images import register_generated_image
|
||||
register_generated_image(filename, owner)
|
||||
image_url = f"/api/generated-image/{filename}"
|
||||
image_id = _save_to_gallery(filename)
|
||||
image_url, image_id = _store_image(dl_resp.content)
|
||||
else:
|
||||
image_url = result_url # fallback to external URL
|
||||
except Exception as _dl_e:
|
||||
@@ -1434,8 +1555,9 @@ async def do_edit_image(
|
||||
session_id: Optional[str] = None,
|
||||
owner: Optional[str] = None,
|
||||
size: str = "auto",
|
||||
quality: str = "medium",
|
||||
quality: Optional[str] = None,
|
||||
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
||||
incognito: bool = False,
|
||||
) -> Dict:
|
||||
"""Edit an uploaded image using the configured image endpoint."""
|
||||
import base64
|
||||
@@ -1453,21 +1575,27 @@ async def do_edit_image(
|
||||
return {"error": "Attached image file was not found"}
|
||||
|
||||
try:
|
||||
from src.settings import load_settings
|
||||
from src.settings import get_user_setting, load_settings
|
||||
_settings = load_settings()
|
||||
except Exception:
|
||||
_settings = {}
|
||||
def get_user_setting(key, owner="", default=None):
|
||||
return default
|
||||
|
||||
if not model_spec:
|
||||
model_spec = _settings.get("image_model", "")
|
||||
if quality == "medium" and _settings.get("image_quality"):
|
||||
quality = _settings["image_quality"]
|
||||
model_spec = get_user_setting("image_edit_model", owner=owner) or get_user_setting("image_model", owner=owner, default="")
|
||||
if quality is None:
|
||||
quality = get_user_setting("image_quality", owner=owner, default="medium") or "medium"
|
||||
if not model_spec:
|
||||
return {"error": "No image model selected for image editing"}
|
||||
|
||||
try:
|
||||
try:
|
||||
def _call():
|
||||
try:
|
||||
return _resolve_model(model_spec, owner=owner)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return _resolve_model(model_spec, owner=owner, model_type="image")
|
||||
except TypeError as exc:
|
||||
@@ -1480,6 +1608,7 @@ async def do_edit_image(
|
||||
except ValueError:
|
||||
return {"error": f"No endpoint found with image model '{model_spec}'."}
|
||||
|
||||
requested_edit_size = size
|
||||
if not size or size == "auto":
|
||||
from PIL import Image
|
||||
from src.image_model_ids import image_edit_size
|
||||
@@ -1509,7 +1638,7 @@ async def do_edit_image(
|
||||
request_id = uuid.uuid4().hex
|
||||
payload["request_id"] = request_id
|
||||
|
||||
logger.info("Image edit: model=%s, size=%s, quality=%s, image=%s, prompt=%s", model_id, size, quality, path.name, prompt[:80])
|
||||
logger.info("Image edit: model=%s, size=%s, quality=%s, image=%s, prompt=%s", model_id, size, quality, '[private]' if incognito else path.name, '[private]' if incognito else prompt[:80])
|
||||
|
||||
def _save_edited_image_to_gallery(filename: str) -> str:
|
||||
try:
|
||||
@@ -1539,6 +1668,9 @@ async def do_edit_image(
|
||||
from PIL import Image
|
||||
with Image.open(BytesIO(image_bytes)) as output:
|
||||
size = f"{output.width}x{output.height}"
|
||||
image_mime = Image.MIME.get(output.format, 'image/png')
|
||||
if incognito:
|
||||
return 'data:' + image_mime + ';base64,' + base64.b64encode(image_bytes).decode('ascii'), ''
|
||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{uuid.uuid4().hex[:12]}{suffix}"
|
||||
@@ -1652,6 +1784,34 @@ async def do_edit_image(
|
||||
"image_url": {"url": f"data:{mime};base64,{image_b64}"},
|
||||
}],
|
||||
}
|
||||
from src.image_model_ids import image_aspect_ratio
|
||||
from PIL import Image as ReferenceImage
|
||||
with ReferenceImage.open(path) as reference:
|
||||
reference_width, reference_height = reference.size
|
||||
if reference.getexif().get(274) in {5, 6, 7, 8}:
|
||||
reference_width, reference_height = reference_height, reference_width
|
||||
if requested_edit_size and requested_edit_size != "auto":
|
||||
try:
|
||||
reference_width, reference_height = (int(value) for value in requested_edit_size.lower().split("x"))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
edit_payload.pop("size", None)
|
||||
edit_payload["aspect_ratio"] = image_aspect_ratio(reference_width, reference_height)
|
||||
if _uses_openrouter_chat_images(model_id):
|
||||
edit_payload = {
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": {
|
||||
"url": f"data:{mime};base64,{image_b64}",
|
||||
}},
|
||||
]}],
|
||||
"modalities": ["image", "text"],
|
||||
"stream": False,
|
||||
"image_config": {"aspect_ratio": edit_payload["aspect_ratio"]},
|
||||
}
|
||||
resp = await client.post(base_url + "/chat/completions", json=edit_payload, headers=headers)
|
||||
else:
|
||||
resp = await client.post(base_url + "/images", json=edit_payload, headers=headers)
|
||||
else:
|
||||
with path.open("rb") as f:
|
||||
@@ -1695,7 +1855,12 @@ async def do_edit_image(
|
||||
}
|
||||
|
||||
data = resp.json()
|
||||
images = data.get("data", [])
|
||||
provider_error = _image_response_error(data, model_id)
|
||||
if provider_error:
|
||||
return {"error": provider_error, "untrusted_content": True}
|
||||
images = (_chat_image_results(data)
|
||||
if is_openrouter and _uses_openrouter_chat_images(model_id)
|
||||
else data.get("data", []))
|
||||
if not images:
|
||||
return {"error": "No image returned from edit API"}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ def compact_browser_observation(value, budget=8000):
|
||||
# Snapshot text already contains labels and refs in DOM order.
|
||||
# The refs mapping repeats them and buries menus in raw JSON.
|
||||
lines = [line for line in snapshot.splitlines()
|
||||
if not re.fullmatch(r'\s*-?\s*generic(?:\s+\[ref=e\d+\])?:?\s*', line)]
|
||||
if not re.fullmatch(r'\s*(?:-\s*)?generic(?:\s+\[ref=e\d+\])?:?\s*', line)]
|
||||
pages.append(('URL: ' + str(url) + '\n' if url else '') + '\n'.join(lines))
|
||||
elif url:
|
||||
notices.append('URL: ' + str(url))
|
||||
@@ -37,7 +37,8 @@ def compact_browser_observation(value, budget=8000):
|
||||
parsed = json.loads(item)
|
||||
except (ValueError, TypeError):
|
||||
# CLI status text precedes the JSON post-interaction state.
|
||||
parts = re.split(r'\n\n\[(?:post-[^\]]+|page state after failed [^\]]+)\]\n', item)
|
||||
# Labels are one line; stopping at \n keeps the split linear.
|
||||
parts = re.split(r'\n\n\[(?:post-[^\]\n]+|page state after failed [^\]\n]+)\]\n', item)
|
||||
if len(parts) > 1:
|
||||
for part in parts:
|
||||
visit(part)
|
||||
|
||||
+139
-3
@@ -10,7 +10,7 @@ import os
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Tuple
|
||||
|
||||
from src.auth_helpers import owner_filter
|
||||
@@ -1957,6 +1957,23 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo
|
||||
return str(e), False
|
||||
|
||||
|
||||
def _daily_brief_utc_boundary(local_dt, tz=None):
|
||||
"""Convert a local wall-clock boundary using the offset at that boundary."""
|
||||
if local_dt.tzinfo is None and tz is not None:
|
||||
local_dt = local_dt.replace(tzinfo=tz)
|
||||
return local_dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _daily_brief_local_start(event, tz=None):
|
||||
"""Keep date-only/floating rows local; convert only stored UTC instants."""
|
||||
start = event.dtstart
|
||||
if event.is_utc and not event.all_day:
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=timezone.utc)
|
||||
return start.astimezone(tz).replace(tzinfo=None)
|
||||
return start
|
||||
|
||||
|
||||
async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
"""Build a short morning digest: today's calendar events, unread email count
|
||||
+ top-N senders/subjects, active todos."""
|
||||
@@ -1966,10 +1983,13 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
|
||||
from core.database import SessionLocal, CalendarEvent, CalendarCal, Note
|
||||
from routes.email_helpers import _imap_connect, _decode_header
|
||||
from sqlalchemy import and_, or_
|
||||
|
||||
# ----- Calendar: today's events -----
|
||||
today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
tomorrow = today + _td(days=1)
|
||||
utc_today = _daily_brief_utc_boundary(today)
|
||||
utc_tomorrow = _daily_brief_utc_boundary(tomorrow)
|
||||
# v2 review HIGH-12: gate the OR-null branch on single-user
|
||||
# (unconfigured) deploys only. In a multi-user deploy, one
|
||||
# user's daily brief must not include another user's notes or
|
||||
@@ -1982,13 +2002,25 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ev_q = db.query(CalendarEvent).join(CalendarCal).filter(
|
||||
or_(
|
||||
and_(
|
||||
CalendarEvent.is_utc.is_(True),
|
||||
CalendarEvent.all_day.is_not(True),
|
||||
CalendarEvent.dtstart < utc_tomorrow,
|
||||
CalendarEvent.dtend > utc_today,
|
||||
),
|
||||
and_(
|
||||
or_(CalendarEvent.is_utc.is_not(True), CalendarEvent.all_day.is_(True)),
|
||||
CalendarEvent.dtstart < tomorrow,
|
||||
CalendarEvent.dtend > today,
|
||||
),
|
||||
),
|
||||
CalendarEvent.status != "cancelled",
|
||||
)
|
||||
if owner:
|
||||
ev_q = owner_filter(ev_q, CalendarCal, owner, include_shared=_allow_null)
|
||||
events = ev_q.order_by(CalendarEvent.dtstart).all()
|
||||
events = ev_q.all()
|
||||
events.sort(key=_daily_brief_local_start)
|
||||
# ----- Notes: pinned + non-archived todos with at least one undone item -----
|
||||
n_q = db.query(Note).filter(Note.archived == False) # noqa: E712
|
||||
if owner:
|
||||
@@ -2058,7 +2090,7 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
if events:
|
||||
plain.append("Calendar:")
|
||||
for e in events:
|
||||
t = e.dtstart.strftime("%H:%M") if not e.all_day else "all day"
|
||||
t = _daily_brief_local_start(e).strftime("%H:%M") if not e.all_day else "all day"
|
||||
loc = f" @ {e.location}" if e.location else ""
|
||||
plain.append(f" {t} {e.summary}{loc}")
|
||||
plain.append("")
|
||||
@@ -2465,6 +2497,100 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
return str(e), False
|
||||
|
||||
|
||||
async def _spec_triage_email_urgency(item: dict, *, triage_version: int) -> dict | None:
|
||||
"""Classify one email's urgency with the trained jeff ``triage`` adapter.
|
||||
|
||||
Returns an email-urgency verdict (same shape as the LLM path) or None when
|
||||
the per-job flag is off or the adapter is unavailable, so the caller falls
|
||||
through to the LLM candidate chain. Only the ``urgency`` question is sent:
|
||||
jeff scores each question in its own forward pass, and that is the question
|
||||
this adapter was trained on.
|
||||
"""
|
||||
from src.jeff_decision import jeff_job_enabled, jeff_systemone, score_value
|
||||
|
||||
if not jeff_job_enabled("EMAIL_URGENCY"):
|
||||
return None
|
||||
message = f"Subject: {item.get('subject','')}\n\n{item.get('body','')}".strip()
|
||||
if not message:
|
||||
return None
|
||||
answers = await jeff_systemone(
|
||||
"triage",
|
||||
{"channel": "email", "message": message[:8000]},
|
||||
{"urgency": {
|
||||
"type": "score",
|
||||
"instructions": "How urgently does this need a response?",
|
||||
"criteria": [
|
||||
"No action needed",
|
||||
"Can wait a few days",
|
||||
"Handle within a day",
|
||||
"Handle within hours",
|
||||
"Handle immediately",
|
||||
],
|
||||
}},
|
||||
)
|
||||
if not answers:
|
||||
return None
|
||||
exp = score_value(answers.get("urgency"))
|
||||
if exp is None:
|
||||
return None
|
||||
|
||||
# Adapter's 0-4 urgency scale -> the app's 0-3 score.
|
||||
score = max(0, min(3, int(round(exp))))
|
||||
reason = "triage adapter"
|
||||
|
||||
# Same high-precision correction the prompt path applies: a person waiting
|
||||
# in person is always urgent, whatever the model thinks.
|
||||
blob = f"{item.get('headers','')}\n{item.get('subject','')}\n{item.get('body','')}".lower()
|
||||
if re.search(r"\b(i'?m|i am|im|we'?re|we are)\s+outside\b", blob) or re.search(
|
||||
r"\b(waiting outside|at the door|locked out|can'?t get in|cannot get in)\b", blob
|
||||
):
|
||||
if score < 3:
|
||||
reason = "person is waiting outside"
|
||||
score = 3
|
||||
|
||||
# Same bulk/marketing guard the prompt path applies: a newsletter or
|
||||
# promotion is not urgent no matter how time-limited it sounds.
|
||||
bulkish = bool(re.search(
|
||||
r"\b(list-unsubscribe|list-id|mailchimp|mailchimpapp|view this email in your browser|unsubscribe|newsletter|digest|precedence:\s*bulk)\b",
|
||||
blob,
|
||||
))
|
||||
# The adapter was trained on support tickets, so it over-rates promotions.
|
||||
# Require a clear bulk header or several marketing terms before zeroing.
|
||||
marketing_hits = sum(1 for t in (
|
||||
"advertisement", "sponsored", "promo", "promotion", "sale", "discount",
|
||||
"offer", "limited time", "deal", "coupon", "shop now", "buy now",
|
||||
"sold out", "low tickets",
|
||||
) if t in blob)
|
||||
if bulkish or marketing_hits >= 2:
|
||||
score = 0
|
||||
reason = "bulk mail; no personal reply needed"
|
||||
|
||||
tags = []
|
||||
if score >= 3:
|
||||
tags.append("urgent")
|
||||
elif score >= 2:
|
||||
tags.append("reply-soon")
|
||||
|
||||
_from_raw = item.get("from", "") or ""
|
||||
if "<" in _from_raw:
|
||||
_from_short = _from_raw.split("<", 1)[0].strip().strip('"') or _from_raw
|
||||
else:
|
||||
_from_short = _from_raw
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
"tags": tags[:4],
|
||||
"spam": False,
|
||||
"reason": reason,
|
||||
"subject": (item.get("subject") or "")[:200],
|
||||
"from": _from_short[:120],
|
||||
"triage_version": triage_version,
|
||||
"message_id": (item.get("message_id") or "").strip(),
|
||||
"unread": bool(item.get("unread")),
|
||||
"ts": time.time(),
|
||||
}
|
||||
|
||||
|
||||
async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
"""Scan unread emails across all accounts, LLM-triage new ones, cache
|
||||
per-UID verdicts, tag the inbox, and fire a reminder when a previously
|
||||
@@ -2852,6 +2978,16 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
per_uid_scores[key] = verdict
|
||||
saved_classifications += 1
|
||||
continue
|
||||
# ── Spec decision adapter (triage): a calibrated urgency score
|
||||
# with no generated-text prompt. Enabled per-job via
|
||||
# ODYSSEUS_JEFF_EMAIL_URGENCY; any failure returns None and the
|
||||
# LLM candidate chain below still runs.
|
||||
_tri = await _spec_triage_email_urgency(item, triage_version=TRIAGE_VERSION)
|
||||
if _tri is not None:
|
||||
cache.setdefault("uids", {})[item["uid"]] = _tri
|
||||
per_uid_scores[key] = _tri
|
||||
saved_classifications += 1
|
||||
continue
|
||||
# ── LLM-classify. JSON-only response; bullet-proof parse.
|
||||
llm_attempts += 1
|
||||
prompt = (
|
||||
|
||||
+12
-1
@@ -211,10 +211,15 @@ class ChatHandler:
|
||||
from src.settings import get_setting
|
||||
vision_enabled = get_setting("vision_enabled", True)
|
||||
if vision_enabled:
|
||||
from src.owner_identity import auth_disabled
|
||||
sess_owner = getattr(sess, "owner", None)
|
||||
if not sess_owner and not auth_disabled():
|
||||
sess_owner = ""
|
||||
main_is_vision = await asyncio.to_thread(
|
||||
model_supports_vision,
|
||||
sess.model or "",
|
||||
getattr(sess, "endpoint_url", "") or "",
|
||||
sess_owner,
|
||||
)
|
||||
|
||||
if effective_att_ids and vision_enabled:
|
||||
@@ -277,7 +282,13 @@ class ChatHandler:
|
||||
_sync_upload_vision_to_gallery(file_info, owner, vl_desc)
|
||||
except Exception:
|
||||
pass
|
||||
enhanced_message = f"{enhanced_message}\n\n[Image: {file_info['name']}]\n{vl_desc}"
|
||||
enhanced_message = f"{enhanced_message}\n\n[Image: {file_info['name']}]\n"
|
||||
if vl_desc:
|
||||
# A separate vision call wrote this, but the chat model sees
|
||||
# one message and answers the prose as if the user wrote it
|
||||
# (#5573). Label it below the marker line — the client strips
|
||||
# the block by matching `[Image: <name>]\n`.
|
||||
enhanced_message += f"[Image description — context only, not part of the user's message]:\n{vl_desc}"
|
||||
# Surface the description to the client live so it renders as a
|
||||
# collapsible "image description" on the user bubble (not just
|
||||
# after a refresh that re-parses the stored message).
|
||||
|
||||
+328
-21
@@ -5,9 +5,14 @@ import re
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import socket
|
||||
import ipaddress
|
||||
import logging
|
||||
import httpx
|
||||
import typing
|
||||
import httpcore
|
||||
from httpcore._backends.sync import SyncBackend, SyncStream
|
||||
from httpcore._exceptions import map_exceptions, ConnectTimeout, ConnectError
|
||||
from urllib.parse import urlparse
|
||||
from fastapi import HTTPException
|
||||
from fastapi import UploadFile
|
||||
@@ -17,6 +22,9 @@ from src.upload_limits import format_byte_limit, get_chat_upload_max_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ORIGINAL_HTTPX_GET = httpx.get
|
||||
_ORIGINAL_HTTPX_POST = httpx.post
|
||||
|
||||
|
||||
def extract_urls(text: str) -> List[str]:
|
||||
"""Extract URLs from text using regex pattern."""
|
||||
@@ -81,6 +89,16 @@ def is_vision_model(model_name: str) -> bool:
|
||||
drops the image entirely. See issue #124.
|
||||
"""
|
||||
m = (model_name or "").lower()
|
||||
if not m:
|
||||
return False
|
||||
# Per-model override (comma-separated substrings), e.g. a local model whose
|
||||
# name carries no vision/VL hint but answers images natively.
|
||||
override = os.getenv("ODYSSEUS_VISION_MODELS", "")
|
||||
if override:
|
||||
for token in override.split(","):
|
||||
token = token.strip().lower()
|
||||
if token and token in m:
|
||||
return True
|
||||
if any(kw in m for kw in _VISION_MODEL_KEYWORDS):
|
||||
return True
|
||||
return bool(_VISION_VL_RE.search(m))
|
||||
@@ -91,23 +109,167 @@ _PROVIDER_FINGERPRINT_TTL = 60.0
|
||||
_lmstudio_models_cache: dict = {}
|
||||
|
||||
|
||||
def _is_local_host(host: Optional[str]) -> bool:
|
||||
"""True for loopback/LAN/Tailscale hosts (never public domains)."""
|
||||
host = (host or "").lower()
|
||||
_BLOCKED_METADATA_IPS = {
|
||||
ipaddress.ip_address("169.254.169.254"),
|
||||
ipaddress.ip_address("169.254.169.253"),
|
||||
ipaddress.ip_address("100.100.100.200"),
|
||||
}
|
||||
|
||||
_CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10")
|
||||
_NAT64_PREFIX = ipaddress.ip_network("64:ff9b::/96")
|
||||
|
||||
|
||||
def _is_ip_safe_local(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
# IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254) — unwrap to evaluate the embedded IPv4
|
||||
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
||||
ip = ip.ipv4_mapped
|
||||
elif isinstance(ip, ipaddress.IPv6Address) and ip in _NAT64_PREFIX:
|
||||
ip = ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
|
||||
|
||||
if ip in _BLOCKED_METADATA_IPS:
|
||||
return False
|
||||
if ip.is_link_local:
|
||||
return False
|
||||
# Explicit loopback check before reserved rejection:
|
||||
# RFC 4291 / CPython marks IPv6 ::1 as both is_loopback and is_reserved.
|
||||
if ip.is_loopback:
|
||||
return True
|
||||
if ip.is_multicast or ip.is_reserved or ip.is_unspecified:
|
||||
return False
|
||||
if ip.is_private:
|
||||
return True
|
||||
if isinstance(ip, ipaddress.IPv4Address) and ip in _CGNAT_NETWORK:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_safe_local_host(host: Optional[str]) -> bool:
|
||||
"""True for loopback/LAN/Tailscale hosts.
|
||||
|
||||
Explicitly excludes link-local, cloud metadata destinations (AWS/GCP/Azure/Alibaba),
|
||||
unresolved single-label names, and public domains.
|
||||
"""
|
||||
host = (host or "").strip().lower()
|
||||
if not host:
|
||||
return False
|
||||
if host in {"localhost", "host.docker.internal"} or host.endswith(".local"):
|
||||
return True
|
||||
# Strip optional IPv6 square brackets: [::1] -> ::1
|
||||
literal = host[1:-1] if host.startswith("[") and host.endswith("]") else host
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
ip = ipaddress.ip_address(literal)
|
||||
return _is_ip_safe_local(ip)
|
||||
except ValueError:
|
||||
return "." not in host
|
||||
if ip.is_loopback or ip.is_private or ip.is_link_local:
|
||||
return True
|
||||
return ip in ipaddress.ip_network("100.64.0.0/10")
|
||||
pass
|
||||
|
||||
allowed_names = {"localhost", "host.docker.internal"}
|
||||
if not (host in allowed_names or host.endswith(".local") or host.endswith(".internal") or host.endswith(".lan")):
|
||||
return False
|
||||
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
if not infos:
|
||||
return False
|
||||
resolved_ips = []
|
||||
for info in infos:
|
||||
sockaddr = info[4]
|
||||
ip_str = str(sockaddr[0]).split("%")[0]
|
||||
resolved_ips.append(ipaddress.ip_address(ip_str))
|
||||
if not resolved_ips:
|
||||
return False
|
||||
return all(_is_ip_safe_local(ip) for ip in resolved_ips)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _probe_lmstudio_models(url: str) -> Optional[list]:
|
||||
_is_local_host = _is_safe_local_host
|
||||
|
||||
|
||||
class SafeProbeBackend(SyncBackend):
|
||||
"""Enforces SSRF address policies at TCP connection time.
|
||||
|
||||
Resolves the host once, evaluates every resolved address against _is_ip_safe_local,
|
||||
and connects directly to the pre-validated IP, eliminating DNS TOCTOU and rebinding.
|
||||
Preserves TLS SNI and hostname certificate validation via SyncStream.start_tls.
|
||||
"""
|
||||
|
||||
def connect_tcp(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
timeout: float | None = None,
|
||||
local_address: str | None = None,
|
||||
socket_options: typing.Iterable[httpcore.SOCKET_OPTION] | None = None,
|
||||
) -> httpcore.NetworkStream:
|
||||
source_address = None if local_address is None else (local_address, 0)
|
||||
exc_map = {socket.timeout: ConnectTimeout, OSError: ConnectError}
|
||||
with map_exceptions(exc_map):
|
||||
infos = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)
|
||||
if not infos:
|
||||
raise ConnectError(f"Host {host} did not resolve")
|
||||
for family, socktype, proto, _canon, sockaddr in infos:
|
||||
ip_str = str(sockaddr[0]).split("%")[0]
|
||||
try:
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
except ValueError:
|
||||
raise ConnectError(f"Invalid resolved IP: {ip_str}")
|
||||
if not _is_ip_safe_local(ip):
|
||||
raise ConnectError(f"Target address is not an authorized safe local destination: {ip}")
|
||||
last_err: Optional[OSError] = None
|
||||
for family, socktype, proto, _canon, sockaddr in infos:
|
||||
sock = socket.socket(family, socktype, proto)
|
||||
try:
|
||||
if timeout is not None:
|
||||
sock.settimeout(timeout)
|
||||
if source_address:
|
||||
sock.bind(source_address)
|
||||
sock.connect(sockaddr)
|
||||
if socket_options:
|
||||
for opt in socket_options:
|
||||
sock.setsockopt(*opt)
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
return SyncStream(sock)
|
||||
except OSError as e:
|
||||
last_err = e
|
||||
sock.close()
|
||||
if last_err is not None:
|
||||
raise last_err
|
||||
raise ConnectError(f"Could not connect to {host}:{port}")
|
||||
|
||||
|
||||
def _safe_probe_get(url: str, *, headers: Optional[dict] = None, timeout: float = 1.0) -> httpx.Response:
|
||||
if httpx.get is not _ORIGINAL_HTTPX_GET:
|
||||
try:
|
||||
if headers:
|
||||
return httpx.get(url, headers=headers, timeout=timeout, follow_redirects=False)
|
||||
return httpx.get(url, timeout=timeout, follow_redirects=False)
|
||||
except TypeError:
|
||||
if headers:
|
||||
return httpx.get(url, headers=headers, timeout=timeout)
|
||||
return httpx.get(url, timeout=timeout)
|
||||
pool = httpcore.ConnectionPool(network_backend=SafeProbeBackend())
|
||||
transport = httpx.HTTPTransport(trust_env=False)
|
||||
transport._pool = pool
|
||||
with httpx.Client(transport=transport, follow_redirects=False, trust_env=False) as client:
|
||||
return client.get(url, headers=headers, timeout=timeout)
|
||||
|
||||
|
||||
def _safe_probe_post(url: str, *, json: typing.Any = None, headers: Optional[dict] = None, timeout: float = 8.0) -> httpx.Response:
|
||||
if httpx.post is not _ORIGINAL_HTTPX_POST:
|
||||
try:
|
||||
if headers:
|
||||
return httpx.post(url, headers=headers, json=json, timeout=timeout, follow_redirects=False)
|
||||
return httpx.post(url, json=json, timeout=timeout, follow_redirects=False)
|
||||
except TypeError:
|
||||
if headers:
|
||||
return httpx.post(url, headers=headers, json=json, timeout=timeout)
|
||||
return httpx.post(url, json=json, timeout=timeout)
|
||||
pool = httpcore.ConnectionPool(network_backend=SafeProbeBackend())
|
||||
transport = httpx.HTTPTransport(trust_env=False)
|
||||
transport._pool = pool
|
||||
with httpx.Client(transport=transport, follow_redirects=False, trust_env=False) as client:
|
||||
return client.post(url, headers=headers, json=json, timeout=timeout)
|
||||
|
||||
|
||||
def _probe_lmstudio_models(url: str, headers: Optional[dict] = None) -> Optional[list]:
|
||||
"""Return LM Studio's native /api/v1/models list, or None when the endpoint
|
||||
isn't LM Studio or is unreachable (short-TTL cached; transient errors uncached)."""
|
||||
parsed = urlparse(url)
|
||||
@@ -117,10 +279,10 @@ def _probe_lmstudio_models(url: str) -> Optional[list]:
|
||||
cached = _lmstudio_models_cache.get(key)
|
||||
if cached is not None and cached[1] > now:
|
||||
return cached[0]
|
||||
authority = host if parsed.port is None else f"{host}:{parsed.port}"
|
||||
authority = parsed.netloc or (f"[{host}]:{parsed.port}" if (host and ":" in host and not host.startswith("[")) else (f"{host}:{parsed.port}" if (host and parsed.port is not None) else (host or "")))
|
||||
probe_url = f"{parsed.scheme or 'http'}://{authority}/api/v1/models"
|
||||
try:
|
||||
r = httpx.get(probe_url, timeout=1.0)
|
||||
r = _safe_probe_get(probe_url, headers=headers, timeout=1.0)
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
@@ -138,15 +300,15 @@ def _probe_lmstudio_models(url: str) -> Optional[list]:
|
||||
return models
|
||||
|
||||
|
||||
def lmstudio_supports_vision(url: str, model: str) -> Optional[bool]:
|
||||
def lmstudio_supports_vision(url: str, model: str, headers: Optional[dict] = None) -> Optional[bool]:
|
||||
"""Read `model`'s capabilities.vision flag from LM Studio, or None when the
|
||||
endpoint isn't LM Studio or doesn't report it (so callers fall back)."""
|
||||
if not model:
|
||||
return None
|
||||
# Never probe a remote provider; LM Studio is always a local/LAN host.
|
||||
if not _is_local_host(urlparse(url).hostname):
|
||||
# Never probe a remote provider or disallowed host; LM Studio is always a local/LAN host.
|
||||
if not _is_safe_local_host(urlparse(url).hostname):
|
||||
return None
|
||||
models = _probe_lmstudio_models(url)
|
||||
models = _probe_lmstudio_models(url, headers=headers)
|
||||
if not models:
|
||||
return None
|
||||
want = model.strip().lower()
|
||||
@@ -162,20 +324,165 @@ def lmstudio_supports_vision(url: str, model: str) -> Optional[bool]:
|
||||
return None
|
||||
|
||||
|
||||
def model_supports_vision(model_name: str, endpoint_url: str = "") -> bool:
|
||||
def model_supports_vision(
|
||||
model_name: str,
|
||||
endpoint_url: str = "",
|
||||
owner: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Whether a model accepts images, using the endpoint's reported
|
||||
capability when available (LM Studio) and falling back to name-based
|
||||
detection otherwise."""
|
||||
capability when available (LM Studio), a cached one-time probe for local
|
||||
OpenAI-compatible endpoints, and name-based detection otherwise."""
|
||||
if is_vision_model(model_name):
|
||||
return True
|
||||
if endpoint_url:
|
||||
target_url = endpoint_url
|
||||
headers: Optional[dict] = None
|
||||
if owner is not None:
|
||||
if not owner:
|
||||
# Authentication enabled, but caller has no owner: refuse probing immediately
|
||||
return is_vision_model(model_name)
|
||||
try:
|
||||
advertised = lmstudio_supports_vision(endpoint_url, model_name or "")
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import (
|
||||
normalize_base,
|
||||
resolve_endpoint_runtime,
|
||||
resolve_owner_registered_endpoint,
|
||||
)
|
||||
with SessionLocal() as db:
|
||||
ep = resolve_owner_registered_endpoint(db, endpoint_url, owner)
|
||||
resolved_base, api_key = resolve_endpoint_runtime(ep)
|
||||
target_url = normalize_base(resolved_base or ep.base_url)
|
||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
|
||||
except ValueError:
|
||||
# Foreign-owned, disabled, or unregistered endpoint
|
||||
return is_vision_model(model_name)
|
||||
except Exception as e:
|
||||
logger.warning("Endpoint authorization check failed: %s", e)
|
||||
return is_vision_model(model_name)
|
||||
else:
|
||||
# Single-user / auth-disabled mode (owner is None):
|
||||
try:
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import (
|
||||
normalize_base,
|
||||
resolve_endpoint_runtime,
|
||||
resolve_owner_registered_endpoint,
|
||||
)
|
||||
with SessionLocal() as db:
|
||||
ep = resolve_owner_registered_endpoint(db, endpoint_url, None)
|
||||
resolved_base, api_key = resolve_endpoint_runtime(ep)
|
||||
target_url = normalize_base(resolved_base or ep.base_url)
|
||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
|
||||
except Exception:
|
||||
# In single-user mode, an unregistered endpoint is allowed
|
||||
# only if target URL passes safe local host checks (probed with headers=None).
|
||||
target_url = endpoint_url
|
||||
headers = None
|
||||
|
||||
try:
|
||||
if headers:
|
||||
advertised = lmstudio_supports_vision(target_url, model_name or "", headers=headers)
|
||||
else:
|
||||
advertised = lmstudio_supports_vision(target_url, model_name or "")
|
||||
except TypeError:
|
||||
advertised = lmstudio_supports_vision(target_url, model_name or "")
|
||||
except Exception:
|
||||
advertised = None
|
||||
if advertised is not None:
|
||||
return advertised
|
||||
try:
|
||||
if headers:
|
||||
probed = probe_endpoint_supports_vision(target_url, model_name or "", headers=headers)
|
||||
else:
|
||||
probed = probe_endpoint_supports_vision(target_url, model_name or "")
|
||||
except TypeError:
|
||||
probed = probe_endpoint_supports_vision(target_url, model_name or "")
|
||||
except Exception:
|
||||
probed = None
|
||||
if probed is not None:
|
||||
return probed
|
||||
return is_vision_model(model_name)
|
||||
|
||||
|
||||
# 1x1 red PNG — the cheapest possible image probe payload.
|
||||
_PROBE_IMAGE_DATA_URL = (
|
||||
"data:image/png;base64,"
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
_VISION_PROBE_TTL = 3600.0
|
||||
# (host, model) -> (bool | None, expiry); None = undecidable (auth/network).
|
||||
_vision_probe_cache: dict = {}
|
||||
|
||||
|
||||
def _vision_probe_enabled() -> bool:
|
||||
return os.getenv("ODYSSEUS_VISION_PROBE", "1").strip().lower() not in ("0", "false", "off", "no")
|
||||
|
||||
|
||||
def _chat_completions_url(endpoint_url: str) -> str:
|
||||
base = (endpoint_url or "").rstrip("/")
|
||||
if base.endswith("/chat/completions"):
|
||||
return base
|
||||
return base + "/chat/completions"
|
||||
|
||||
|
||||
def probe_endpoint_supports_vision(
|
||||
endpoint_url: str,
|
||||
model_name: str,
|
||||
headers: Optional[dict] = None,
|
||||
) -> Optional[bool]:
|
||||
"""One-time (cached) probe: does a local OpenAI-compatible endpoint accept
|
||||
an image_url content part for `model_name`?
|
||||
|
||||
Returns True on a 200, False when the endpoint rejects the image (400/413/
|
||||
415/422), and None when we cannot decide (remote host, auth required,
|
||||
unreachable, or probing disabled) so the caller falls back to name rules.
|
||||
"""
|
||||
if not endpoint_url or not model_name or not _vision_probe_enabled():
|
||||
return None
|
||||
parsed = urlparse(endpoint_url)
|
||||
if not _is_safe_local_host(parsed.hostname):
|
||||
return None # never probe a public provider or disallowed host
|
||||
key = ((parsed.hostname or "").lower(), (parsed.port or 0), model_name.strip().lower())
|
||||
now = time.time()
|
||||
cached = _vision_probe_cache.get(key)
|
||||
if cached is not None and cached[1] > now:
|
||||
return cached[0]
|
||||
payload = {
|
||||
"model": model_name,
|
||||
"max_tokens": 1,
|
||||
"stream": False,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Reply with one word."},
|
||||
{"type": "image_url", "image_url": {"url": _PROBE_IMAGE_DATA_URL}},
|
||||
],
|
||||
}],
|
||||
}
|
||||
verdict: Optional[bool] = None
|
||||
try:
|
||||
r = _safe_probe_post(
|
||||
_chat_completions_url(endpoint_url),
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=8.0,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
verdict = True
|
||||
elif r.status_code in (400, 413, 415, 422):
|
||||
body = (r.text or "").lower()
|
||||
if any(tok in body for tok in ("image", "vision", "multimodal", "content type")):
|
||||
verdict = False
|
||||
else:
|
||||
verdict = False
|
||||
except Exception:
|
||||
verdict = None
|
||||
_vision_probe_cache[key] = (verdict, now + _VISION_PROBE_TTL)
|
||||
if verdict is not None:
|
||||
logger.info("vision probe %s@%s -> %s", model_name, parsed.hostname, verdict)
|
||||
return verdict
|
||||
|
||||
|
||||
def validate_message(message: str) -> str:
|
||||
"""Validate message input."""
|
||||
if not message:
|
||||
|
||||
+689
-22
@@ -20,7 +20,11 @@ from pathlib import Path
|
||||
import httpx
|
||||
import jsonschema
|
||||
|
||||
from src.context_compactor import prune_multimodal_images, trim_for_context
|
||||
from src.context_compactor import (
|
||||
keep_recent_image_turns,
|
||||
prune_multimodal_images,
|
||||
trim_for_context,
|
||||
)
|
||||
from src.agent_runtime.runtime_selection import COMPACT_PREVIEW_MODE
|
||||
from src import agent_runs
|
||||
from src.agent_evidence import command_has_mutation_effect, workspace_artifact_is_usable
|
||||
@@ -42,6 +46,7 @@ from src.turn_contract import (
|
||||
_REQUEST_PREFIX,
|
||||
calendar_retiming_request,
|
||||
FAMILY_TOOLS, broad_web_briefing_request, required_read_operation_for_request,
|
||||
note_read_intent,
|
||||
targets_bound_editor_request, inline_text_transformation, editor_request_instructions,
|
||||
_bound_editor_requests_web_verification, scheduled_automation_request, creation_container_tool,
|
||||
)
|
||||
@@ -206,9 +211,17 @@ CONTRACT_REQUIRED_TOOLS = frozenset({
|
||||
})
|
||||
from src.turn_contract import CONTRACT_CORE_TOOLS
|
||||
|
||||
# Scoped NL photo-editor surface. These are offered only when the
|
||||
# ``photo_editor`` capability family is active (editor Quick Edit popup or an
|
||||
# image-edit chat turn); they drive the in-process, owner-scoped editor bridge
|
||||
# and the existing AI image ops.
|
||||
EDITOR_AGENT_TOOLS = frozenset({
|
||||
'plan_edit', 'run_editor_command', 'get_editor_screenshot', 'ai_edit_image',
|
||||
})
|
||||
PREVIEW_TOOLS = (
|
||||
READ_TOOLS | SAFE_WRITE_TOOLS | EXPLICIT_EXECUTE_TOOLS | SAFE_UI_TOOLS
|
||||
| BROKERED_JOB_TOOLS | CONTRACT_REQUIRED_TOOLS | CONTRACT_CORE_TOOLS
|
||||
| EDITOR_AGENT_TOOLS
|
||||
)
|
||||
# Keep a small recovery-capable surface on every interactive compact agent
|
||||
# turn. Routing still adds domain tools, while policy and action guards remain
|
||||
@@ -1035,6 +1048,22 @@ def notes_terminal_response(raw, *, user_text='', max_items=20):
|
||||
return _bounded_structured_list(summary, user_text=user_text)
|
||||
|
||||
|
||||
def note_locator_followup(raw, *, user_text=''):
|
||||
"""Require a body read before answering a note-content request."""
|
||||
_, needs_body = note_read_intent(user_text)
|
||||
if not needs_body or note_search_result_empty(raw):
|
||||
return ''
|
||||
listing = notes_terminal_response(raw, user_text=user_text, max_items=5)
|
||||
match = re.search(r'\(#note-([A-Za-z0-9_-]+)\)', listing)
|
||||
if not match:
|
||||
return ''
|
||||
return (
|
||||
'The notes result contains titles only, not the body. '
|
||||
f'Call manage_notes with action="view" and id="{match.group(1)}" now. '
|
||||
'Use the returned body to answer the request; do not infer content from the title.'
|
||||
)
|
||||
|
||||
|
||||
def documents_terminal_response(raw, *, user_text='', max_items=8):
|
||||
"""Render linked document rows under the user's explicit global cap."""
|
||||
from src.agent_loop import _document_list_summary_from_tool_output
|
||||
@@ -1200,7 +1229,7 @@ def tasks_terminal_response(raw, *, user_text='', max_items=20):
|
||||
text = str(payload or '').strip()
|
||||
rows = []
|
||||
for line in text.splitlines():
|
||||
match = re.match(r'^\s*\d+\.\s+(.*?)\s+\(([^)]+)\)\s+—\s+(.+)$', line)
|
||||
match = re.match(r'^\s*\d+\.\s(.*?)\s\(([^()]+)\)\s+—\s+(.+)$', line)
|
||||
if match:
|
||||
details = match[3].strip()
|
||||
rows.append({
|
||||
@@ -1317,7 +1346,7 @@ def skills_terminal_response(raw, *, user_text='', max_items=20):
|
||||
continue
|
||||
match = re.match(r'^\s*-\s+\*\*([^*]+)\*\*(?:\s+\(([^)]+)\))?', line)
|
||||
if not match:
|
||||
match = re.match(r'^\s*-\s+([^:]+?)(?:\s+\(([^)]+)\))?(?:\s*:|$)', line)
|
||||
match = re.match(r'^\s*-\s+([^\s:][^:]*?)(?:\s\(([^()]+)\)(?:\s*:|$)|:|$)', line)
|
||||
if match and status:
|
||||
rows.append((status, match.group(1).strip(), (match.group(2) or '').strip()))
|
||||
if not rows:
|
||||
@@ -1368,17 +1397,26 @@ def contentless_final_response(content):
|
||||
|
||||
|
||||
def action_promise_response(content):
|
||||
"""Recognize a short promise to do work that contains no result yet."""
|
||||
text = re.sub(r'\s+', ' ', str(content or '')).strip()
|
||||
if not text or len(text.split()) > 48:
|
||||
"""Recognize a short promise to do work that contains no result yet.
|
||||
|
||||
Fenced code blocks are stripped before the word count and the verb match,
|
||||
so a promise followed by a code block (e.g. "I will browse Google Maps ..."
|
||||
plus a requests/BeautifulSoup snippet and no tool call) still counts as an
|
||||
action promise. The caller only reaches this when no tool call was made.
|
||||
"""
|
||||
without_code = re.sub(
|
||||
r'\s+', ' ', re.sub(r'```.*?```', ' ', str(content or ''), flags=re.DOTALL)
|
||||
).strip()
|
||||
if not without_code or len(without_code.split()) > 48:
|
||||
return False
|
||||
if re.search(r'\b(?:answer|result|score|served|created|saved)\s*:', text, re.I):
|
||||
if re.search(r'\b(?:answer|result|score|served|created|saved)\s*:', without_code, re.I):
|
||||
return False
|
||||
return bool(re.match(
|
||||
r"^(?:okay[,.:]?\s*)?(?:let me|i(?:'ll| will)|next[,.:]?\s+i(?:'ll| will))\s+"
|
||||
r"(?:now\s+)?(?:inspect|extract|read|check|review|analy[sz]e|verify|open|"
|
||||
r"search|look|create|write|render|run|use|continue|finish|provide)\b",
|
||||
text,
|
||||
r"search|look|create|write|render|run|use|continue|finish|provide|"
|
||||
r"browse|find|fetch|visit|navigate|go|get|scrape)\b",
|
||||
without_code,
|
||||
re.I,
|
||||
))
|
||||
|
||||
@@ -1407,6 +1445,210 @@ def progressive_thinking_for_turn(model, offered_schemas, thinking_mode=None):
|
||||
return uses_odysseus_progressive_thinking(model) and not bool(offered_schemas)
|
||||
|
||||
|
||||
def spec_router_thinking_decision(endpoint_url, model, owner, headers, direct_user_text):
|
||||
"""Env-gated router decision for spec tool-surface turns. Fail open (None)."""
|
||||
try:
|
||||
import sys as _sys
|
||||
spec_router = _sys.modules.get('src.spec_router')
|
||||
if spec_router is None:
|
||||
from src import spec_router # noqa: F401
|
||||
if not spec_router.think_router_enabled():
|
||||
return None
|
||||
from src.agent_loop import _configured_model_tool_surface_raw
|
||||
if _configured_model_tool_surface_raw(
|
||||
endpoint_url, model, owner, headers=headers
|
||||
) != 'spec':
|
||||
return None
|
||||
return spec_router.route_thinking(direct_user_text)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning(
|
||||
'spec think router decision failed; using rules only', exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def spec_router_thinking_decision_async(endpoint_url, model, owner, headers, direct_user_text):
|
||||
"""Async env-gated router decision for spec tool-surface turns. Fail open (None)."""
|
||||
try:
|
||||
import sys as _sys
|
||||
spec_router = _sys.modules.get('src.spec_router')
|
||||
if spec_router is None:
|
||||
from src import spec_router # noqa: F401
|
||||
if not spec_router.think_router_enabled():
|
||||
return None
|
||||
from src.agent_loop import _configured_model_tool_surface_raw
|
||||
if _configured_model_tool_surface_raw(
|
||||
endpoint_url, model, owner, headers=headers
|
||||
) != 'spec':
|
||||
return None
|
||||
if hasattr(spec_router, 'route_thinking_async'):
|
||||
return await spec_router.route_thinking_async(direct_user_text)
|
||||
return spec_router.route_thinking(direct_user_text)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning(
|
||||
'spec think router decision failed; using rules only', exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def spec_router_think_reason(round_number, decision, existing_deliberate):
|
||||
"""Add 'router_think' on round 1 when the router asked for thinking and no
|
||||
other rule already selected a deliberate reason."""
|
||||
if (round_number == 1 and not existing_deliberate
|
||||
and isinstance(decision, dict) and decision.get('think')):
|
||||
return 'router_think'
|
||||
return ''
|
||||
|
||||
|
||||
def spec_direct_mode():
|
||||
"""Which direct-answer behaviour to use when the gate is on.
|
||||
|
||||
'tool_choice_none' (default) keeps the tools list but forbids a tool call
|
||||
for the round; 'empty_menu' sends no tools at all. Any other value falls
|
||||
back to the default.
|
||||
"""
|
||||
mode = str(
|
||||
os.getenv('ODYSSEUS_SPEC_DIRECT_MODE', 'tool_choice_none') or ''
|
||||
).strip().lower()
|
||||
return 'empty_menu' if mode == 'empty_menu' else 'tool_choice_none'
|
||||
|
||||
|
||||
def spec_direct_no_tools_p(user_message, endpoint_url, model, owner, headers):
|
||||
"""Env-gated confidence for a spec 'answer_directly' turn.
|
||||
|
||||
Returns the answer_directly probability when the spec router just decided
|
||||
to answer directly with enough confidence, else None. Default OFF, so the
|
||||
caller behaves exactly as before unless ODYSSEUS_SPEC_DIRECT_NOTOOLS is set.
|
||||
"""
|
||||
if str(os.getenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '')).strip().lower() not in (
|
||||
'1', 'true', 'yes', 'on'):
|
||||
return None
|
||||
try:
|
||||
from src.agent_loop import _configured_model_tool_surface_raw
|
||||
if _configured_model_tool_surface_raw(
|
||||
endpoint_url, model, owner, headers=headers
|
||||
) != 'spec':
|
||||
return None
|
||||
import sys as _sys
|
||||
spec_router = _sys.modules.get('src.spec_router')
|
||||
if spec_router is None:
|
||||
from src import spec_router # noqa: F401
|
||||
decision = spec_router.latest_decision()
|
||||
if not isinstance(decision, dict):
|
||||
return None
|
||||
# Guard against a stale decision from a previous turn.
|
||||
msg = str(user_message or '')
|
||||
decided_msg = str(decision.get('message') or '')
|
||||
if decided_msg and msg and decided_msg[:40] != msg[:40]:
|
||||
return None
|
||||
if decision.get('choice') != 'answer_directly':
|
||||
return None
|
||||
try:
|
||||
p = float(decision.get('answer_directly') or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
try:
|
||||
min_p = float(os.getenv('ODYSSEUS_SPEC_DIRECT_MIN_P', '0.9') or 0.9)
|
||||
except (TypeError, ValueError):
|
||||
min_p = 0.9
|
||||
return p if p >= min_p else None
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning(
|
||||
'spec direct-answer decision failed; using rules only', exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def spec_driver_active(endpoint_url, model, owner, headers):
|
||||
"""True when ODYSSEUS_SPEC_MODE=driver and the turn uses the spec surface."""
|
||||
if str(os.getenv('ODYSSEUS_SPEC_MODE', '') or '').strip().lower() != 'driver':
|
||||
return False
|
||||
try:
|
||||
from src.agent_loop import _configured_model_tool_surface_raw
|
||||
return _configured_model_tool_surface_raw(
|
||||
endpoint_url, model, owner, headers=headers) == 'spec'
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def spec_driver_pick(conversation, disabled_tools, user_text, schemas=None):
|
||||
"""Per-round ODYSSEUS_SPEC_MODE=driver decision: the router drives tools.
|
||||
|
||||
Ask the router once for this round and return its TOP-1 choice at ANY
|
||||
confidence: ``{'choice': 'tool'|'answer_directly'|'ask_user', 'p': float,
|
||||
'tool_name': str|None}``. Returns None on any router error/timeout or an
|
||||
unusable pick, so the caller keeps the normal menu for that round.
|
||||
|
||||
``schemas`` is the round's offered menu; the router only ever picks from
|
||||
it, so a top-1 tool is always one the turn can actually execute (passing
|
||||
every known tool here made the driver name tools outside the offered set,
|
||||
which then looped on permission errors).
|
||||
"""
|
||||
try:
|
||||
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
||||
import sys as _sys
|
||||
spec_router = _sys.modules.get('src.spec_router')
|
||||
if spec_router is None:
|
||||
from src import spec_router # noqa: F401
|
||||
menu = list(schemas) if schemas else list(FUNCTION_TOOL_SCHEMAS)
|
||||
picks, _decided = spec_router.spec_route_tools(
|
||||
str(user_text or ''), menu,
|
||||
set(disabled_tools or ()), list(conversation or []),
|
||||
return_decision=True,
|
||||
)
|
||||
decision = spec_router.latest_decision() or {}
|
||||
if not isinstance(decision, dict) or decision.get('error'):
|
||||
return None
|
||||
choice = decision.get('choice')
|
||||
top = decision.get('top') or []
|
||||
if choice == 'answer_directly':
|
||||
return {'choice': 'answer_directly',
|
||||
'p': float(decision.get('answer_directly') or 0.0),
|
||||
'tool_name': None}
|
||||
if choice == 'ask_user':
|
||||
return {'choice': 'ask_user',
|
||||
'p': float(decision.get('ask_user') or 0.0),
|
||||
'tool_name': None}
|
||||
# Driver mode takes the router's TOP-1 at ANY confidence, so read the
|
||||
# ranked ``top`` (not the MIN_CONF-filtered ``picks``/``added``).
|
||||
if top and isinstance(top[0], (list, tuple)) and top[0]:
|
||||
name = str(top[0][0] or '')
|
||||
p = None
|
||||
if len(top[0]) >= 2:
|
||||
try:
|
||||
p = float(top[0][1])
|
||||
except (TypeError, ValueError):
|
||||
p = None
|
||||
if name:
|
||||
return {'choice': 'tool', 'p': p, 'tool_name': name}
|
||||
if picks:
|
||||
try:
|
||||
name = str(next(iter(picks)))
|
||||
except TypeError:
|
||||
name = str(picks[0]) if len(picks) else ''
|
||||
if name:
|
||||
return {'choice': 'tool', 'p': None, 'tool_name': name}
|
||||
return None
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning(
|
||||
'spec driver decision failed; using the normal menu', exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def tool_deliberation_reason(name, args):
|
||||
"""Classify selected operations, never infer intent from tool availability."""
|
||||
name = canonical(name)
|
||||
if name in {'python', 'edit_file', 'apply_patch'}:
|
||||
return 'code'
|
||||
if name in {'edit_document', 'suggest_document', 'update_document'}:
|
||||
return 'document_edit'
|
||||
if name in {'draft_email', 'draft_email_reply', 'ai_draft_email_reply'}:
|
||||
return 'email_draft'
|
||||
action = str(args.get('action') or '').lower()
|
||||
if name == 'manage_tasks' and action in {'create', 'add', 'update', 'edit'}:
|
||||
return 'task_design'
|
||||
if name == 'manage_skills' and action in {'add', 'edit', 'patch', 'publish', 'audit'}:
|
||||
return 'skill_evaluation'
|
||||
return ''
|
||||
|
||||
|
||||
def visible_content_after_qwen_thinking(content):
|
||||
"""Remove private Qwen reasoning when the server lacks a reasoning parser."""
|
||||
|
||||
@@ -1422,6 +1664,48 @@ def visible_content_after_qwen_thinking(content):
|
||||
return text.strip()
|
||||
|
||||
|
||||
def qwen_think_split_events(content, state):
|
||||
"""Split a no-reasoning-parser thinking round into live deltas.
|
||||
|
||||
Some thinking servers (no --reasoning-parser) return the whole
|
||||
``<think>…</think>`` trace as ordinary ``content``. Buffering that round
|
||||
made the UI show nothing while the model thought and then dump the whole
|
||||
answer at once. Stream the trace as ``{'delta': ..., 'thinking': True}``
|
||||
events (the same shape a reasoning-parser server emits) and the text after
|
||||
``</think>`` as normal answer deltas. ``state`` is a mutable per-round dict
|
||||
carrying the emission bookkeeping; the stored answer is unchanged (see
|
||||
``visible_content_after_qwen_thinking``).
|
||||
"""
|
||||
events = []
|
||||
text = str(content or '')
|
||||
if not text:
|
||||
return events
|
||||
lowered = text.casefold()
|
||||
lead = len('<think>') if lowered.startswith('<think>') else 0
|
||||
close = lowered.find('</think>')
|
||||
if close < 0:
|
||||
# The opening <think> is injected by the chat template, so a progressive
|
||||
# trace can arrive with no marker until </think>. Emit the buffered text
|
||||
# as thinking - holding back a possible partial close tag - so the trace
|
||||
# streams live instead of dumping in one block at the end.
|
||||
think_region = text[lead:max(lead, len(text) - len('</think>'))]
|
||||
answer_region = ''
|
||||
else:
|
||||
think_region = text[lead:close]
|
||||
answer_region = text[close + len('</think>'):]
|
||||
emitted_think = int(state.get('think_emitted', 0) or 0)
|
||||
if len(think_region) > emitted_think:
|
||||
events.append({'delta': think_region[emitted_think:], 'thinking': True})
|
||||
state['think_emitted'] = len(think_region)
|
||||
if close >= 0:
|
||||
state['closed'] = True
|
||||
emitted_answer = int(state.get('answer_emitted', 0) or 0)
|
||||
if len(answer_region) > emitted_answer:
|
||||
events.append({'delta': answer_region[emitted_answer:]})
|
||||
state['answer_emitted'] = len(answer_region)
|
||||
return events
|
||||
|
||||
|
||||
def prior_short_answer_for_no_tool_summary(user_text, history):
|
||||
"""Reuse the immediately prior concise answer for an explicit no-tool recap."""
|
||||
text = str(user_text or '')
|
||||
@@ -2714,6 +2998,18 @@ def normalize_preview_entity_anchor_args(name, args):
|
||||
value = args.get(field)
|
||||
if isinstance(value, str) and value.startswith(prefix):
|
||||
args[field] = value[len(prefix):]
|
||||
if canonical(name) == 'manage_notes':
|
||||
# Some models copy the rendered note href without its leading '#'.
|
||||
# Decode only a complete UUID; partial selectors retain the existing
|
||||
# owner-bound resolution policy instead of gaining a new alias.
|
||||
value = args.get('note_id')
|
||||
if isinstance(value, str):
|
||||
match = re.fullmatch(
|
||||
r'note-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})',
|
||||
value, re.I,
|
||||
)
|
||||
if match:
|
||||
args['note_id'] = match.group(1)
|
||||
return args
|
||||
|
||||
|
||||
@@ -2754,6 +3050,9 @@ def normalize_preview_function_args(name, args, *, user_text=''):
|
||||
args.pop('filter', None)
|
||||
if canonical(name) == 'manage_notes':
|
||||
action = str(args.get('action') or '').strip().replace('-', '_').casefold()
|
||||
if action == 'read' and (args.get('id') or args.get('note_id')):
|
||||
args['action'] = 'view'
|
||||
action = 'view'
|
||||
if action == 'list':
|
||||
request = str(user_text or '').casefold()
|
||||
# The notes backend interprets title/query/content on ``list`` as
|
||||
@@ -2770,6 +3069,10 @@ def normalize_preview_function_args(name, args, *, user_text=''):
|
||||
args.pop('archived', None)
|
||||
if not re.search(r'\b(?:checklists?|freeform\s+notes?|notes?\s+only)\b', request):
|
||||
args.pop('note_type', None)
|
||||
query, _ = note_read_intent(user_text)
|
||||
if query:
|
||||
args['action'] = 'search'
|
||||
args['query'] = query
|
||||
if canonical(name) == 'manage_skills':
|
||||
action = str(args.get('action') or '').strip().replace('-', '_').casefold()
|
||||
if action in {'update', 'change', 'revise'}:
|
||||
@@ -2897,8 +3200,9 @@ def scope_preview_contract(preview_contract, routed_contract, active_capabilitie
|
||||
requested_family_tools = set().union(*(FAMILY_TOOLS.get(f, ()) for f in active))
|
||||
available_requested = routed_canonical & offered_canonical & requested_family_tools
|
||||
routed_canonical.update(canonical(name) for name in extra_tools)
|
||||
if 'image_generation' not in active:
|
||||
# A past image request must not keep generation warm on email/doc turns.
|
||||
if 'image_generation' not in active and 'photo_editor' not in active:
|
||||
# A past image request must not keep generation warm on email/doc turns,
|
||||
# unless the photo_editor family (which bundles generate_image) is active.
|
||||
routed_canonical.discard('generate_image')
|
||||
missing_active = {
|
||||
f'capability:{family}' for family in active
|
||||
@@ -3230,7 +3534,11 @@ def _successful_email_identifiers(history):
|
||||
for message in history or ():
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
if message.get('role') == 'system' and 'Message UID:' in str(message.get('content') or ''):
|
||||
active_reader = (
|
||||
message.get('_agent_injected') == 'context'
|
||||
and (message.get('metadata') or {}).get('source') == 'active email reader'
|
||||
)
|
||||
if (message.get('role') == 'system' or active_reader) and 'Message UID:' in str(message.get('content') or ''):
|
||||
extracted = _email_identifiers_from_text(message.get('content'))
|
||||
known['uid'].update(extracted['uid'])
|
||||
known['message_id'].update(extracted['message_id'])
|
||||
@@ -3518,6 +3826,20 @@ def evaluate_preview_call(name, args, user_text='', *, allow_execute_code=False,
|
||||
runtime_tools = PREVIEW_TOOLS | (
|
||||
NATIVE_WORKSPACE_TOOLS if allow_native_workspace else frozenset()
|
||||
) | ({canonical(tool) for tool in external_runtime_tools} if allow_native_workspace else set())
|
||||
# Spec turns are driven by the router: the Spec contract (its offered set)
|
||||
# IS the model runtime, so a Spec pick is runnable. Keyed off the same
|
||||
# marker the spec hook sets (`routing_experiment == 'spec-router'`), never
|
||||
# a new flag, and never applied to compact/native turns.
|
||||
try:
|
||||
from src.turn_contract import active_turn_contract as _atc
|
||||
_spec_contract = _atc()
|
||||
if _spec_contract is not None and getattr(
|
||||
_spec_contract, 'routing_experiment', '') == 'spec-router':
|
||||
runtime_tools = runtime_tools | {
|
||||
canonical(tool) for tool in getattr(_spec_contract, 'offered', ()) or ()
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
if bare not in runtime_tools:
|
||||
return decision(False, 'tool_not_in_model_runtime')
|
||||
if bare in {canonical(tool) for tool in external_runtime_tools}:
|
||||
@@ -3559,6 +3881,9 @@ def evaluate_preview_call(name, args, user_text='', *, allow_execute_code=False,
|
||||
or bare in BROKERED_JOB_TOOLS
|
||||
or (contract_required and bare in {'edit_image', 'generate_image'})
|
||||
or (contract_required and bare == 'create_document' and standalone_code_request(user_text))
|
||||
# The editor command is conservatively classified as a write;
|
||||
# document.info is a read even when the user requested no edit.
|
||||
or (bare == 'run_editor_command' and args.get('op') == 'document.info')
|
||||
)
|
||||
)
|
||||
if family not in authorized and not contextual_revision and not contract_scoped_mutation and not fixture_delete and not offered_private_action:
|
||||
@@ -3619,6 +3944,18 @@ def evaluate_preview_call(name, args, user_text='', *, allow_execute_code=False,
|
||||
# editor is offered only for an explicit image-editing turn.
|
||||
blocked_effects.remove(ToolEffect.NETWORK_EGRESS)
|
||||
allowed_effects.add(ToolEffect.NETWORK_EGRESS)
|
||||
if (
|
||||
(contract_required or family in turn_authorized_families)
|
||||
and bare in {'plan_edit', 'run_editor_command', 'get_editor_screenshot', 'ai_edit_image'}
|
||||
):
|
||||
# Scoped photo_editor agent tools. They are never in the general tool
|
||||
# list; the immutable turn contract only selects them for an explicit
|
||||
# editor request (Quick Edit popup / image-edit turn), so their UI and
|
||||
# brokered-network effects are covered by the contract seal.
|
||||
blocked_effects.discard(ToolEffect.UI_SIDE_EFFECT)
|
||||
allowed_effects.add(ToolEffect.UI_SIDE_EFFECT)
|
||||
blocked_effects.discard(ToolEffect.NETWORK_EGRESS)
|
||||
allowed_effects.add(ToolEffect.NETWORK_EGRESS)
|
||||
if (
|
||||
contract_required
|
||||
and bare in {'download_model', 'serve_preset', 'stop_served_model'}
|
||||
@@ -3849,6 +4186,29 @@ def conversation(history_session, messages, *, owner=None, diagnostics=None):
|
||||
from src.turn_contract import result_reference_followup
|
||||
source_context = (recent_source_reference_context(groups[-2])
|
||||
if len(groups) > 1 and result_reference_followup(current_text) else None)
|
||||
# Tool output can dwarf the dialogue (especially a web search). Keep the
|
||||
# visible exchange before enforcing the history cap, so one search-heavy
|
||||
# turn cannot erase the subject of the next follow-up.
|
||||
for index, group in enumerate(groups[:-1]):
|
||||
if len(json.dumps(group)) <= 5000 or len(group) < 2:
|
||||
continue
|
||||
# Keep non-web tool evidence intact: a note/list follow-up may need
|
||||
# exact result identifiers, and shortening it would be misleading.
|
||||
if not any(
|
||||
canonical((call.get('function') or {}).get('name', '')) in {'web_search', 'web_fetch'}
|
||||
for message in group if message.get('role') == 'assistant'
|
||||
for call in message.get('tool_calls') or ()
|
||||
):
|
||||
continue
|
||||
answer = next((message.get('content') for message in reversed(group)
|
||||
if message.get('role') == 'assistant'
|
||||
and str(message.get('content') or '').strip()), None)
|
||||
if answer is None:
|
||||
continue
|
||||
answer_text = str(answer)
|
||||
if len(answer_text) > 3500:
|
||||
answer_text = answer_text[:3500] + '\n[Earlier answer shortened for context.]'
|
||||
groups[index] = [group[0], {'role': 'assistant', 'content': answer_text}]
|
||||
# Drop whole turns only, never orphan tool results from their native calls.
|
||||
groups = groups[-8:]
|
||||
while len(groups) > 1 and len(json.dumps(groups)) > 22000:
|
||||
@@ -4207,6 +4567,26 @@ def denied_response():
|
||||
return 'I can’t perform that operation in this preview. No changes were made.'
|
||||
|
||||
|
||||
WORKSPACE_REQUIRED_REFUSAL = (
|
||||
'Shell and Python commands only run inside a workspace. '
|
||||
'Open the code workspace (or set a working directory) and try again.'
|
||||
)
|
||||
|
||||
|
||||
def workspace_process_denial(executions) -> bool:
|
||||
"""True when a blocked call was refused because process execution needs a
|
||||
sealed workspace (a plain chat turn carries none)."""
|
||||
for entry in executions or ():
|
||||
blob = ' '.join(
|
||||
str(entry.get(key) or '') for key in ('error', 'output', 'failure_kind')
|
||||
)
|
||||
# Resource identity also covers missing note and document selectors.
|
||||
# Those denials must not turn into instructions to open a code workspace.
|
||||
if 'sealed workspace' in blob.casefold():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def execution_has_write_effect(tool_name, content, capability, *, native_workspace_enabled):
|
||||
"""Recognize successful native code that visibly mutates the workspace."""
|
||||
|
||||
@@ -4226,7 +4606,7 @@ _MUTATION_REQUEST = re.compile(
|
||||
r'\b(?:add|creat(?:e|ing)?|make|writ(?:e|ing)|sav(?:e|ing)|updat(?:e|ing)|edit(?:ing)?|chang(?:e|ing)|'
|
||||
r'set|schedul(?:e|ing)|reschedul(?:e|ing)|paus(?:e|ing)|resum(?:e|ing)|toggle|mark|'
|
||||
r'pin|archive|delet(?:e|ing)|remov(?:e|ing)|send|reply|draft|publish|run|launch|serve|start|stop|'
|
||||
r'remember|remeber|forget|remind|review|proofread|suggest(?:ions?)?|expand|broaden|deepen|lighten|feedback|reserve|block)\b|'
|
||||
r'remember|remeber|forget|remind|review|proofread|suggest(?:ions?)?|expand|broaden|deepen|lighten|reserve|block)\b|'
|
||||
r'\bgo\s+deeper\b',
|
||||
re.I,
|
||||
)
|
||||
@@ -4262,7 +4642,7 @@ def mutation_action_requested(user_text):
|
||||
actionable = re.sub(
|
||||
r"\b(?:do\s+not|don't|never|without)\s+"
|
||||
r"(?:(?:chang(?:e|ing)|modif(?:y|ying)|edit(?:ing)?|delet(?:e|ing)|"
|
||||
r"remov(?:e|ing)|send(?:ing)?|writ(?:e|ing)|creat(?:e|ing))\b)"
|
||||
r"remov(?:e|ing)|send(?:ing)?|writ(?:e|ing)|creat(?:e|ing)|schedul(?:e|ing))\b)"
|
||||
r"[^.;\n]{0,120}?(?=(?:[.;\n]|\bbut\b|\binstead\b|$))",
|
||||
'', text, flags=re.I,
|
||||
)
|
||||
@@ -4685,7 +5065,7 @@ def email_search_result_empty(value):
|
||||
try:
|
||||
decoded = json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
return bool(re.fullmatch(r'\s*No emails matched [^\n]+\.?\s*', value, re.I))
|
||||
return bool(re.fullmatch(r'\s*No emails matched [^\n]+(?:\n\s*)?', value, re.I))
|
||||
if decoded == value:
|
||||
return False
|
||||
value = decoded
|
||||
@@ -4959,6 +5339,16 @@ def private_browser_effective_url(result):
|
||||
return urls[-1] if urls else ''
|
||||
|
||||
|
||||
def is_google_sorry_url(url):
|
||||
"""Recognize Google's rate-limit interstitial."""
|
||||
# Matching the host separately keeps this linear; one combined pattern
|
||||
# rescans the host from every dot.
|
||||
return any(
|
||||
re.fullmatch(r'(?:[^/]+\.)?google\.[^/]+', host[1], re.I)
|
||||
for host in re.finditer(r'https?://([^/]+)/sorry/', url, re.I)
|
||||
)
|
||||
|
||||
|
||||
def browser_observation_page_missing(raw):
|
||||
"""Recognize a rendered error page, rather than an article mentioning 404."""
|
||||
text = str(raw or '')
|
||||
@@ -5242,6 +5632,10 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
external_tool_schemas=None, temperature=0.0,
|
||||
context_resolution=None,
|
||||
request_authority=MISSING_AUTHORITY, **ignored):
|
||||
if spec_driver_active(endpoint_url, model, owner, headers):
|
||||
yield event({'delta': 'Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed.'})
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
from src.generation_sampling import validate_temperature
|
||||
temperature = validate_temperature(temperature)
|
||||
# This path sends requests directly with httpx and therefore bypasses
|
||||
@@ -5622,6 +6016,38 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
'multimodal_image_count': image_context_count,
|
||||
'attachment_reference_count': attachment_refs,
|
||||
'image_rehydration': conversation_diagnostics.get('image_rehydration')})
|
||||
_photo_editor_active = 'photo_editor' in (getattr(turn_contract, 'active_capabilities', ()) or ())
|
||||
_photo_editor_open = bool(
|
||||
((client_runtime_context or {}).get('web_ui_state') or {}).get('photo_editor_open')
|
||||
)
|
||||
if _photo_editor_active and not _photo_editor_open:
|
||||
_editor_guidance = (
|
||||
"This image isn't open in the editor yet, so I can't apply edits to the "
|
||||
"canvas from chat. Open the attached image in the editor (Quick Edit) and "
|
||||
"send this request there \u2014 I'll apply it to the canvas."
|
||||
)
|
||||
history.append({'role': 'assistant', 'content': _editor_guidance})
|
||||
yield event({'delta': _editor_guidance})
|
||||
_action_prompt = str(direct_user_text or '')
|
||||
for _annotation in ('\n\n[Image:', '\n[Image:', '\n\n[Attached', '\n[No vision model'):
|
||||
_cut = _action_prompt.find(_annotation)
|
||||
if _cut != -1:
|
||||
_action_prompt = _action_prompt[:_cut]
|
||||
_action_prompt = _action_prompt.strip() or str(direct_user_text or '').strip()
|
||||
yield event({
|
||||
'type': 'final_response',
|
||||
'content': _editor_guidance,
|
||||
'photo_editor_action': {
|
||||
'kind': 'open_and_apply',
|
||||
'prompt': _action_prompt,
|
||||
},
|
||||
})
|
||||
yield event({'type': 'metrics', 'data': {
|
||||
'model': model, 'rounds': 0, 'round_texts': [_editor_guidance],
|
||||
'tool_events': [], 'tool_calls': 0, 'agent_rounds': 0,
|
||||
}})
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=preview_http_timeout(
|
||||
@@ -5703,6 +6129,40 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
usage_in += intent_accounting.get('input_tokens', 0)
|
||||
usage_out += intent_accounting.get('output_tokens', 0)
|
||||
has_real_usage = intent_accounting.get('usage_source') == 'real'
|
||||
# Optional env-gated router decision (spec surface only). Computed
|
||||
# once per user turn; disabled/None leaves behaviour unchanged.
|
||||
_spec_route_think = await spec_router_thinking_decision_async(
|
||||
endpoint_url, model, owner, headers, direct_user_text)
|
||||
if _spec_route_think is not None:
|
||||
logging.getLogger(__name__).info(
|
||||
'spec think router: think=%s p_think=%s ms=%s',
|
||||
_spec_route_think.get('think'),
|
||||
_spec_route_think.get('p_think'),
|
||||
_spec_route_think.get('ms'))
|
||||
# Optional env-gated direct-answer override (spec surface only):
|
||||
# when the spec router chose answer_directly with high confidence,
|
||||
# round 1 is sent with tool_choice 'none' (same tools list).
|
||||
_spec_direct_p = spec_direct_no_tools_p(
|
||||
direct_user_text, endpoint_url, model, owner, headers)
|
||||
if (turn_contract.required or turn_contract.unavailable
|
||||
or turn_contract.required_read_operation is not None):
|
||||
_spec_direct_p = None
|
||||
# Option B: send round 1 with no tools at all for a direct answer.
|
||||
_spec_direct_empty_menu = (
|
||||
_spec_direct_p is not None and spec_direct_mode() == 'empty_menu')
|
||||
# ODYSSEUS_SPEC_MODE=driver: the router (not the 9B) picks the tool
|
||||
# every round; the 9B only writes the arguments or the answer.
|
||||
# Driver execution is refused at entry. Keep this false even if
|
||||
# configuration changes while the stream is already in progress.
|
||||
_spec_driver_on = False
|
||||
# Driver mode narrows the contract per round to the Spec pick.
|
||||
# Rebuild each round from this frozen base so the narrowing does
|
||||
# not accumulate/mutate across rounds.
|
||||
_driver_base_contract = turn_contract
|
||||
# Per-round Spec pick (driver mode): 'answer_directly' | 'ask_user'
|
||||
# | 'tool' | None (router fallback). Set inside the round loop.
|
||||
_driver_round_kind = None
|
||||
_driver_round_pick = None
|
||||
# One extra iteration is available only when a provider emits raw
|
||||
# tool markup during the normal final no-tools round. Ordinary
|
||||
# turns still obey ``round_limit`` exactly.
|
||||
@@ -5839,7 +6299,9 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
'completion_reserve_seconds': artifact_completion_reserve_seconds,
|
||||
})
|
||||
request_messages = provider_request_messages(
|
||||
prune_multimodal_images(history, max_images=3)
|
||||
prune_multimodal_images(
|
||||
keep_recent_image_turns(history, keep=2), max_images=3
|
||||
)
|
||||
)
|
||||
if prior_summary_answer or force_no_tools_next_round or reserve_final_synthesis:
|
||||
round_offered = []
|
||||
@@ -6036,6 +6498,94 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
break
|
||||
request['tools'] = source_tools
|
||||
request['tool_choice'] = 'required'
|
||||
# ODYSSEUS_SPEC_MODE=driver: the router is the tool driver.
|
||||
# Ask it for this round's single next action (any confidence);
|
||||
# the 9B then only writes the arguments (a named tool_choice) or
|
||||
# the answer (tools key omitted; vLLM rejects an empty array).
|
||||
# Router error/timeout keeps the normal menu for this round.
|
||||
if _spec_driver_on and round_offered:
|
||||
# The router chooses from the FULL Ajax Spec surface (~all
|
||||
# built-ins minus disabled), not the compact round menu, so
|
||||
# a Spec pick is never silently out of reach. The 9B then
|
||||
# only writes the arguments (a named tool_choice) or the
|
||||
# answer (tools key omitted; vLLM rejects an empty array).
|
||||
# Router error/timeout keeps the normal menu this round.
|
||||
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS as _SPEC_SURFACE
|
||||
_drv = spec_driver_pick(history, disabled_tools, direct_user_text)
|
||||
if _drv is not None:
|
||||
_drv_choice = _drv.get('choice')
|
||||
if _drv_choice == 'answer_directly':
|
||||
_driver_round_kind = 'answer_directly'
|
||||
request.pop('tools', None)
|
||||
request.pop('tool_choice', None)
|
||||
elif _drv_choice == 'ask_user':
|
||||
_driver_round_kind = 'ask_user'
|
||||
_ask = [s for s in _SPEC_SURFACE
|
||||
if (s.get('function') or {}).get('name') == 'ask_user']
|
||||
if _ask:
|
||||
request['tools'] = [_ask[0]]
|
||||
request['tool_choice'] = {
|
||||
'type': 'function',
|
||||
'function': {'name': 'ask_user'},
|
||||
}
|
||||
if _ask[0] not in round_offered:
|
||||
round_offered = list(round_offered) + [_ask[0]]
|
||||
elif _drv_choice == 'tool' and _drv.get('tool_name'):
|
||||
_name = _drv['tool_name']
|
||||
_sel = [s for s in list(round_offered) + list(_SPEC_SURFACE)
|
||||
if (s.get('function') or {}).get('name') == _name]
|
||||
if _sel:
|
||||
_driver_round_kind = 'tool'
|
||||
_driver_round_pick = _name
|
||||
request['tools'] = [_sel[0]]
|
||||
request['tool_choice'] = {
|
||||
'type': 'function',
|
||||
'function': {'name': _name},
|
||||
}
|
||||
# The per-call validator looks the schema up in
|
||||
# `round_offered` (not `request['tools']`), so the
|
||||
# Spec pick must be present there too.
|
||||
round_offered = [_sel[0]]
|
||||
# Driver mode narrows the whole contract to the
|
||||
# Spec pick for this round: the router is the
|
||||
# authority, and the executor must be able to run
|
||||
# the picked tool. Build a valid contract (the
|
||||
# TurnContract invariant requires
|
||||
# required <= offered <= executable and
|
||||
# schema_json names == offered).
|
||||
try:
|
||||
from src.turn_contract import (
|
||||
_ACTIVE_CONTRACT as _TC,
|
||||
canonical_tool as _ct,
|
||||
)
|
||||
_pick = _ct(_name)
|
||||
turn_contract = replace(
|
||||
_driver_base_contract,
|
||||
required=frozenset(),
|
||||
offered=frozenset({_pick}),
|
||||
executable=frozenset(
|
||||
set(_driver_base_contract.executable) | {_pick}),
|
||||
schema_json=(json.dumps(_sel[0]),),
|
||||
required_read_operation=None,
|
||||
)
|
||||
_TC.set(turn_contract)
|
||||
except Exception:
|
||||
turn_contract = _driver_base_contract
|
||||
logging.getLogger(__name__).info(
|
||||
'[spec-driver] round=%s pick=%s contract admit failed; '
|
||||
'normal contract kept', round_number, _name)
|
||||
else:
|
||||
logging.getLogger(__name__).info(
|
||||
'[spec-driver] round=%s pick=%s not executable; normal menu this round',
|
||||
round_number, _name)
|
||||
logging.getLogger(__name__).info(
|
||||
'[spec-driver] round=%s pick=%s p=%s', round_number,
|
||||
(_drv.get('tool_name') if _drv_choice == 'tool'
|
||||
else _drv_choice),
|
||||
_drv.get('p'))
|
||||
else:
|
||||
logging.getLogger(__name__).info(
|
||||
'[spec-driver] round=%s pick=fallback p=None', round_number)
|
||||
pending, content, round_reasoning = {}, '', ''
|
||||
document_preview_index = None
|
||||
document_preview_content = ''
|
||||
@@ -6056,6 +6606,49 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
request = search_tool_choice_request(request)
|
||||
tool_call_requested = request.get('tool_choice') not in (None, 'auto', 'none')
|
||||
request = provider_compatible_tool_choice_request(request, model)
|
||||
# Log the tool surface actually sent to the provider: the
|
||||
# metrics `tool_schema_names` reports the pre-narrowing
|
||||
# `offered` set, which hid the fact that a forced choice had
|
||||
# shrunk the provider tools array to one schema.
|
||||
logging.getLogger(__name__).info(
|
||||
"clean preview round %s sending %d tool(s): %s",
|
||||
round_number,
|
||||
len(request.get('tools') or []),
|
||||
[
|
||||
(schema.get('function') or {}).get('name')
|
||||
for schema in (request.get('tools') or [])
|
||||
][:40],
|
||||
)
|
||||
if round_number == 1 and _spec_direct_p is not None:
|
||||
# The router decided to answer directly with high
|
||||
# confidence.
|
||||
if _spec_direct_empty_menu:
|
||||
# Option B: an empty menu — send no tools at all, so
|
||||
# nothing can be executed and no markup is recovered.
|
||||
request['tools'] = []
|
||||
request.pop('tool_choice', None)
|
||||
tool_call_requested = False
|
||||
# Keep the thinking router's decision; never let the
|
||||
# no-tools progressive rule turn thinking on when the
|
||||
# router said off.
|
||||
if not (_spec_route_think or {}).get('think'):
|
||||
ctk = dict(request.get('chat_template_kwargs') or {})
|
||||
ctk['enable_thinking'] = False
|
||||
request['chat_template_kwargs'] = ctk
|
||||
logging.getLogger(__name__).info(
|
||||
'[spec-harness] direct answer: empty menu p=%s',
|
||||
_spec_direct_p)
|
||||
else:
|
||||
# Default: keep the same tools list (prefix-cache
|
||||
# friendly) but forbid a tool call this round. Thinking
|
||||
# is left to the thinking router; offered stays
|
||||
# non-empty so this does not switch progressive
|
||||
# thinking on.
|
||||
request['tool_choice'] = 'none'
|
||||
tool_call_requested = False
|
||||
logging.getLogger(__name__).info(
|
||||
'[spec-harness] direct answer: tool_choice none p=%s',
|
||||
_spec_direct_p)
|
||||
if active_editor_target and request.get('tools'):
|
||||
# Edits share mutable document state. Await a saved result
|
||||
# before asking for another call; one call may still batch
|
||||
@@ -6144,6 +6737,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
and not progressive_thinking
|
||||
and not source_lookup_pending
|
||||
and not tool_call_requested
|
||||
and not _spec_direct_empty_menu
|
||||
):
|
||||
text_event = {'delta': text}
|
||||
if replace_streamed_draft_on_finish and not streamed_round_text:
|
||||
@@ -6197,6 +6791,13 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
if content and not prior_summary_answer and not source_lookup_pending:
|
||||
yield event({'delta': content})
|
||||
streamed_round_text = True
|
||||
if _spec_direct_empty_menu and pending:
|
||||
# Option B: nothing is offered, so never execute a tool
|
||||
# call the model still produced (native shape).
|
||||
logging.getLogger(__name__).info(
|
||||
'[spec-harness] direct answer: dropped %d tool call(s)',
|
||||
len(pending))
|
||||
pending = {}
|
||||
proposed = [pending[i] for i in sorted(pending)]
|
||||
# A lead-in emitted before a tool call is live progress, not
|
||||
# part of the terminal answer. Replace that draft when the
|
||||
@@ -6204,7 +6805,9 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
if proposed and streamed_round_text:
|
||||
replace_streamed_draft_on_finish = True
|
||||
unexecutable_dsml_completion = False
|
||||
if not proposed and 'DSML' in content:
|
||||
if (not proposed and not _spec_direct_empty_menu
|
||||
and _driver_round_kind != 'answer_directly'
|
||||
and 'DSML' in content):
|
||||
offered_by_canonical = {
|
||||
canonical(schema['function']['name']): schema['function']['name']
|
||||
for schema in round_offered
|
||||
@@ -6252,6 +6855,15 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
})
|
||||
else:
|
||||
unexecutable_dsml_completion = True
|
||||
if _spec_direct_empty_menu and content:
|
||||
# Option B: show the answer text without any tool-call
|
||||
# markup the model may still have written (nothing offered).
|
||||
_stripped = re.sub(
|
||||
r'<tool_call\b[^>]*>.*?</tool_call>', '',
|
||||
content, flags=re.DOTALL | re.IGNORECASE).strip()
|
||||
if _stripped != content.strip():
|
||||
content = _stripped
|
||||
replace_streamed_draft_on_finish = True
|
||||
if source_lookup_pending and not proposed:
|
||||
if source_answer_retries < 1 and round_number < round_limit:
|
||||
source_answer_retries += 1
|
||||
@@ -6276,6 +6888,37 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
)
|
||||
if active_editor_target:
|
||||
proposed = drop_redundant_editor_noops(proposed)
|
||||
# ODYSSEUS_SPEC_MODE=driver: Spec is the tool driver, so execute
|
||||
# ONLY the call Spec picked this round. Drop (never execute) any
|
||||
# other native call and any text/DSML-recovered call; when Spec
|
||||
# said answer_directly, keep no calls and strip tool markup so
|
||||
# the remaining text is the answer.
|
||||
if _driver_round_kind == 'answer_directly':
|
||||
if proposed:
|
||||
for _c in proposed:
|
||||
logging.getLogger(__name__).info(
|
||||
'[spec-driver] dropped off-pick call name=%s',
|
||||
(_c.get('function') or {}).get('name'))
|
||||
proposed = []
|
||||
if content:
|
||||
_stripped = strip_tool_blocks(content, skip_fenced=True).strip()
|
||||
_stripped = re.sub(
|
||||
r'<tool_call\b[^>]*>.*?</tool_call>', '',
|
||||
_stripped, flags=re.DOTALL | re.IGNORECASE).strip()
|
||||
if _stripped != content:
|
||||
content = _stripped
|
||||
replace_streamed_draft_on_finish = True
|
||||
elif _driver_round_kind in ('tool', 'ask_user'):
|
||||
_want = 'ask_user' if _driver_round_kind == 'ask_user' else _driver_round_pick
|
||||
_keep = []
|
||||
for _c in proposed:
|
||||
_cn = (_c.get('function') or {}).get('name')
|
||||
if canonical(str(_cn or '')) == canonical(str(_want or '')):
|
||||
_keep.append(_c)
|
||||
else:
|
||||
logging.getLogger(__name__).info(
|
||||
'[spec-driver] dropped off-pick call name=%s', _cn)
|
||||
proposed = _keep
|
||||
if model_choice_experiment:
|
||||
for proposal in proposed:
|
||||
yield event({'type': 'model_tool_proposal', 'round': round_number,
|
||||
@@ -6695,6 +7338,25 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
# that draft and persisted history with the evidence-backed result.
|
||||
history.pop()
|
||||
refusal = denied_response()
|
||||
# A photo-editor turn with no canvas command actually executed
|
||||
# (no browser editor attached) cannot claim the edit. Point the
|
||||
# user at the editor instead of the generic no-op refusal.
|
||||
if 'photo_editor' in (getattr(turn_contract, 'active_capabilities', ()) or ()):
|
||||
touched_canvas = any(
|
||||
str(e.get('tool') or '') in {
|
||||
'run_editor_command', 'get_editor_screenshot',
|
||||
'ai_edit_image', 'generate_image',
|
||||
}
|
||||
and not e.get('error') and e.get('exit_code', 0) == 0
|
||||
for e in executions
|
||||
)
|
||||
if not touched_canvas:
|
||||
refusal = (
|
||||
'This photo is not open on the editor canvas, so I cannot '
|
||||
'apply edits to it from chat. Open the attached image in the '
|
||||
'editor (Quick Edit) and send the request there, and I will '
|
||||
'apply it to the canvas.'
|
||||
)
|
||||
history.append({'role': 'assistant', 'content': refusal})
|
||||
yield event({'type': 'final_response', 'content': refusal})
|
||||
break
|
||||
@@ -7561,11 +8223,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
browser_search_url = requested_browser_url or effective_browser_url
|
||||
is_search_engine_navigation = contains_search_engine_navigation(
|
||||
browser_search_url
|
||||
) or bool(re.search(
|
||||
r'https?://(?:[^/]+\.)?google\.[^/]+/sorry/',
|
||||
effective_browser_url,
|
||||
re.I,
|
||||
))
|
||||
) or is_google_sorry_url(effective_browser_url)
|
||||
has_native_search = any(
|
||||
canonical(schema['function']['name']) == 'web_search'
|
||||
for schema in offered
|
||||
@@ -7948,6 +8606,15 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
output, user_text=latest_user,
|
||||
max_items=contract_item_limit(turn_contract, 20),
|
||||
)
|
||||
followup = note_locator_followup(output, user_text=latest_user)
|
||||
if followup:
|
||||
if round_number < round_limit:
|
||||
structured_terminal_response = ''
|
||||
round_recovery_messages.append(followup)
|
||||
else:
|
||||
structured_terminal_response = (
|
||||
'I found a note, but could not read its content in this turn.'
|
||||
)
|
||||
action = str(args.get('action') or '').replace('-', '_').casefold()
|
||||
if (
|
||||
action in {'search', 'find'}
|
||||
|
||||
@@ -92,6 +92,84 @@ def prune_multimodal_images(
|
||||
return pruned
|
||||
|
||||
|
||||
_IMAGE_TURN_LABEL_RE = re.compile(r"\[Image(?:s)?(?:[ \t]{1,64}attached)?:[ \t]{0,64}([^\]]{1,512})\]", re.IGNORECASE)
|
||||
|
||||
|
||||
def _image_turn_label(message: Dict) -> str:
|
||||
"""Best-effort '[Image: name]' label for a message whose image parts are dropped."""
|
||||
name = ""
|
||||
content = message.get("content") if isinstance(message, dict) else None
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
if isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES:
|
||||
image_url = item.get("image_url")
|
||||
candidate = item.get("name") or (image_url.get("name") if isinstance(image_url, dict) else None)
|
||||
if candidate:
|
||||
name = str(candidate)
|
||||
break
|
||||
if not name:
|
||||
text = _content_as_text(content)
|
||||
match = _IMAGE_TURN_LABEL_RE.search(text)
|
||||
if match:
|
||||
name = match.group(1).strip()
|
||||
return f"[Image: {name}]" if name else "[Image]"
|
||||
|
||||
|
||||
def keep_recent_image_turns(messages: List[Dict], *, keep: int = 2) -> List[Dict]:
|
||||
"""Keep image parts only for the last `keep` messages that carry an image.
|
||||
|
||||
Older image blocks are dropped and replaced with a short ``[Image: name]``
|
||||
text label so the model still knows a picture was shared (and its name)
|
||||
without paying the image tokens on every later turn. Non-text, non-image
|
||||
multimodal blocks (e.g. audio) are left untouched.
|
||||
"""
|
||||
limit = max(0, int(keep))
|
||||
image_messages = [
|
||||
index for index, message in enumerate(messages)
|
||||
if isinstance(message, dict)
|
||||
and isinstance(message.get("content"), list)
|
||||
and any(
|
||||
isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES
|
||||
for item in message["content"]
|
||||
)
|
||||
]
|
||||
if len(image_messages) <= limit:
|
||||
return list(messages)
|
||||
selected = set(image_messages[len(image_messages) - limit:]) if limit else set()
|
||||
|
||||
trimmed: List[Dict] = []
|
||||
for message_index, message in enumerate(messages):
|
||||
if message_index in selected or not isinstance(message, dict):
|
||||
trimmed.append(message)
|
||||
continue
|
||||
content = message.get("content")
|
||||
if not isinstance(content, list):
|
||||
trimmed.append(message)
|
||||
continue
|
||||
kept_blocks = [
|
||||
dict(item) if isinstance(item, dict) else item for item in content
|
||||
if not (isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES)
|
||||
]
|
||||
if len(kept_blocks) == len(content):
|
||||
trimmed.append(message)
|
||||
continue
|
||||
label = _image_turn_label(message)
|
||||
# Attach the label to the trailing text block when possible so we do not
|
||||
# introduce an extra content block; otherwise append a fresh text block.
|
||||
labelled = False
|
||||
for item in reversed(kept_blocks):
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
item["text"] = (item.get("text") or "").rstrip() + f"\n{label}"
|
||||
labelled = True
|
||||
break
|
||||
if not labelled:
|
||||
kept_blocks.append({"type": "text", "text": label})
|
||||
cloned = dict(message)
|
||||
cloned["content"] = kept_blocks
|
||||
trimmed.append(cloned)
|
||||
return trimmed
|
||||
|
||||
|
||||
COMPACT_THRESHOLD = 0.85 # Trigger compaction at 85% of context window
|
||||
SUMMARY_MAX_TOKENS = 1024
|
||||
SMALL_CONTEXT_LIMIT = 8192 # Models with context <= this get aggressive trimming
|
||||
@@ -106,7 +184,7 @@ def auto_compact_threshold_percent() -> int:
|
||||
return max(50, min(95, value))
|
||||
|
||||
# Cursor-style self-summarization prompt — produces structured, dense summaries
|
||||
SELF_SUMMARY_SYSTEM_PROMPT = """You are summarizing a conversation to preserve context after compaction. Produce a structured summary that lets the conversation continue seamlessly.
|
||||
SELF_SUMMARY_SYSTEM_PROMPT = """You are summarizing a conversation with Ajax, the user's AI assistant, to preserve context after compaction. Produce a structured summary that lets Ajax continue seamlessly. Ajax is the assistant's name; Odysseus is the app name.
|
||||
|
||||
Use this format:
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""Document processing: PDF/OCR extraction, text file handling, image VL analysis, user content building."""
|
||||
|
||||
import os
|
||||
import io
|
||||
import logging
|
||||
import mimetypes
|
||||
import base64
|
||||
@@ -141,7 +142,10 @@ def _process_pdf(
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
temp_img_path = tmp.name
|
||||
try:
|
||||
img.image.save(temp_img_path, "PNG") # pypdf -> PIL image
|
||||
image = img.image
|
||||
if image.mode == "CMYK":
|
||||
image = image.convert("RGB")
|
||||
image.save(temp_img_path, "PNG")
|
||||
ocr_text = analyze_image_with_vl(temp_img_path, owner=owner)
|
||||
if ocr_text and "unavailable" not in ocr_text.lower():
|
||||
pdf_text += f"\n\n[Page {page_num + 1} image {img_index + 1} text]: {ocr_text}"
|
||||
@@ -496,6 +500,49 @@ def analyze_image_with_vl(image_path: str, owner: str | None = None) -> str:
|
||||
return analyze_image_with_vl_result(image_path, owner=owner).get("text", "")
|
||||
|
||||
|
||||
def _vision_max_side() -> int:
|
||||
"""Longest-side cap for images inlined into the chat request (tokens)."""
|
||||
try:
|
||||
value = int(os.getenv("ODYSSEUS_VISION_MAX_SIDE", "1280") or 1280)
|
||||
except (TypeError, ValueError):
|
||||
value = 1280
|
||||
return max(0, value)
|
||||
|
||||
|
||||
def encode_image_data_url(path: str, fallback_format: str) -> tuple[str, str]:
|
||||
"""Return (data_url, image_format) for an image, downscaled if large.
|
||||
|
||||
Downscale the longest side to ODYSSEUS_VISION_MAX_SIDE (default 1280) and
|
||||
re-encode as JPEG so the chat model does not pay full-resolution image
|
||||
tokens. Small/undecodable images fall back to the original bytes so a
|
||||
real attachment is never dropped.
|
||||
"""
|
||||
max_side = _vision_max_side()
|
||||
try:
|
||||
from PIL import Image # local import: keep module import cost low
|
||||
with Image.open(path) as img:
|
||||
img = img.convert("RGB") if img.mode not in ("RGB", "L") else img
|
||||
width, height = img.size
|
||||
if max_side and max(width, height) > max_side:
|
||||
scale = max_side / float(max(width, height))
|
||||
img = img.resize(
|
||||
(max(1, round(width * scale)), max(1, round(height * scale))),
|
||||
Image.LANCZOS,
|
||||
)
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="JPEG", quality=85, optimize=True)
|
||||
return (
|
||||
"data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode("utf-8"),
|
||||
"jpeg",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(f"Image downscale failed for {path}: {exc}; sending original bytes")
|
||||
with open(path, "rb") as image_file:
|
||||
encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
|
||||
image_format = (fallback_format or "png").lower()
|
||||
return f"data:image/{image_format};base64,{encoded_string}", image_format
|
||||
|
||||
|
||||
def build_user_content(
|
||||
text: str,
|
||||
attachment_ids: list[str] | None,
|
||||
@@ -542,15 +589,14 @@ def build_user_content(
|
||||
|
||||
if upload_handler.is_image_file(display_name, mime):
|
||||
try:
|
||||
with open(path, "rb") as image_file:
|
||||
encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
|
||||
# Extensionless uploads (e.g. a pasted screenshot) have no ext,
|
||||
# so fall back to the resolved MIME subtype rather than emitting
|
||||
# an invalid "data:image/;base64," with an empty subtype.
|
||||
image_format = ext[1:] or (mime.split("/", 1)[1] if mime.startswith("image/") else "png")
|
||||
fallback_format = ext[1:] or (mime.split("/", 1)[1] if mime.startswith("image/") else "png")
|
||||
data_url, image_format = encode_image_data_url(path, fallback_format)
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/{image_format};base64,{encoded_string}"},
|
||||
"image_url": {"url": data_url},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to encode image {fid}: {e}")
|
||||
|
||||
@@ -0,0 +1,732 @@
|
||||
"""Editor agent/planner model resolution.
|
||||
|
||||
The image-editor Quick Edit planner (`/api/editor/plan`, `/api/editor/agent`)
|
||||
runs on an explicitly configured local vision model when one is reachable,
|
||||
with the configured chat model as a fallback. This module owns:
|
||||
|
||||
* the local model base URLs / model id (settings-overridable),
|
||||
* an ordered candidate list (per-request ``agent_model`` override, then the
|
||||
local Qwen3.5-9B, then the hosted default/utility chat model),
|
||||
* a vision fallback chain: local model WITH the canvas screenshot, else a
|
||||
text-only call whose image is described by a separate VL caption model,
|
||||
* a lightweight reachability probe for the configured local bases.
|
||||
|
||||
Everything is best-effort: when no local server is reachable the planner
|
||||
transparently falls back to the previously-used hosted chat model, so the
|
||||
editor agent never regresses to "no plan".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Local endpoints are opt-in through settings or ODYSSEUS_EDITOR_AGENT_BASES.
|
||||
DEFAULT_LOCAL_BASES = []
|
||||
DEFAULT_LOCAL_MODEL = "Qwen/Qwen3.5-9B"
|
||||
|
||||
# Vision caption model used when the editor-agent model cannot accept images.
|
||||
DEFAULT_CAPTION_BASE = ""
|
||||
DEFAULT_CAPTION_MODEL = "Qwen/Qwen3.5-9B"
|
||||
|
||||
# Served-model-id cache per base (populated on first use). The local servers 400
|
||||
# with "Failed to load model" when asked for an id they do not serve (e.g. the
|
||||
# client's default "Qwen3.5-9B-MLX-8bit", or a stale caption model), so we
|
||||
# resolve the requested id against GET {base}/models and fall back to one that
|
||||
# is actually served.
|
||||
_MODELS_CACHE: Dict[str, set] = {}
|
||||
|
||||
_CAPTION_INSTRUCTION = (
|
||||
"Describe this image in one or two sentences for a photo editor: the main "
|
||||
"subject, the background/scene, lighting, dominant colours, and any obvious "
|
||||
"flaws (blown highlights, colour cast, clutter, blur)."
|
||||
)
|
||||
|
||||
|
||||
def _setting(key: str, default: Any = "") -> Any:
|
||||
try:
|
||||
from src.settings import load_settings
|
||||
|
||||
value = load_settings().get(key)
|
||||
return value if value not in (None, "") else default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def local_bases() -> List[str]:
|
||||
override = _setting("editor_agent_base_url")
|
||||
if override:
|
||||
return [str(override)]
|
||||
env = os.getenv("ODYSSEUS_EDITOR_AGENT_BASES")
|
||||
if env:
|
||||
bases = [b.strip() for b in env.split(",") if b.strip()]
|
||||
if bases:
|
||||
return bases
|
||||
return list(DEFAULT_LOCAL_BASES)
|
||||
|
||||
|
||||
def local_model() -> str:
|
||||
return str(_setting("editor_agent_model", DEFAULT_LOCAL_MODEL))
|
||||
|
||||
|
||||
def caption_base() -> str:
|
||||
return str(_setting("editor_caption_base_url", DEFAULT_CAPTION_BASE))
|
||||
|
||||
|
||||
def caption_model() -> str:
|
||||
return str(_setting("editor_caption_model", DEFAULT_CAPTION_MODEL))
|
||||
|
||||
|
||||
# Per-request / per-plan context (contextvars keep this correct under the
|
||||
# async event loop without sharing state between concurrent editor turns).
|
||||
_override: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
||||
"editor_agent_model_override", default=None
|
||||
)
|
||||
_last_model: contextvars.ContextVar[Optional[Tuple[str, str]]] = contextvars.ContextVar(
|
||||
"editor_agent_last_model", default=None
|
||||
)
|
||||
|
||||
|
||||
def set_override(value: Optional[str]) -> None:
|
||||
_override.set(value or None)
|
||||
|
||||
|
||||
def get_override() -> Optional[str]:
|
||||
return _override.get()
|
||||
|
||||
|
||||
def set_last_model(model: str, source: str) -> None:
|
||||
_last_model.set((model, source))
|
||||
|
||||
|
||||
def get_last_model() -> Optional[Tuple[str, str]]:
|
||||
return _last_model.get()
|
||||
|
||||
|
||||
def _chat_url(base: str) -> str:
|
||||
b = str(base or "").rstrip("/")
|
||||
if b.endswith("/chat/completions"):
|
||||
return b
|
||||
return b + "/chat/completions"
|
||||
|
||||
|
||||
def _split_override(override: str) -> Tuple[str, str]:
|
||||
"""Parse ``base::model`` (or a bare model id)."""
|
||||
raw = str(override or "").strip()
|
||||
if "::" in raw:
|
||||
base, model = raw.split("::", 1)
|
||||
return base.strip(), model.strip()
|
||||
return "", raw
|
||||
|
||||
|
||||
def _has_image(messages: List[dict]) -> bool:
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "image_url":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _first_image(messages: List[dict]) -> Optional[str]:
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "image_url":
|
||||
url = (part.get("image_url") or {}).get("url")
|
||||
if isinstance(url, str):
|
||||
return url
|
||||
return None
|
||||
|
||||
|
||||
def _strip_images(messages: List[dict]) -> List[dict]:
|
||||
out: List[dict] = []
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
text = "\n".join(
|
||||
str(part.get("text") or "")
|
||||
for part in content
|
||||
if isinstance(part, dict) and part.get("type") == "text"
|
||||
)
|
||||
out.append({"role": msg.get("role", "user"), "content": text})
|
||||
else:
|
||||
out.append({"role": msg.get("role", "user"), "content": content})
|
||||
return out
|
||||
|
||||
|
||||
def _with_caption(messages: List[dict], caption: str) -> List[dict]:
|
||||
note = f"\n\n[Canvas screenshot caption: {caption}]"
|
||||
out = [dict(m) for m in messages]
|
||||
for msg in reversed(out):
|
||||
if msg.get("role") == "user":
|
||||
msg["content"] = str(msg.get("content") or "") + note
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
async def _post_chat(
|
||||
base: str,
|
||||
model: str,
|
||||
messages: List[dict],
|
||||
headers: Optional[dict],
|
||||
timeout: float,
|
||||
max_tokens: int = 1024,
|
||||
response_format: Optional[dict] = None,
|
||||
) -> Optional[str]:
|
||||
if not base or not model:
|
||||
return None
|
||||
payload: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": 0.2,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": False,
|
||||
}
|
||||
# Constrain the planner to a strict JSON ops schema (structured outputs /
|
||||
# grammar) so the model can only emit valid editor ops. Unknown servers
|
||||
# ignore the field; the server-side schema validation is the real enforcer.
|
||||
if isinstance(response_format, dict):
|
||||
payload["response_format"] = response_format
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(_chat_url(base), json=payload, headers=headers or {})
|
||||
if resp.status_code != 200:
|
||||
logger.debug(
|
||||
"editor agent chat %s -> %s (%s)", base, resp.status_code, resp.text[:200]
|
||||
)
|
||||
return None
|
||||
data = resp.json()
|
||||
except Exception as exc:
|
||||
logger.debug("editor agent chat %s failed: %s", base, exc)
|
||||
return None
|
||||
try:
|
||||
return data["choices"][0]["message"]["content"]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _served_models(base: str, timeout: float = 3.0) -> set:
|
||||
"""Return the set of model ids a base serves (cached; empty on failure)."""
|
||||
if not base:
|
||||
return set()
|
||||
cached = _MODELS_CACHE.get(base)
|
||||
if cached is not None:
|
||||
return cached
|
||||
ids: set = set()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.get(base.rstrip("/") + "/models")
|
||||
if resp.status_code == 200:
|
||||
ids = {
|
||||
str(m.get("id"))
|
||||
for m in (resp.json().get("data") or [])
|
||||
if isinstance(m, dict) and m.get("id")
|
||||
}
|
||||
except Exception:
|
||||
ids = set()
|
||||
_MODELS_CACHE[base] = ids
|
||||
return ids
|
||||
|
||||
|
||||
async def _resolve_model(base: str, requested: str, fallback: str) -> str:
|
||||
"""Return a model id ``base`` actually serves.
|
||||
|
||||
If the requested id is served, keep it; otherwise use ``fallback`` (when
|
||||
served) or any served id. When the served list is unknown (server down),
|
||||
keep the requested id (or ``fallback``).
|
||||
"""
|
||||
requested = str(requested or "").strip()
|
||||
fallback = str(fallback or "").strip()
|
||||
ids = await _served_models(base)
|
||||
if ids:
|
||||
if requested and requested in ids:
|
||||
return requested
|
||||
if fallback in ids:
|
||||
return fallback
|
||||
return sorted(ids)[0]
|
||||
return requested or fallback
|
||||
|
||||
|
||||
async def caption_image(data_url: str, owner: Optional[str] = None, timeout: float = 90.0) -> Optional[str]:
|
||||
if not isinstance(data_url, str) or not data_url.startswith("data:image/"):
|
||||
return None
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": _CAPTION_INSTRUCTION},
|
||||
{"type": "image_url", "image_url": {"url": data_url}},
|
||||
],
|
||||
}
|
||||
]
|
||||
base = caption_base()
|
||||
if not base:
|
||||
return None
|
||||
model = await _resolve_model(base, caption_model(), local_model())
|
||||
return await _post_chat(base, model, messages, None, timeout, max_tokens=200)
|
||||
|
||||
|
||||
_DONE_RE = re.compile(r"^\s*DONE\b", re.I)
|
||||
_RETRY_RE = re.compile(r"RETRY:?\s*(.+)", re.I | re.S)
|
||||
|
||||
|
||||
def _parse_self_check(text: str) -> Dict[str, Any]:
|
||||
s = str(text or "").strip()
|
||||
if _DONE_RE.match(s):
|
||||
return {"done": True, "issues": [], "retry_hint": "", "text": s}
|
||||
m = _RETRY_RE.search(s)
|
||||
if m or re.search(r"\bretry\b", s, re.I):
|
||||
hint = (m.group(1).strip() if m else s) or s
|
||||
return {"done": False, "issues": [hint], "retry_hint": hint, "text": s}
|
||||
return {"done": True, "issues": [], "retry_hint": "", "text": s}
|
||||
|
||||
|
||||
async def self_check(
|
||||
before: Any,
|
||||
after: Any,
|
||||
prompt: str,
|
||||
model: Optional[str] = None,
|
||||
owner: Optional[str] = None,
|
||||
timeout: float = 30.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""Same-origin VL self-check: compare BEFORE vs AFTER against the intent.
|
||||
|
||||
The browser cannot call the local VL server directly (it sends no CORS
|
||||
headers), so ``/api/editor/self-check`` proxies here. Returns
|
||||
``{done, issues, retry_hint, text}``; on any failure (bad input, the model
|
||||
is unreachable, or a timeout) it degrades to ``done=True`` with
|
||||
``reason="unavailable"`` so the client never gets stuck retrying.
|
||||
"""
|
||||
if not (
|
||||
isinstance(before, str) and before.startswith("data:image/")
|
||||
and isinstance(after, str) and after.startswith("data:image/")
|
||||
):
|
||||
return {"done": True, "issues": [], "retry_hint": "",
|
||||
"reason": "unavailable", "text": "DONE"}
|
||||
bases = local_bases() or list(DEFAULT_LOCAL_BASES)
|
||||
if not bases:
|
||||
return {"done": True, "issues": [], "retry_hint": "",
|
||||
"reason": "unavailable", "text": "DONE"}
|
||||
base = bases[0]
|
||||
mdl = await _resolve_model(base, str(model or local_model()), local_model())
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": str(prompt or "")},
|
||||
{"type": "image_url", "image_url": {"url": before}},
|
||||
{"type": "image_url", "image_url": {"url": after}},
|
||||
],
|
||||
}]
|
||||
text = await _post_chat(base, mdl, messages, None, timeout, max_tokens=140)
|
||||
if not text:
|
||||
return {"done": True, "issues": [], "retry_hint": "",
|
||||
"reason": "unavailable", "text": "DONE"}
|
||||
out = _parse_self_check(text)
|
||||
out["model"] = mdl
|
||||
out["source"] = "local"
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_override_candidate(override: str, owner: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
"""Resolve an explicit agent_model override under server authority.
|
||||
|
||||
Guarantees:
|
||||
- Client-provided raw URLs never determine arbitrary outbound network destinations.
|
||||
- Registered endpoint IDs resolve only to enabled endpoints visible to the owner.
|
||||
- Shared endpoints (owner IS NULL) remain visible to authorized users.
|
||||
- Disabled, foreign-owned, forged, or unconfigured destinations fail closed (return None).
|
||||
- Admin privileges do not allow arbitrary unconfigured destinations.
|
||||
"""
|
||||
raw = str(override or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
if "::" in raw:
|
||||
target, requested_model = raw.split("::", 1)
|
||||
target = target.strip()
|
||||
requested_model = requested_model.strip()
|
||||
else:
|
||||
target = ""
|
||||
requested_model = raw
|
||||
|
||||
# 1. Explicit local selection or matching a configured local base
|
||||
if target == "local":
|
||||
bases = local_bases()
|
||||
if not bases:
|
||||
return None
|
||||
return {
|
||||
"base": bases[0],
|
||||
"model": requested_model or local_model(),
|
||||
"headers": None,
|
||||
"source": "override",
|
||||
}
|
||||
for lb in local_bases():
|
||||
if target and target.rstrip("/") == lb.rstrip("/"):
|
||||
return {
|
||||
"base": lb,
|
||||
"model": requested_model or local_model(),
|
||||
"headers": None,
|
||||
"source": "override",
|
||||
}
|
||||
|
||||
# 2. Explicit hosted fallback selection
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
|
||||
for prefix in ("utility", "default"):
|
||||
try:
|
||||
url, model, headers = resolve_endpoint(prefix, owner=owner)
|
||||
except Exception:
|
||||
continue
|
||||
if url and model:
|
||||
if target in ("default", "hosted"):
|
||||
return {
|
||||
"base": url,
|
||||
"model": requested_model or model,
|
||||
"headers": headers,
|
||||
"source": "hosted",
|
||||
}
|
||||
if target and target.rstrip("/") == url.rstrip("/"):
|
||||
if not requested_model or requested_model == model:
|
||||
return {
|
||||
"base": url,
|
||||
"model": requested_model or model,
|
||||
"headers": headers,
|
||||
"source": "hosted",
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
if target in ("default", "hosted"):
|
||||
return None
|
||||
|
||||
# 3. Bare model id without endpoint prefix
|
||||
if not target:
|
||||
if requested_model == "local":
|
||||
bases = local_bases()
|
||||
return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"} if bases else None
|
||||
if requested_model in ("default", "hosted"):
|
||||
return _resolve_override_candidate("default::", owner)
|
||||
|
||||
# Exact match with local model
|
||||
if requested_model == local_model():
|
||||
bases = local_bases()
|
||||
if bases:
|
||||
return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"}
|
||||
|
||||
# Exact match with hosted default/utility
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
|
||||
for prefix in ("utility", "default"):
|
||||
try:
|
||||
url, model, headers = resolve_endpoint(prefix, owner=owner)
|
||||
except Exception:
|
||||
continue
|
||||
if url and model and model == requested_model:
|
||||
return {"base": url, "model": model, "headers": headers, "source": "hosted"}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check registered database endpoints for this bare model
|
||||
try:
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from src.auth_helpers import owner_filter, _auth_disabled
|
||||
from src.endpoint_resolver import (
|
||||
resolve_endpoint_runtime,
|
||||
build_chat_url,
|
||||
build_headers,
|
||||
_endpoint_enabled_models,
|
||||
_endpoint_hidden_models,
|
||||
)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if not owner and not _auth_disabled():
|
||||
q = db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.is_enabled == True,
|
||||
ModelEndpoint.owner == None,
|
||||
)
|
||||
else:
|
||||
q = owner_filter(
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
|
||||
ModelEndpoint,
|
||||
owner,
|
||||
)
|
||||
matching_eps = []
|
||||
for ep in q.all():
|
||||
enabled_models = _endpoint_enabled_models(ep)
|
||||
hidden_models = _endpoint_hidden_models(ep)
|
||||
if requested_model in enabled_models and requested_model not in hidden_models:
|
||||
matching_eps.append(ep)
|
||||
|
||||
# Avoid model ID collision across providers: only accept if unique
|
||||
if len(matching_eps) == 1:
|
||||
ep = matching_eps[0]
|
||||
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||
return {
|
||||
"base": build_chat_url(base),
|
||||
"model": requested_model,
|
||||
"headers": build_headers(api_key, base),
|
||||
"source": "registered",
|
||||
"endpoint_id": ep.id,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
# 4. Target is an endpoint_id or a registered legacy endpoint URL
|
||||
try:
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from src.auth_helpers import owner_filter, _auth_disabled
|
||||
from src.endpoint_resolver import (
|
||||
resolve_endpoint_runtime,
|
||||
build_chat_url,
|
||||
build_headers,
|
||||
_endpoint_enabled_models,
|
||||
_endpoint_hidden_models,
|
||||
_first_chat_model,
|
||||
)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if not owner and not _auth_disabled():
|
||||
q = db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.is_enabled == True,
|
||||
ModelEndpoint.owner == None,
|
||||
)
|
||||
else:
|
||||
q = owner_filter(
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
|
||||
ModelEndpoint,
|
||||
owner,
|
||||
)
|
||||
# Lookup by ID
|
||||
ep = q.filter(ModelEndpoint.id == target).first()
|
||||
|
||||
# If not found by ID, match legacy registered base URL (strictly registered rows only)
|
||||
if ep is None and ("://" in target or target.startswith("http")):
|
||||
target_norm = target.rstrip("/")
|
||||
for cand_ep in q.all():
|
||||
cand_base = (cand_ep.base_url or "").rstrip("/")
|
||||
if (
|
||||
cand_base == target_norm
|
||||
or cand_base + "/v1" == target_norm
|
||||
or cand_base == target_norm + "/v1"
|
||||
):
|
||||
ep = cand_ep
|
||||
break
|
||||
|
||||
if ep is None:
|
||||
# Target is disabled, foreign-owned, nonexistent, or arbitrary unconfigured URL -> fail closed!
|
||||
return None
|
||||
|
||||
enabled_models = _endpoint_enabled_models(ep)
|
||||
hidden_models = _endpoint_hidden_models(ep)
|
||||
if requested_model:
|
||||
if requested_model in hidden_models:
|
||||
return None
|
||||
if enabled_models and requested_model not in enabled_models:
|
||||
# Model not served by this endpoint -> fail closed against forged model IDs
|
||||
return None
|
||||
model = requested_model
|
||||
else:
|
||||
model = _first_chat_model(enabled_models)
|
||||
if not model:
|
||||
return None
|
||||
|
||||
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||
return {
|
||||
"base": build_chat_url(base),
|
||||
"model": model,
|
||||
"headers": build_headers(api_key, base),
|
||||
"source": "registered",
|
||||
"endpoint_id": ep.id,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]:
|
||||
cands: List[Dict[str, Any]] = []
|
||||
for base in local_bases():
|
||||
cands.append({"base": base, "model": local_model(), "headers": None, "source": "local"})
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
|
||||
for prefix in ("utility", "default"):
|
||||
try:
|
||||
url, model, headers = resolve_endpoint(prefix, owner=owner)
|
||||
except Exception:
|
||||
continue
|
||||
if url and model:
|
||||
cands.append({"base": url, "model": model, "headers": headers, "source": "hosted"})
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if override:
|
||||
cand = _resolve_override_candidate(override, owner)
|
||||
if cand is not None:
|
||||
cands.insert(0, cand)
|
||||
return cands
|
||||
# Unauthorized, invalid, foreign, disabled, or forged override:
|
||||
# Fail closed! Never silently dispatch sensitive user images/state to an unexpected fallback.
|
||||
return []
|
||||
return cands
|
||||
|
||||
|
||||
async def plan_with_editor_agent(
|
||||
messages: List[dict],
|
||||
owner: Optional[str] = None,
|
||||
override: Optional[str] = None,
|
||||
timeout: float = 90.0,
|
||||
response_format: Optional[dict] = None,
|
||||
) -> Optional[Tuple[str, str, str]]:
|
||||
"""Return (raw_plan_text, model_used, source) or None.
|
||||
|
||||
Tries the local model WITH the screenshot, then the same model text-only
|
||||
with a VL caption of the screenshot, then the hosted chat model.
|
||||
|
||||
``response_format`` (a JSON-schema structured-output constraint) is sent
|
||||
ONLY to the local editor-agent bases, so a hosted fallback model that does
|
||||
not support the field never rejects the request.
|
||||
"""
|
||||
has_img = _has_image(messages)
|
||||
text_messages = _strip_images(messages) if has_img else messages
|
||||
image = _first_image(messages) if has_img else None
|
||||
caption: Optional[str] = None
|
||||
_local = {str(b).rstrip("/") for b in local_bases()}
|
||||
for cand in _candidates(owner, override):
|
||||
base, model = cand["base"], cand["model"]
|
||||
headers = cand.get("headers")
|
||||
is_local = cand["source"] == "local" or str(base).rstrip("/") in _local
|
||||
rf = response_format if (response_format and is_local) else None
|
||||
extra = {"response_format": rf} if rf else {}
|
||||
if has_img:
|
||||
raw = await _post_chat(base, model, messages, headers, timeout, **extra)
|
||||
if raw:
|
||||
return raw, model, cand["source"]
|
||||
if caption is None and image:
|
||||
caption = await caption_image(image, owner)
|
||||
if caption:
|
||||
raw = await _post_chat(
|
||||
base, model, _with_caption(text_messages, caption), headers, timeout, **extra,
|
||||
)
|
||||
if raw:
|
||||
return raw, model, cand["source"] + "+caption"
|
||||
else:
|
||||
raw = await _post_chat(
|
||||
base, model, text_messages, headers, timeout, **extra
|
||||
)
|
||||
if raw:
|
||||
return raw, model, cand["source"]
|
||||
return None
|
||||
|
||||
|
||||
async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Models available to the editor agent, for the model picker.
|
||||
|
||||
Lists the local Qwen3.5-9B (one entry per reachable base) plus the hosted
|
||||
default/utility chat model that the planner falls back to.
|
||||
"""
|
||||
models: List[Dict[str, Any]] = []
|
||||
seen: set = set()
|
||||
for base in local_bases():
|
||||
model = local_model()
|
||||
key = ("local", base, model)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
models.append({
|
||||
"id": model,
|
||||
"label": f"{model} · local",
|
||||
"source": "local",
|
||||
"vision": True,
|
||||
"base_url": base,
|
||||
"endpoint_id": "local",
|
||||
})
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.settings import get_user_setting, load_settings
|
||||
|
||||
for prefix in ("utility", "default"):
|
||||
try:
|
||||
url, model, _headers = resolve_endpoint(prefix, owner=owner)
|
||||
except Exception:
|
||||
continue
|
||||
if url and model:
|
||||
key = ("hosted", model)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
ep_id = None
|
||||
try:
|
||||
settings = load_settings()
|
||||
ep_id = (get_user_setting(f"{prefix}_endpoint_id", owner or "", settings.get(f"{prefix}_endpoint_id", "")) or "").strip() or None
|
||||
except Exception:
|
||||
pass
|
||||
models.append({
|
||||
"id": model,
|
||||
"label": model,
|
||||
"source": "hosted",
|
||||
"vision": None,
|
||||
"base_url": url,
|
||||
"endpoint_id": ep_id or prefix,
|
||||
})
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
return models
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reachability probe (best effort, non-blocking).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PROBE: Dict[str, bool] = {}
|
||||
|
||||
|
||||
def probe_models(base: str, timeout: float = 3.0) -> bool:
|
||||
try:
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
resp = client.get(str(base).rstrip("/") + "/models")
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _probe_worker() -> None:
|
||||
for base in local_bases():
|
||||
ok = probe_models(base)
|
||||
_PROBE[base] = ok
|
||||
logger.info("editor agent local base %s reachable=%s", base, ok)
|
||||
cap = caption_base()
|
||||
if cap:
|
||||
_PROBE[cap] = probe_models(cap)
|
||||
logger.info("editor agent caption base %s reachable=%s", cap, _PROBE[cap])
|
||||
|
||||
|
||||
def startup_probe() -> None:
|
||||
"""Kick off a background reachability probe (never blocks startup)."""
|
||||
try:
|
||||
threading.Thread(target=_probe_worker, name="editor-agent-probe", daemon=True).start()
|
||||
except Exception:
|
||||
logger.debug("editor agent probe thread failed", exc_info=True)
|
||||
|
||||
|
||||
def probe_status() -> Dict[str, bool]:
|
||||
return dict(_PROBE)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""The shared model defaults used by Quick Edit's image operations."""
|
||||
from src.settings import get_user_setting, load_settings
|
||||
|
||||
|
||||
def editor_image_models(owner: str = "") -> dict[str, str]:
|
||||
settings = load_settings()
|
||||
generation = ""
|
||||
edit_model = ""
|
||||
if owner:
|
||||
generation = str(get_user_setting("image_model", owner=owner, default="") or "").strip()
|
||||
edit_model = str(get_user_setting("image_edit_model", owner=owner, default="") or "").strip()
|
||||
if not generation:
|
||||
generation = str(settings.get("image_model") or "").strip()
|
||||
if not edit_model:
|
||||
edit_model = str(settings.get("image_edit_model") or generation).strip()
|
||||
return {
|
||||
"generation": generation,
|
||||
"edit": edit_model or generation,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+113
-6
@@ -2,7 +2,8 @@
|
||||
import json
|
||||
import time
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -19,7 +20,7 @@ _DEPENDENCIES = {
|
||||
'web': {'web_search', 'web_fetch', 'private_browser'},
|
||||
'email': {'list_email_accounts', 'list_emails', 'search_emails', 'read_email',
|
||||
'download_attachment'},
|
||||
'contacts': {'resolve_contact'},
|
||||
'contacts': {'resolve_contact', 'manage_contact'},
|
||||
'documents': {'search_documents', 'read_document'},
|
||||
}
|
||||
_DRAFT_TOOLS = {'ask_user', 'update_plan', 'draft_email', 'draft_email_reply',
|
||||
@@ -116,8 +117,9 @@ def parse_email_task_intent(value):
|
||||
|
||||
def scope_email_tools(schemas, intent, *, active_editor=False):
|
||||
if intent.operation == 'read' and intent.dependencies:
|
||||
allowed = set().union(*(_DEPENDENCIES[d] for d in intent.dependencies))
|
||||
if active_editor:
|
||||
allowed = {tool for dependency in intent.dependencies
|
||||
for tool in _DEPENDENCIES[dependency]}
|
||||
if active_editor and intent.dependencies:
|
||||
allowed.update(_OPEN_EDITOR_TOOLS)
|
||||
if intent.needs_clarification:
|
||||
allowed.add('ask_user')
|
||||
@@ -126,6 +128,9 @@ def scope_email_tools(schemas, intent, *, active_editor=False):
|
||||
if intent.operation not in {'draft', 'revise'}:
|
||||
return list(schemas)
|
||||
allowed = _DRAFT_TOOLS.union(*(_DEPENDENCIES[d] for d in intent.dependencies))
|
||||
# manage_contact is a read-only runtime source for lookups, not a
|
||||
# composition tool. Drafts still resolve recipients through resolve_contact.
|
||||
allowed.discard('manage_contact')
|
||||
if active_editor:
|
||||
allowed.update(_OPEN_EDITOR_TOOLS)
|
||||
if not intent.needs_clarification:
|
||||
@@ -145,6 +150,62 @@ def scope_email_tools(schemas, intent, *, active_editor=False):
|
||||
# as though it were the whole task. This byte budget is deliberately conservative.
|
||||
CLASSIFIER_CONTEXT_BYTES = 24000
|
||||
|
||||
# The model can produce a structurally valid classification while forgetting
|
||||
# the source or mistaking a list of message headers for a request for bodies.
|
||||
_OWN_MAILBOX = re.compile(
|
||||
r'\b(?:my|our)\s+(?:(?:last|latest|recent|new|unread|read|first|most|\d+)\s+){0,5}'
|
||||
r'(?:e-?mails?|mail|inbox|mailbox)\b', re.IGNORECASE,
|
||||
)
|
||||
_MAILBOX_HEADER_LIST = re.compile(
|
||||
r'^\s*(?:list|show|display|what(?:\'s| are))\s+(?:me\s+)?(?:my|our)\s+'
|
||||
r'(?:(?:last|latest|recent|new|unread|read|first|most|\d+)\s+){0,5}'
|
||||
r'(?:e-?mails|messages)\s*'
|
||||
r'(?:with\s+(?:(?:sender|subject|date|time|recipient|status)\s*'
|
||||
r'(?:(?:,|and)\s*)?)+)?[?.!]*\s*$', re.IGNORECASE,
|
||||
)
|
||||
_EMAIL_HEADER_FIELDS = re.compile(r'\b(?:subjects?|senders?|dates?)\b', re.IGNORECASE)
|
||||
_EMAIL_HEADER_FOLLOWUP = re.compile(r'\b(?:those|these|them|their|same)\b', re.IGNORECASE)
|
||||
_READ_REQUEST = re.compile(r'^\s*(?:please\s+)?(?:show|list|display|find|search|read|what|how many|are there|do i have)\b', re.IGNORECASE)
|
||||
_EMAIL_COUNT = re.compile(r'^\s*how many\s+(?:(?:unread|read|new)\s+)?(?:e-?mails|messages)\b',
|
||||
re.IGNORECASE)
|
||||
_EMAIL_BODY_QUERY = re.compile(
|
||||
r'\b(?:mention(?:s|ed|ing)?|body|contents?|amount|owe|says?|said|when|where|why|'
|
||||
r'starts?|deadline|attachments?)\b|\bhow much\b|\bwhat time\b',
|
||||
re.IGNORECASE)
|
||||
_CONTACT_EMAIL_FIELD = re.compile(
|
||||
r'\bcontacts?\b[^.!?\n]{0,100}\bemail\b', re.IGNORECASE,
|
||||
)
|
||||
_MAILBOX_SOURCE = re.compile(
|
||||
r'\b(?:my|our)\s+(?:(?:last|latest|recent|new|unread|read|first|most|\d+)\s+){0,5}'
|
||||
r'(?:e-?mails?|inbox|mailbox)\b|\b(?:inbox|mailbox|email\s+history)\b|'
|
||||
r'\b(?:e-?mails?|messages?)\s+(?:from|to|sent|received)\b|'
|
||||
r'\bemailed?\s+(?:me|us)\b', re.IGNORECASE,
|
||||
)
|
||||
|
||||
EMAIL_TASK_RESPONSE_FORMAT = {
|
||||
'type': 'json_schema',
|
||||
'json_schema': {
|
||||
'name': 'email_task_intent',
|
||||
'strict': True,
|
||||
'schema': {
|
||||
'type': 'object',
|
||||
'additionalProperties': False,
|
||||
'properties': {
|
||||
'operation': {'type': 'string', 'enum': ['draft', 'revise', 'read', 'send', 'other']},
|
||||
'requires_content': {'type': 'boolean'},
|
||||
'dependencies': {'type': 'array', 'items': {
|
||||
'type': 'string', 'enum': list(_DEPENDENCIES),
|
||||
}},
|
||||
'destination': {'type': 'string', 'enum': ['chat', 'mailbox']},
|
||||
'needs_clarification': {'type': 'boolean'},
|
||||
'summary': {'type': 'string'},
|
||||
},
|
||||
'required': ['operation', 'requires_content', 'dependencies', 'destination',
|
||||
'needs_clarification', 'summary'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
||||
supplied_context=None, accounting=None):
|
||||
@@ -152,6 +213,8 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
||||
# text from multimodal messages, so an attached image cannot hide the latest
|
||||
# instruction and leave us classifying an earlier task instead.
|
||||
dialogue = []
|
||||
latest_user_text = ''
|
||||
prior_user_text = ''
|
||||
for row in history:
|
||||
if row.get('role') not in {'user', 'assistant'} or row.get('_harness_control'):
|
||||
continue
|
||||
@@ -166,6 +229,9 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
||||
and isinstance(block.get('text'), str))
|
||||
if isinstance(content, str):
|
||||
dialogue.append({'role': row['role'], 'content': content})
|
||||
if row['role'] == 'user':
|
||||
prior_user_text = latest_user_text
|
||||
latest_user_text = content
|
||||
payload = json.dumps({'dialogue': dialogue, 'supplied_context': supplied_context},
|
||||
ensure_ascii=False)
|
||||
if len(payload.encode('utf-8')) > CLASSIFIER_CONTEXT_BYTES:
|
||||
@@ -174,7 +240,7 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
||||
response = await client.post(endpoint_url, headers=headers, timeout=20, json={
|
||||
'model': model, 'stream': False, 'temperature': 0, 'max_tokens': 500,
|
||||
'chat_template_kwargs': {'enable_thinking': False},
|
||||
'response_format': {'type': 'json_object'},
|
||||
'response_format': EMAIL_TASK_RESPONSE_FORMAT,
|
||||
'messages': [{'role': 'system', 'content': (
|
||||
'Classify the current conversational task. Return JSON only with operation '
|
||||
'(draft, revise, read, send, other), requires_content (boolean), dependencies (array containing only web, '
|
||||
@@ -250,6 +316,47 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
||||
'response_time': round(time.monotonic() - started, 3),
|
||||
})
|
||||
try:
|
||||
return parse_email_task_intent(json.loads(body['choices'][0]['message']['content']))
|
||||
intent = parse_email_task_intent(json.loads(body['choices'][0]['message']['content']))
|
||||
if intent.operation == 'read' and not intent.needs_clarification:
|
||||
# In a contact-record question, "email" can name an address field
|
||||
# rather than the mailbox as a source. Keep both dependencies when
|
||||
# the request also names a mailbox/history source.
|
||||
contact_email_field = bool(
|
||||
_CONTACT_EMAIL_FIELD.search(latest_user_text)
|
||||
and not _MAILBOX_SOURCE.search(latest_user_text)
|
||||
)
|
||||
if contact_email_field:
|
||||
intent = replace(intent, dependencies=tuple(dict.fromkeys(
|
||||
(*[d for d in intent.dependencies if d != 'email'], 'contacts')
|
||||
)))
|
||||
# Reuse the product's existing source classifier rather than a
|
||||
# second vocabulary of mailbox phrases. The classifier has a known
|
||||
# gap for possessive single-message questions, so retain that
|
||||
# narrow fallback only when it found no competing source family.
|
||||
from src.turn_contract import requested_capabilities
|
||||
capabilities = requested_capabilities(latest_user_text)
|
||||
# A header-only reference can inherit the immediately preceding
|
||||
# mailbox read, but an intervening request or a competing source
|
||||
# breaks that link. Never infer a read from an earlier write.
|
||||
header_followup = (
|
||||
not capabilities
|
||||
and bool(_EMAIL_HEADER_FIELDS.search(latest_user_text))
|
||||
and bool(_EMAIL_HEADER_FOLLOWUP.search(latest_user_text))
|
||||
and bool(_READ_REQUEST.match(prior_user_text))
|
||||
and requested_capabilities(prior_user_text) == {'email'}
|
||||
)
|
||||
mailbox_read = not contact_email_field and (capabilities == {'email'} or (
|
||||
not capabilities and bool(_OWN_MAILBOX.search(latest_user_text))
|
||||
) or header_followup)
|
||||
else:
|
||||
mailbox_read = False
|
||||
if mailbox_read:
|
||||
intent = replace(intent, dependencies=tuple(dict.fromkeys((*intent.dependencies, 'email'))))
|
||||
if (not _EMAIL_BODY_QUERY.search(latest_user_text)
|
||||
and (_MAILBOX_HEADER_LIST.fullmatch(latest_user_text)
|
||||
or _EMAIL_HEADER_FIELDS.search(latest_user_text)
|
||||
or _EMAIL_COUNT.match(latest_user_text))):
|
||||
intent = replace(intent, requires_content=False)
|
||||
return intent
|
||||
except (KeyError, IndexError, TypeError) as exc:
|
||||
raise ValueError('Invalid classifier response') from exc
|
||||
|
||||
@@ -68,6 +68,7 @@ def _row(index: int, message: dict[str, Any]) -> dict[str, Any]:
|
||||
})
|
||||
return {
|
||||
"uid": str(message.get("uid") or _uid(index)),
|
||||
"message_id": str(message.get("message_id") or ""),
|
||||
"account": str(message.get("account") or "fixture"),
|
||||
"account_email": str(message.get("account_email") or message.get("to") or ""),
|
||||
"account_id": str(message.get("account_id") or "fixture"),
|
||||
@@ -78,6 +79,16 @@ def _row(index: int, message: dict[str, Any]) -> dict[str, Any]:
|
||||
"folder": str(message.get("folder") or "INBOX"),
|
||||
"attachments": attachments,
|
||||
"has_attachments": bool(attachments),
|
||||
"summary": str(message.get("summary") or message.get("body") or ""),
|
||||
}
|
||||
|
||||
|
||||
def _account_matches(message: dict[str, Any], account: str | None) -> bool:
|
||||
selector = str(account or "").strip().casefold()
|
||||
return not selector or selector in {
|
||||
str(message.get("account") or "fixture").strip().casefold(),
|
||||
str(message.get("account_email") or message.get("to") or "").strip().casefold(),
|
||||
str(message.get("account_id") or "fixture").strip().casefold(),
|
||||
}
|
||||
|
||||
|
||||
@@ -159,6 +170,7 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
||||
|
||||
if bare == "read_email":
|
||||
uid = str(args.get("uid") or "")
|
||||
message_id = str(args.get("message_id") or "")
|
||||
folder = str(args.get("folder") or "INBOX")
|
||||
try:
|
||||
index = int(uid) - 1
|
||||
@@ -185,6 +197,7 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
||||
all_messages = _messages()
|
||||
owner_value = str(owner or "").strip()
|
||||
visible_index = -1
|
||||
matches = []
|
||||
for original in all_messages:
|
||||
if owner_value and original.get("owner") and original.get("owner") != owner_value:
|
||||
continue
|
||||
@@ -193,6 +206,13 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
||||
continue
|
||||
if not _folder_matches(original.get("folder"), folder):
|
||||
continue
|
||||
if not _account_matches(original, args.get("account")):
|
||||
continue
|
||||
matches.append(original)
|
||||
if len(matches) > 1:
|
||||
return {"error": "Email identity is ambiguous; specify its account and folder.", "exit_code": 1}
|
||||
if matches:
|
||||
original = matches[0]
|
||||
if bare == "archive_email":
|
||||
original["folder"] = "Archive"
|
||||
action = "Archived"
|
||||
|
||||
@@ -40,6 +40,15 @@ def image_edit_size(model_id: str, width: int, height: int) -> str:
|
||||
return f"{width}x{height}"
|
||||
|
||||
|
||||
def image_aspect_ratio(width: int, height: int) -> str:
|
||||
"""Choose the nearest common Image API ratio, without distorting pixels."""
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("Image dimensions must be positive")
|
||||
ratios = ((1, 1), (16, 9), (9, 16), (3, 2), (2, 3), (4, 3), (3, 4), (21, 9))
|
||||
w, h = min(ratios, key=lambda ratio: abs(math.log((ratio[0] / ratio[1]) / (width / height))))
|
||||
return f"{w}:{h}"
|
||||
|
||||
|
||||
def looks_like_image_generation_model(model_id: str) -> bool:
|
||||
"""Return True when a model id should use image generation routes.
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Thin client for the local jeff-serve decision endpoint (POST /v1/systemone).
|
||||
|
||||
Trained decision adapters (triage, spam, guard-email, ...) answer a situation
|
||||
with calibrated probabilities over named options in a single forward pass, with
|
||||
no generated text to parse. Background jobs can prefer an adapter and fall back
|
||||
to the normal LLM candidate chain whenever this client returns None.
|
||||
|
||||
Every call fails open (returns None) and is disabled per job unless the job's
|
||||
flag is set, so importing/enabling this never changes behaviour on its own.
|
||||
|
||||
Env:
|
||||
ODYSSEUS_JEFF_URL base URL of jeff-serve (default http://127.0.0.1:8765)
|
||||
ODYSSEUS_JEFF_TIMEOUT per-request timeout in seconds (default 4.0)
|
||||
ODYSSEUS_JEFF_<JOB> per-job enable flag, e.g. ODYSSEUS_JEFF_EMAIL_URGENCY=1
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_URL = "http://127.0.0.1:8765"
|
||||
_FALSEY = ("", "0", "false", "off", "no")
|
||||
|
||||
|
||||
def jeff_url() -> str:
|
||||
"""Base URL of the local jeff-serve (no trailing slash)."""
|
||||
return os.getenv("ODYSSEUS_JEFF_URL", _DEFAULT_URL).strip().rstrip("/")
|
||||
|
||||
|
||||
def jeff_job_enabled(job: str) -> bool:
|
||||
"""True when ODYSSEUS_JEFF_<JOB> is set to a truthy value. Default off."""
|
||||
slug = "".join(c if (c.isalnum() or c == "_") else "_" for c in (job or "")).upper()
|
||||
return os.getenv("ODYSSEUS_JEFF_" + slug, "0").strip().lower() not in _FALSEY
|
||||
|
||||
|
||||
def jeff_timeout(default: float = 4.0) -> float:
|
||||
try:
|
||||
return float(os.getenv("ODYSSEUS_JEFF_TIMEOUT", str(default)))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
async def jeff_systemone(
|
||||
model: str,
|
||||
state: Dict[str, Any],
|
||||
questions: Dict[str, Any],
|
||||
*,
|
||||
timeout: Optional[float] = None,
|
||||
orders: int = 1,
|
||||
client: Optional[httpx.AsyncClient] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""POST a decision request and return the ``answers`` dict, or None on error.
|
||||
|
||||
Each question is scored in its own forward pass, so send only questions the
|
||||
adapter was trained on. Raises are swallowed and logged (fail open).
|
||||
"""
|
||||
url = jeff_url()
|
||||
if not url:
|
||||
return None
|
||||
payload: Dict[str, Any] = {"model": model, "state": state, "questions": questions}
|
||||
if orders and orders != 1:
|
||||
payload["orders"] = orders
|
||||
to = timeout if timeout is not None else jeff_timeout()
|
||||
try:
|
||||
if client is None:
|
||||
async with httpx.AsyncClient(timeout=to) as _c:
|
||||
resp = await _c.post(url + "/v1/systemone", json=payload)
|
||||
else:
|
||||
resp = await client.post(url + "/v1/systemone", json=payload)
|
||||
resp.raise_for_status()
|
||||
answers = (resp.json() or {}).get("answers")
|
||||
return answers if isinstance(answers, dict) else None
|
||||
except Exception as exc:
|
||||
logger.warning("jeff decision (%s) failed: %s", model, exc)
|
||||
return None
|
||||
|
||||
|
||||
async def jeff_available(timeout: float = 1.5) -> bool:
|
||||
"""Best-effort health probe for the local jeff-serve."""
|
||||
url = jeff_url()
|
||||
if not url:
|
||||
return False
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as _c:
|
||||
resp = await _c.get(url + "/health")
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def score_value(answer: Optional[Dict[str, Any]]) -> Optional[float]:
|
||||
"""Expected value (0..n-1) of a jeff ``score`` answer, or None."""
|
||||
if not isinstance(answer, dict):
|
||||
return None
|
||||
try:
|
||||
if answer.get("type") == "score":
|
||||
value = float(answer.get("score"))
|
||||
elif answer.get("type") == "choice":
|
||||
probs = answer.get("probabilities") or {}
|
||||
value = float(sum(i * float(p) for i, p in enumerate(probs.values())))
|
||||
else:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
# A malformed adapter reply must fall back like any other failure.
|
||||
return value if math.isfinite(value) else None
|
||||
|
||||
|
||||
def choice_of(answer: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
"""The winning key of a jeff ``choice`` answer, or None."""
|
||||
if isinstance(answer, dict) and answer.get("type") == "choice":
|
||||
c = answer.get("choice")
|
||||
return str(c) if c is not None else None
|
||||
return None
|
||||
+52
-13
@@ -422,7 +422,18 @@ def _stream_delta_event(text: str, *, thinking: bool = False) -> str:
|
||||
return f"data: {json.dumps(payload)}\n\n"
|
||||
|
||||
|
||||
_DEGENERATE_WORD_RE = re.compile(r"[A-Za-z0-9_\u0370-\u03ff\u0400-\u04ff]+")
|
||||
_WORD_CHARS = r"A-Za-z0-9_\u0370-\u03ff\u0400-\u04ff"
|
||||
_DELIM_RE = re.compile(rf"[^{_WORD_CHARS}+\-.]")
|
||||
_NUMERIC_PATTERN = rf"(?<![{_WORD_CHARS}])[+-]?(?:[0-9]+\.[0-9]+|\.[0-9]+|[0-9]+)(?:[eE][+-]?[0-9]+)?(?![{_WORD_CHARS}])"
|
||||
_NUMERIC_FULLMATCH_RE = re.compile(rf"^[+-]?(?:[0-9]+\.[0-9]+|\.[0-9]+|[0-9]+)(?:[eE][+-]?[0-9]+)?$")
|
||||
_DEGENERATE_WORD_RE = re.compile(rf"[{_WORD_CHARS}]+")
|
||||
_TOKEN_RE = re.compile(rf"(?P<NUM>{_NUMERIC_PATTERN})|(?P<WORD>[{_WORD_CHARS}]+)")
|
||||
|
||||
|
||||
def _is_numeric_token(token: str) -> bool:
|
||||
if token.isdigit():
|
||||
return True
|
||||
return _NUMERIC_FULLMATCH_RE.fullmatch(token) is not None
|
||||
|
||||
|
||||
class _DegenerateStreamGuard:
|
||||
@@ -432,32 +443,50 @@ class _DegenerateStreamGuard:
|
||||
"Summer Summer ..."). This is not a useful response and can burn context,
|
||||
browser memory, and GPU time. Keep the guard conservative: only fire on long
|
||||
same-token runs or a very dominant repeated token in the recent window.
|
||||
Repetition made only of numbers is data (a zero or identity matrix, a
|
||||
padded buffer), so it only stops at a long all-numeric backstop.
|
||||
"""
|
||||
|
||||
def __init__(self, model: str):
|
||||
self.model = model or "model"
|
||||
self.last_token = ""
|
||||
self.same_run = 0
|
||||
self.numeric_run = 0
|
||||
self.recent_tokens: List[str] = []
|
||||
self.periodic_tokens: List[str] = []
|
||||
self.total_chars = 0
|
||||
self._buffer = ""
|
||||
|
||||
def check(self, text: str) -> Optional[str]:
|
||||
if not text:
|
||||
return None
|
||||
self.total_chars += len(text)
|
||||
# Keep one-character tokens. Structured output often varies through
|
||||
# numeric selectors or coordinates; dropping those tokens can make
|
||||
# valid CSS/SVG rows look like an exact repeated phrase.
|
||||
tokens = [t.lower() for t in _DEGENERATE_WORD_RE.findall(text)]
|
||||
self._buffer += text
|
||||
|
||||
m_delims = list(_DELIM_RE.finditer(self._buffer))
|
||||
if m_delims:
|
||||
split_idx = m_delims[-1].end()
|
||||
head = self._buffer[:split_idx]
|
||||
self._buffer = self._buffer[split_idx:]
|
||||
elif len(self._buffer) >= 256:
|
||||
head = self._buffer
|
||||
self._buffer = ""
|
||||
else:
|
||||
return None
|
||||
|
||||
tokens = []
|
||||
for m in _TOKEN_RE.finditer(head):
|
||||
tokens.append((m.group(0).lower(), m.lastgroup == "NUM"))
|
||||
if not tokens:
|
||||
return None
|
||||
for token in tokens:
|
||||
|
||||
for token, is_num in tokens:
|
||||
if token == self.last_token:
|
||||
self.same_run += 1
|
||||
else:
|
||||
self.last_token = token
|
||||
self.same_run = 1
|
||||
self.numeric_run = self.numeric_run + 1 if is_num else 0
|
||||
self.recent_tokens.append(token)
|
||||
self.periodic_tokens.append(token)
|
||||
if len(self.recent_tokens) > 96:
|
||||
@@ -466,14 +495,20 @@ class _DegenerateStreamGuard:
|
||||
self.periodic_tokens = self.periodic_tokens[-2048:]
|
||||
|
||||
reason = None
|
||||
if self.same_run >= 28 and self.total_chars >= 100:
|
||||
if self.same_run >= 28 and self.total_chars >= 100 and not _is_numeric_token(self.last_token):
|
||||
reason = f"repeated '{self.last_token}' {self.same_run} times"
|
||||
elif len(self.recent_tokens) >= 72:
|
||||
top = max(set(self.recent_tokens), key=self.recent_tokens.count)
|
||||
count = self.recent_tokens.count(top)
|
||||
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78:
|
||||
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78 and not _is_numeric_token(top):
|
||||
reason = f"repeated '{top}' {count}/{len(self.recent_tokens)} recent tokens"
|
||||
if not reason and len(self.recent_tokens) >= 48:
|
||||
if (
|
||||
not reason
|
||||
and len(self.recent_tokens) >= 48
|
||||
# A window that is mostly numbers is matrix/table data; its
|
||||
# periodicity is real. Mixed loops still hit the exact-block check.
|
||||
and sum(_is_numeric_token(t) for t in self.recent_tokens) * 2 < len(self.recent_tokens)
|
||||
):
|
||||
# Detect a periodic suffix instead of counting repeated n-grams.
|
||||
# Reused property names are normal in CSS/SVG/JSON; a collapsed
|
||||
# generation repeats nearly every token at a short fixed period.
|
||||
@@ -508,12 +543,15 @@ class _DegenerateStreamGuard:
|
||||
if history[previous:previous + anchor_size] != anchor:
|
||||
continue
|
||||
period = current_anchor - previous
|
||||
if period < 24 or len(history) < period * 2:
|
||||
if period < 24 or len(history) < period * 2 or self.numeric_run >= period:
|
||||
continue
|
||||
if history[-2 * period:-period] == history[-period:]:
|
||||
if history[-2 * period:-period] == history[-period:] and not all(_is_numeric_token(t) for t in history[-period:]):
|
||||
reason = f"repeated an exact {period}-token block twice"
|
||||
break
|
||||
|
||||
if not reason and self.numeric_run >= 2048:
|
||||
reason = f"emitted {self.numeric_run} numbers in a row"
|
||||
|
||||
if not reason:
|
||||
return None
|
||||
|
||||
@@ -1556,8 +1594,9 @@ def _build_chatgpt_responses_payload(
|
||||
"stream": stream,
|
||||
"store": False,
|
||||
}
|
||||
if not _restricts_temperature(model):
|
||||
payload["temperature"] = temperature
|
||||
# ChatGPT plan Responses requests reject temperature for every model.
|
||||
# The common caller still passes its preset value, but this provider must
|
||||
# omit the field even for model names outside the known reasoning families.
|
||||
# ChatGPT Subscription Codex API does not support max_output_tokens —
|
||||
# passing it returns HTTP 400 "Unsupported parameter: max_output_tokens".
|
||||
# Do not include it in the payload.
|
||||
|
||||
@@ -10,6 +10,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
import contextlib
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
from src.database import McpServer, SessionLocal
|
||||
|
||||
@@ -383,12 +384,21 @@ class McpManager:
|
||||
|
||||
provider = build_provider(server_id, url, on_redirect=_on_redirect)
|
||||
stack = AsyncExitStack()
|
||||
try:
|
||||
transport = await stack.enter_async_context(streamablehttp_client(url, auth=provider))
|
||||
read_stream, write_stream, _get_session_id = transport
|
||||
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
|
||||
await session.initialize()
|
||||
|
||||
tools_result = await session.list_tools()
|
||||
except BaseException:
|
||||
# Close the half-open transport here, in the task that entered
|
||||
# it. Left for the garbage collector, its AnyIO cancel scope is
|
||||
# exited from the wrong task and the event loop keeps spinning
|
||||
# (#5518). Its own teardown error would only mask the real one.
|
||||
with contextlib.suppress(Exception):
|
||||
await stack.aclose()
|
||||
raise
|
||||
tools = []
|
||||
for tool in tools_result.tools:
|
||||
tools.append({
|
||||
|
||||
@@ -193,7 +193,10 @@ class ModelDiscovery:
|
||||
data = r.json()
|
||||
# Some OpenAI-compatible servers return a bare list, not {"data": [...]}.
|
||||
items = data if isinstance(data, list) else ((data or {}).get("data") or [])
|
||||
ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
|
||||
ids = [
|
||||
m["id"] for m in items
|
||||
if isinstance(m, dict) and isinstance(m.get("id"), str) and m["id"]
|
||||
]
|
||||
if ids:
|
||||
return {
|
||||
"host": host,
|
||||
|
||||
+4
-2
@@ -102,8 +102,10 @@ _PLAIN_FRONT_MATTER_RE = re.compile(
|
||||
# - **label:** value <!-- field=NAME-ENC type=text -->
|
||||
# - **label** [opts]: value <!-- field=NAME-ENC type=choice -->
|
||||
# - [x] **label** <!-- field=NAME-ENC type=checkbox -->
|
||||
# Accept Unicode word characters for older saved markers, whose encoder left
|
||||
# non-ASCII letters/numbers literal. Newly rendered names are ASCII-safe.
|
||||
_FIELD_BULLET_RE = re.compile(
|
||||
r'^\s*-\s+(?P<body>.*?)\s*<!--\s*field=(?P<name>[A-Za-z0-9_.%-]+)\s+type=(?P<type>\w+)\s*-->\s*$'
|
||||
r'^\s*-\s+(?P<body>.*?)\s*<!--\s*field=(?P<name>[\w.%-]+)\s+type=(?P<type>\w+)\s*-->\s*$'
|
||||
)
|
||||
|
||||
|
||||
@@ -115,7 +117,7 @@ def _encode_name(name: str) -> str:
|
||||
"""
|
||||
out = []
|
||||
for ch in name or "":
|
||||
if ch.isalnum() or ch in ("_", ".", "-"):
|
||||
if (ch.isascii() and ch.isalnum()) or ch in ("_", ".", "-"):
|
||||
out.append(ch)
|
||||
else:
|
||||
for b in ch.encode("utf-8"):
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Pre-Wave-3 private_browser schema (agent-browser wrapper), used when ODYSSEUS_PRIVATE_BROWSER_LEGACY is on."""
|
||||
|
||||
PRIVATE_BROWSER_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "private_browser",
|
||||
"description": "Private browser automation through Odysseus' agent-browser wrapper. After open, snapshot the page and interact with returned element refs such as @e12; click/fill target is a selector or element ref, never guessed visible text. Prefer one batch for known consecutive steps, such as open plus snapshot. Use only when a specific page needs JavaScript, login/session state, interaction, or rendered DOM. For open-ended search use web_search; for reading a normal URL use web_fetch.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {"type": "string", "enum": ["open", "read", "snapshot", "find", "evaluate", "click", "fill", "press", "scroll", "wait", "screenshot", "close", "batch"]},
|
||||
"url": {"type": "string", "description": "Required URL for open; optional URL for read (omit to read the current page)"},
|
||||
"selector": {"type": "string", "description": "Element ref or selector for read/click/fill/wait"},
|
||||
"target": {"type": "string", "description": "Element ref returned by snapshot (preferred, e.g. @e12) or CSS selector for read/click/fill/wait; never a guessed visible label; top or bottom for scroll"},
|
||||
"key": {"type": "string", "description": "Key name for press action, e.g. Enter"},
|
||||
"direction": {"type": "string", "enum": ["up", "down", "left", "right"], "description": "Direction for scroll action"},
|
||||
"amount": {"type": "integer", "minimum": 1, "description": "Optional scroll distance in pixels; default 300"},
|
||||
"text": {"type": "string", "description": "Text for fill action"},
|
||||
"value": {"type": "string", "description": "Alternative text/value for fill action"},
|
||||
"find": {"type": "string", "description": "Visible text to locate for find action"},
|
||||
"script": {"type": "string", "description": "JavaScript expression for evaluate action"},
|
||||
"path": {"type": "string", "description": "Optional output path for screenshot"},
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"description": "Non-empty batch commands as arrays, e.g. [[\"open\", \"https://example.com\"], [\"snapshot\"]]. Do not send an empty batch; use action=snapshot for current page state.",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{"type": "array", "items": {"type": "string"}},
|
||||
{"type": "object"},
|
||||
]
|
||||
},
|
||||
},
|
||||
"timeout_ms": {"type": "integer", "description": "Optional operation timeout, max 120000; for action=wait without a selector, this is the wait duration"}
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
-8
@@ -76,9 +76,23 @@ _ERROR_DETAIL = {
|
||||
"auth_or_protocol_error": "authentication or protocol error",
|
||||
"no_models": "endpoint returned no models",
|
||||
"no_host": "no host configured",
|
||||
"config_source_error": "configuration source could not be read",
|
||||
"error": "probe failed",
|
||||
}
|
||||
|
||||
# Config/inventory source behind each network subsystem. `_gather_inputs()`
|
||||
# fails soft, so a source that could not be read hands its probe an empty
|
||||
# inventory — indistinguishable from "nothing configured", which every probe
|
||||
# answers with `disabled`. The collector therefore reports the source failure
|
||||
# instead of running a probe whose verdict would be a lie.
|
||||
CONFIG_SOURCE_ERROR = "config_source_error"
|
||||
_SUBSYSTEM_SOURCE = {
|
||||
"searxng": "settings",
|
||||
"ntfy": "integrations",
|
||||
"email": "accounts",
|
||||
"providers": "endpoints",
|
||||
}
|
||||
|
||||
|
||||
def _svc(name: str, status: str, detail: str, **meta: Any) -> Dict[str, Any]:
|
||||
return {"name": name, "status": status, "detail": detail, "meta": dict(meta)}
|
||||
@@ -411,26 +425,33 @@ def _gather_inputs() -> Dict[str, Any]:
|
||||
"""Pull live config/account/endpoint lists from the app's data sources.
|
||||
|
||||
Each lookup fails soft: a broken source yields an empty/neutral value so a
|
||||
single failure can't take down the whole health report.
|
||||
single failure can't take down the whole health report. The failure is also
|
||||
*recorded* under `failed` (source -> controlled category), because the empty
|
||||
value alone cannot be told apart from a source that loaded and is genuinely
|
||||
empty — and the probes read empty as `disabled`.
|
||||
"""
|
||||
settings: Dict[str, Any] = {}
|
||||
integrations: List[Dict[str, Any]] = []
|
||||
accounts: List[Dict[str, Any]] = []
|
||||
endpoints: List[Dict[str, Any]] = []
|
||||
failed: Dict[str, str] = {}
|
||||
try:
|
||||
from src.settings import load_settings
|
||||
settings = load_settings() or {}
|
||||
except Exception as e:
|
||||
failed["settings"] = _classify_error(e)
|
||||
logger.debug(f"service_health: settings load failed: {e}")
|
||||
try:
|
||||
from src.integrations import load_integrations
|
||||
integrations = load_integrations() or []
|
||||
except Exception as e:
|
||||
failed["integrations"] = _classify_error(e)
|
||||
logger.debug(f"service_health: integrations load failed: {e}")
|
||||
try:
|
||||
from routes.email_helpers import _list_email_accounts
|
||||
accounts = _list_email_accounts() or []
|
||||
except Exception as e:
|
||||
failed["accounts"] = _classify_error(e)
|
||||
logger.debug(f"service_health: email accounts load failed: {e}")
|
||||
try:
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
@@ -443,9 +464,10 @@ def _gather_inputs() -> Dict[str, Any]:
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
failed["endpoints"] = _classify_error(e)
|
||||
logger.debug(f"service_health: endpoint load failed: {e}")
|
||||
return {"settings": settings, "integrations": integrations,
|
||||
"accounts": accounts, "endpoints": endpoints}
|
||||
"accounts": accounts, "endpoints": endpoints, "failed": failed}
|
||||
|
||||
|
||||
async def _run_subsystem(name: str, fn: Callable, *args: Any) -> Dict[str, Any]:
|
||||
@@ -464,6 +486,24 @@ async def _run_subsystem(name: str, fn: Callable, *args: Any) -> Dict[str, Any]:
|
||||
return _svc(name, DOWN, _detail_for(category), error=category)
|
||||
|
||||
|
||||
async def _source_unavailable(name: str, source: str,
|
||||
category: str) -> Dict[str, Any]:
|
||||
"""Controlled entry for a subsystem whose config source could not be read.
|
||||
|
||||
Deliberately not `disabled`: the configured state is *unknown*, not known to
|
||||
be off. `degraded` rather than `down` because the failure is in reading the
|
||||
inventory, not a probe that reached the subsystem and failed — but it still
|
||||
carries non-zero severity, so a report containing one can never roll up to
|
||||
`ok`. `meta` names the source and the controlled category of the underlying
|
||||
exception; never its text.
|
||||
"""
|
||||
return _svc(name, DEGRADED,
|
||||
f"Could not read configuration "
|
||||
f"({_detail_for(CONFIG_SOURCE_ERROR)}).",
|
||||
error=CONFIG_SOURCE_ERROR, source=source,
|
||||
source_error=category)
|
||||
|
||||
|
||||
async def collect_service_health(rag_manager: Any = None,
|
||||
memory_vector: Any = None) -> Dict[str, Any]:
|
||||
"""Run every probe and return {overall, services, timestamp}.
|
||||
@@ -472,6 +512,10 @@ async def collect_service_health(rag_manager: Any = None,
|
||||
four network subsystems run concurrently, each under `_SUBSYSTEM_DEADLINE`,
|
||||
with an overall `_AGGREGATE_DEADLINE` backstop. Per-item probes inside
|
||||
providers/email are themselves bounded by `_FANOUT_BUDGET`.
|
||||
|
||||
A subsystem whose config/inventory source failed to load is reported via
|
||||
`_source_unavailable()` instead of being probed with an empty inventory,
|
||||
which every probe would otherwise report as `disabled`.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -482,12 +526,23 @@ async def collect_service_health(rag_manager: Any = None,
|
||||
chroma = chromadb_health(rag_manager, memory_vector)
|
||||
|
||||
names = ["searxng", "ntfy", "email", "providers"]
|
||||
coros = [
|
||||
_run_subsystem("searxng", searxng_health, settings),
|
||||
_run_subsystem("ntfy", ntfy_health, inputs["integrations"], settings),
|
||||
_run_subsystem("email", email_health, inputs["accounts"]),
|
||||
_run_subsystem("providers", providers_health, inputs["endpoints"]),
|
||||
]
|
||||
probes = {
|
||||
"searxng": (searxng_health, (settings,)),
|
||||
"ntfy": (ntfy_health, (inputs["integrations"], settings)),
|
||||
"email": (email_health, (inputs["accounts"],)),
|
||||
"providers": (providers_health, (inputs["endpoints"],)),
|
||||
}
|
||||
# A subsystem whose inventory could not be read is reported as such. The
|
||||
# others are still probed normally — one broken source degrades one entry.
|
||||
failed = inputs.get("failed") or {}
|
||||
coros = []
|
||||
for name in names:
|
||||
source = _SUBSYSTEM_SOURCE[name]
|
||||
if source in failed:
|
||||
coros.append(_source_unavailable(name, source, failed[source]))
|
||||
else:
|
||||
fn, args = probes[name]
|
||||
coros.append(_run_subsystem(name, fn, *args))
|
||||
try:
|
||||
results = await asyncio.wait_for(asyncio.gather(*coros),
|
||||
timeout=_AGGREGATE_DEADLINE)
|
||||
|
||||
+15
-56
@@ -12,16 +12,6 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Names that indicate a throwaway/test session
|
||||
_THROWAWAY_NAMES = {
|
||||
"test", "testing", "asdf", "asd", "hello", "hi", "hey",
|
||||
"yo", "sup", "hola", "hii", "hiii", "heyo",
|
||||
"foo", "bar", "baz", "tmp", "temp", "scratch", "untitled",
|
||||
"new chat", "delete", "remove", "junk", "trash", "xxx",
|
||||
"abc", "qwerty", "blah", "stuff", "whatever", "idk",
|
||||
"ok", "lol", "bruh", "hmm", "hm", "meh",
|
||||
}
|
||||
_THROWAWAY_MAX_MESSAGES = 4
|
||||
_FRESH_EMPTY_SESSION_GRACE = timedelta(minutes=10)
|
||||
_FRESH_SESSION_GRACE = _FRESH_EMPTY_SESSION_GRACE
|
||||
|
||||
@@ -54,24 +44,25 @@ def is_session_recently_active(row, now=None, grace=_FRESH_SESSION_GRACE) -> boo
|
||||
|
||||
|
||||
async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bool = True) -> str:
|
||||
"""Run session cleanup + (optional) AI folder sort for the given owner.
|
||||
"""Remove empty/Incognito sessions and optionally organize saved chats.
|
||||
|
||||
Args:
|
||||
owner: user whose sessions to process
|
||||
skip_llm: when True, do only Phase 1 (delete empty/throwaway sessions);
|
||||
skip_llm: when True, do only Phase 1 (delete empty/Incognito sessions);
|
||||
skip Phase 2 (AI folder assignment). Used by the built-in daily
|
||||
background sweep so it never burns LLM tokens.
|
||||
delete_throwaway: when False, only empty/incognito sessions are deleted.
|
||||
delete_throwaway: retained for caller compatibility. Ordinary sessions
|
||||
with saved messages are always preserved, regardless of this flag.
|
||||
|
||||
Returns a human-readable summary of what was done.
|
||||
"""
|
||||
from core.database import SessionLocal, Session as DbSession, ChatMessage as DbMsg
|
||||
from src.llm_core import llm_call_async
|
||||
from src.task_endpoint import resolve_task_endpoint
|
||||
from src.task_endpoint import resolve_task_endpoint, task_llm_call_async
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# ── Phase 1: Delete empty/throwaway sessions ──
|
||||
# ── Phase 1: Delete only empty and explicitly Incognito sessions ──
|
||||
deleted_empty = 0
|
||||
deleted_throwaway = 0
|
||||
|
||||
@@ -93,47 +84,13 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
|
||||
if is_session_recently_active(row, now=cleanup_now):
|
||||
continue
|
||||
|
||||
msg_count = db.query(DbMsg.id).filter(
|
||||
has_messages = db.query(DbMsg.id).filter(
|
||||
DbMsg.session_id == row.id
|
||||
).limit(_THROWAWAY_MAX_MESSAGES + 1).count()
|
||||
should_delete = False
|
||||
|
||||
if msg_count == 0:
|
||||
).first() is not None
|
||||
if not has_messages:
|
||||
if is_fresh:
|
||||
continue
|
||||
should_delete = True
|
||||
deleted_empty += 1
|
||||
elif delete_throwaway and msg_count <= _THROWAWAY_MAX_MESSAGES:
|
||||
name = (row.name or "").strip().lower()
|
||||
first_msg = db.query(DbMsg.content).filter(
|
||||
DbMsg.session_id == row.id, DbMsg.role == "user"
|
||||
).order_by(DbMsg.timestamp).first()
|
||||
first_text = (first_msg[0] or "").strip().lower() if first_msg else ""
|
||||
assistant_count = db.query(DbMsg.id).filter(
|
||||
DbMsg.session_id == row.id, DbMsg.role == "assistant"
|
||||
).limit(1).count()
|
||||
|
||||
if name in _THROWAWAY_NAMES or name.startswith("chat:") or first_text in _THROWAWAY_NAMES:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
elif msg_count == 1 and assistant_count == 0:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
elif msg_count <= 4 and first_text and len(first_text.split()) <= 8 and len(first_text) <= 80:
|
||||
# Short trivial chats — e.g. "write hi to a friend" → "Hi!"
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
else:
|
||||
# Aggressive: total message text under 250 chars combined = trivial
|
||||
msg_rows = db.query(DbMsg.content).filter(
|
||||
DbMsg.session_id == row.id
|
||||
).all()
|
||||
total_chars = sum(len(m[0] or "") for m in msg_rows)
|
||||
if total_chars <= 250:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
|
||||
if should_delete:
|
||||
db.delete(row)
|
||||
|
||||
if deleted_empty or deleted_throwaway:
|
||||
@@ -164,8 +121,6 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
|
||||
return f"Cleaned {deleted_empty + deleted_throwaway} sessions (folder sort skipped)."
|
||||
|
||||
url, model, headers = resolve_task_endpoint(owner=owner or None)
|
||||
if not url:
|
||||
return f"Cleaned {deleted_empty + deleted_throwaway} sessions. No model endpoint available for sorting."
|
||||
|
||||
names_text = "\n".join(f' "{s["id"][:8]}": "{s["name"]}"' for s in session_list)
|
||||
prompt = (
|
||||
@@ -183,8 +138,12 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
|
||||
try:
|
||||
# 16384 (was 4096): large folder JSON + reasoning-model thinking
|
||||
# overflowed 4096 and truncated the JSON, so it never parsed.
|
||||
raw = await llm_call_async(url, model, [{"role": "user", "content": prompt}],
|
||||
temperature=0.3, max_tokens=16384, headers=headers, timeout=120)
|
||||
raw = await task_llm_call_async(
|
||||
[{"role": "user", "content": prompt}],
|
||||
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
||||
owner=owner or None,
|
||||
temperature=0.3, max_tokens=16384, timeout=120,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Auto-sort LLM call failed: {e}")
|
||||
return f"Cleaned {deleted_empty + deleted_throwaway} sessions. Folder sort skipped (model unreachable)."
|
||||
|
||||
+33
-5
@@ -57,25 +57,53 @@ def _message_to_context(msg: DBChatMessage) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _escape_like(value: str) -> str:
|
||||
def escape_like_pattern(value: str) -> str:
|
||||
"""Escape SQL LIKE wildcards (``%``, ``_``) and the escape char itself.
|
||||
|
||||
Public shared helper for building ``ilike(f"%{...}%", escape="\\\\")``
|
||||
patterns from user input. Must escape backslash first.
|
||||
"""
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _escape_like(value: str) -> str:
|
||||
return escape_like_pattern(value)
|
||||
|
||||
|
||||
def _snippet(content: str, query: str, radius: int = 60) -> str:
|
||||
content = content or ""
|
||||
query = query or ""
|
||||
if not query:
|
||||
return content[: radius * 2]
|
||||
|
||||
idx = content.lower().find(query.lower())
|
||||
if idx == -1:
|
||||
match_start = -1
|
||||
match_length = 0
|
||||
lowered_content = content.lower()
|
||||
for candidate in _query_terms(query):
|
||||
idx = lowered_content.find(candidate.lower())
|
||||
if idx != -1 and (match_start == -1 or idx < match_start):
|
||||
match_start = idx
|
||||
match_length = len(candidate)
|
||||
|
||||
if match_start == -1:
|
||||
return content[: radius * 2]
|
||||
|
||||
start = max(0, idx - radius)
|
||||
end = min(len(content), idx + len(query) + radius)
|
||||
start = max(0, match_start - radius)
|
||||
end = min(len(content), match_start + match_length + radius)
|
||||
return ("..." if start > 0 else "") + content[start:end] + ("..." if end < len(content) else "")
|
||||
|
||||
|
||||
def _query_terms(query: str) -> list[str]:
|
||||
"""Return meaningful quoted phrases and tokens in user-entered order."""
|
||||
terms: list[str] = []
|
||||
for match in re.finditer(r'"([^"]+)"|[\w][\w._-]*', query, flags=re.UNICODE):
|
||||
term = match.group(1) if match.group(1) is not None else match.group(0).strip("._-")
|
||||
term = term.strip()
|
||||
if term:
|
||||
terms.append(term)
|
||||
return terms
|
||||
|
||||
|
||||
def _sanitize_fts_query(query: str) -> str | None:
|
||||
"""Convert free text into a conservative FTS5 MATCH query.
|
||||
|
||||
|
||||
+22
-1
@@ -46,7 +46,14 @@ DEFAULT_SETTINGS = {
|
||||
"agent_email_confirm": True,
|
||||
"image_gen_enabled": False,
|
||||
"image_model": "",
|
||||
"image_edit_model": "",
|
||||
"image_quality": "medium",
|
||||
# Optional editor planner/caption endpoints. Empty uses configured chat
|
||||
# fallback without probing a developer-specific host.
|
||||
"editor_agent_base_url": "",
|
||||
"editor_agent_model": "",
|
||||
"editor_caption_base_url": "",
|
||||
"editor_caption_model": "",
|
||||
"vision_model": "",
|
||||
"vision_enabled": True,
|
||||
# Ordered fallback chain for the Vision model (image analysis, OCR, tagging).
|
||||
@@ -288,7 +295,7 @@ def is_setting_overridden(key: str) -> bool:
|
||||
# resolved by FastAPI deps; an empty/None owner falls through to the global.
|
||||
_PER_USER_KEYS = {
|
||||
"vision_model", "vision_enabled", "vision_model_fallbacks",
|
||||
"image_model", "image_gen_enabled", "image_quality",
|
||||
"image_model", "image_edit_model", "image_gen_enabled", "image_quality",
|
||||
# Default chat endpoint / model — without per-user resolution every new
|
||||
# account inherited whatever the most-recent admin picked, which then
|
||||
# got injected into the chat composer on first open.
|
||||
@@ -307,6 +314,20 @@ def get_user_setting(key: str, owner: str = "", default: Any = None) -> Any:
|
||||
Falls back gracefully if the prefs module can't be imported (cycle/early
|
||||
boot) — admin-global settings keep working.
|
||||
"""
|
||||
if key == "image_gen_enabled":
|
||||
global_val = get_setting("image_gen_enabled", default if default is not None else False)
|
||||
if not global_val:
|
||||
return False
|
||||
if owner:
|
||||
try:
|
||||
from routes.prefs_routes import _load_for_user
|
||||
prefs = _load_for_user(owner) or {}
|
||||
if "image_gen_enabled" in prefs and prefs["image_gen_enabled"] is not None:
|
||||
return bool(prefs["image_gen_enabled"])
|
||||
except Exception:
|
||||
pass
|
||||
return bool(global_val)
|
||||
|
||||
if owner and key in _PER_USER_KEYS:
|
||||
try:
|
||||
from routes.prefs_routes import _load_for_user
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Process-wide shutdown signalling for graceful restarts.
|
||||
|
||||
Server-Sent-Events / streaming responses never complete on their own, so
|
||||
uvicorn's graceful shutdown would wait for an open browser stream up to the
|
||||
full ``timeout_graceful_shutdown`` window before exiting. A SIGTERM/SIGINT
|
||||
handler flips this flag and tells uvicorn to exit; every streaming generator
|
||||
checks the flag on each loop and ends promptly, so a restart completes in
|
||||
seconds while ordinary in-flight requests still finish.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SHUTTING_DOWN = threading.Event()
|
||||
|
||||
|
||||
def is_shutting_down() -> bool:
|
||||
"""True once a shutdown (SIGTERM/SIGINT) has been requested."""
|
||||
return _SHUTTING_DOWN.is_set()
|
||||
|
||||
|
||||
def request_shutdown() -> None:
|
||||
"""Mark the process as shutting down (idempotent)."""
|
||||
if not _SHUTTING_DOWN.is_set():
|
||||
_SHUTTING_DOWN.set()
|
||||
logger.info("shutdown requested; streaming responses will end promptly")
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
"""Clear the flag (for tests)."""
|
||||
_SHUTTING_DOWN.clear()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Helpers for the experimental Spec driver, whose execution is disabled."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
from src import spec_router
|
||||
from src.agent_runtime.authority import MISSING_AUTHORITY, with_request_authority
|
||||
from src.clean_agent_preview import (
|
||||
event, preview_model_response, preview_tool_result_text,
|
||||
)
|
||||
from src.tool_capabilities import ToolRunSecurityContext
|
||||
from src.tool_execution import execute_tool_block
|
||||
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS, function_call_to_tool_block
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _text(content):
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return '\n'.join(str(p.get('text') or '') for p in content
|
||||
if isinstance(p, dict) and p.get('type') == 'text')
|
||||
return ''
|
||||
|
||||
|
||||
def _name(schema):
|
||||
return (schema.get('function') or {}).get('name') or ''
|
||||
|
||||
|
||||
# JSON-schema keywords the provider's guided decoding (named tool_choice) cannot compile.
|
||||
_GRAMMAR_UNSUPPORTED = frozenset({
|
||||
'uniqueItems', 'contains', 'minContains', 'maxContains', 'patternProperties', 'propertyNames',
|
||||
'dependentRequired', 'dependentSchemas', 'if', 'then', 'else',
|
||||
'unevaluatedProperties', 'unevaluatedItems',
|
||||
})
|
||||
|
||||
|
||||
def grammar_safe(schema):
|
||||
"""Copy of a schema without keywords guided decoding rejects (validation still runs at execution)."""
|
||||
if isinstance(schema, dict):
|
||||
return {k: grammar_safe(v) for k, v in schema.items() if k not in _GRAMMAR_UNSUPPORTED}
|
||||
if isinstance(schema, list):
|
||||
return [grammar_safe(v) for v in schema]
|
||||
return schema
|
||||
|
||||
|
||||
def spec_surface(disabled_tools):
|
||||
disabled = set(disabled_tools or ())
|
||||
return [s for s in FUNCTION_TOOL_SCHEMAS if _name(s) and _name(s) not in disabled]
|
||||
|
||||
|
||||
async def _model_round(client, endpoint_url, headers, request, recovery):
|
||||
"""Run one chat-model round; return (content, tool_calls, usage)."""
|
||||
content, calls, usage, raw = '', {}, {}, []
|
||||
async with preview_model_response(client, endpoint_url, headers, request, recovery) as response:
|
||||
async for line in response.aiter_lines():
|
||||
if len(raw) < 4 and line.strip():
|
||||
raw.append(line[:400])
|
||||
if line.strip() and not line.startswith('data: '):
|
||||
logger.warning('[spec-driver] unexpected stream line: %s', line[:500])
|
||||
if not line.startswith('data: ') or line.strip() == 'data: [DONE]':
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(line[6:])
|
||||
except ValueError:
|
||||
continue
|
||||
if chunk.get('error') or chunk.get('object') == 'error':
|
||||
raise RuntimeError(f"model stream error: {str(chunk.get('error') or chunk)[:500]}")
|
||||
usage = chunk.get('usage') or usage
|
||||
for choice in chunk.get('choices') or []:
|
||||
delta = choice.get('delta') or {}
|
||||
text = delta.get('content') or ''
|
||||
content += text
|
||||
for frag in delta.get('tool_calls') or []:
|
||||
call = calls.setdefault(frag.get('index', 0), {'id': '', 'name': '', 'arguments': ''})
|
||||
call['id'] = frag.get('id') or call['id']
|
||||
fn = frag.get('function') or {}
|
||||
call['name'] += fn.get('name') or ''
|
||||
call['arguments'] += fn.get('arguments') or ''
|
||||
if not calls:
|
||||
logger.warning('[spec-driver] no tool call; status=%s max_tokens=%s msgs=%s first lines=%s',
|
||||
getattr(response, 'status_code', None), request.get('max_tokens'),
|
||||
[(m.get('role'), type(m.get('content')).__name__,
|
||||
[p.get('type') for p in m['content']] if isinstance(m.get('content'), list) else len(str(m.get('content') or '')))
|
||||
for m in request.get('messages', [])], raw[-2:])
|
||||
return content, [calls[i] for i in sorted(calls)], usage
|
||||
|
||||
|
||||
@with_request_authority
|
||||
async def stream_spec_turn(*, endpoint_url, model, messages, headers, session_id, owner,
|
||||
disabled_tools, tool_policy, workspace=None, active_document=None,
|
||||
client_runtime_context=None, external_untrusted_context_seen=False,
|
||||
max_tokens=768, max_rounds=8, temperature=0.0,
|
||||
request_authority=MISSING_AUTHORITY, **ignored):
|
||||
# This experimental runtime lacks the normal turn and completion gates.
|
||||
# Refuse direct callers too, before contacting a router or executor.
|
||||
yield event({'delta': 'Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed.'})
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
@@ -0,0 +1,661 @@
|
||||
"""Odysseus spec: small-model tool router (JEFF-style) for the agent harness.
|
||||
|
||||
The router (a 0.8B decision model served by jeff-serve) reads the user's
|
||||
message and scores offered tools in one forward pass. The harness may narrow
|
||||
the existing turn contract, but cannot add authority or drop its obligations.
|
||||
On an error, timeout or low-confidence answer it retains the existing contract.
|
||||
|
||||
Env:
|
||||
ODYSSEUS_SPEC_ROUTER_URL jeff-serve base URL, e.g. http://ajax:8765 (unset = disabled)
|
||||
ODYSSEUS_SPEC_ROUTER_MODEL adapter name (default: odysseus-s60)
|
||||
ODYSSEUS_SPEC_ROUTER_TIMEOUT seconds (default: 0.5)
|
||||
ODYSSEUS_SPEC_ROUTER_MIN_CONF minimum top-1 probability to act (default: 0.3)
|
||||
ODYSSEUS_SPEC_ROUTER_SECOND minimum 2nd-choice probability to also add it (default: 0.25)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
import asyncio
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from contextvars import ContextVar
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_HEAD = {
|
||||
"answer_directly": "No tool is needed: answer the user directly from the conversation",
|
||||
"ask_user": "A tool is needed but required information is missing: ask the user first",
|
||||
}
|
||||
_INSTRUCTIONS = (
|
||||
"Which tool should the agent call next to handle the user's latest message? "
|
||||
"Use the conversation for context. If no tool is needed, choose answer directly. "
|
||||
"If a tool is needed but information it requires is missing, choose ask the user."
|
||||
)
|
||||
_AGENT = "Odysseus, a personal tool-using assistant."
|
||||
_LAST_DECISION: ContextVar[Optional[Dict[str, Any]]] = ContextVar('spec_router_decision', default=None)
|
||||
|
||||
|
||||
def latest_decision() -> Optional[Dict[str, Any]]:
|
||||
"""Return the router decision in the current request context."""
|
||||
return _LAST_DECISION.get()
|
||||
|
||||
|
||||
def clear_decision() -> None:
|
||||
_LAST_DECISION.set(None)
|
||||
_MCP_BROWSER_TOOLS = (
|
||||
"mcp__builtin_browser__browser_navigate",
|
||||
"mcp__builtin_browser__browser_snapshot",
|
||||
"mcp__builtin_browser__browser_click",
|
||||
"mcp__builtin_browser__browser_type",
|
||||
"mcp__builtin_browser__browser_press_key",
|
||||
"mcp__builtin_browser__browser_wait_for",
|
||||
"mcp__builtin_browser__browser_take_screenshot",
|
||||
)
|
||||
|
||||
|
||||
def _fn(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return schema.get("function") if isinstance(schema.get("function"), dict) else schema
|
||||
|
||||
|
||||
def tool_line(schema: Dict[str, Any]) -> str:
|
||||
fn = _fn(schema)
|
||||
name = str(fn.get("name") or "")
|
||||
props = ((fn.get("parameters") or {}).get("properties") or {})
|
||||
if os.getenv("ODYSSEUS_SPEC_TOOL_FORMAT", "").strip().lower() == "names":
|
||||
return name + "(" + ", ".join(props) + ")" # names + parameters only (spec_v1n adapters are trained on this)
|
||||
desc = " ".join(str(fn.get("description") or "").split())
|
||||
if len(desc) > 220:
|
||||
desc = desc[:217] + "..."
|
||||
return f"{name}({', '.join(props)}): {desc}"
|
||||
|
||||
|
||||
def prior_tool_names(messages: Optional[Iterable[Dict[str, Any]]]) -> Set[str]:
|
||||
"""Tools already called in this conversation (kept offered: no 'forgetting')."""
|
||||
seen: Set[str] = set()
|
||||
for m in messages or []:
|
||||
if not isinstance(m, dict) or m.get("role") != "assistant":
|
||||
continue
|
||||
for tc in m.get("tool_calls") or []:
|
||||
name = (tc.get("function") or tc).get("name") if isinstance(tc, dict) else None
|
||||
if name:
|
||||
seen.add(str(name))
|
||||
return seen
|
||||
|
||||
|
||||
def _conversation(messages: Optional[Iterable[Dict[str, Any]]], limit: int = 4) -> List[Dict[str, str]]:
|
||||
out: List[Dict[str, str]] = []
|
||||
for m in list(messages or []):
|
||||
if isinstance(m, dict) and m.get("role") in ("user", "assistant") and isinstance(m.get("content"), str) and m["content"].strip():
|
||||
out.append({"role": m["role"], "content": m["content"][:600]})
|
||||
# the last user message is passed separately as user_message
|
||||
if out and out[-1]["role"] == "user":
|
||||
out = out[:-1]
|
||||
return out[-limit:]
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return bool(os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip())
|
||||
|
||||
|
||||
def spec_route_tools(
|
||||
user_message: str,
|
||||
schemas: Iterable[Dict[str, Any]],
|
||||
disabled_tools: Optional[Set[str]] = None,
|
||||
messages: Optional[Iterable[Dict[str, Any]]] = None,
|
||||
return_decision: bool = False,
|
||||
):
|
||||
"""Return tool names the router wants offered (plus tools already used).
|
||||
|
||||
With return_decision=True returns (tools, decided) where decided is True when
|
||||
the router answered confidently (a tool pick, or answer_directly)."""
|
||||
added: Set[str] = set(prior_tool_names(messages))
|
||||
decided = False
|
||||
clear_decision()
|
||||
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||
if not url or not (user_message or "").strip():
|
||||
return (added, False) if return_decision else added
|
||||
t0 = time.time()
|
||||
decision: Dict[str, Any] = {"ts": time.time(), "message": user_message[:160], "sticky": sorted(added)}
|
||||
try:
|
||||
disabled = set(disabled_tools or ())
|
||||
lines: Dict[str, str] = {}
|
||||
for s in schemas:
|
||||
n = str(_fn(s).get("name") or "")
|
||||
if n and n not in disabled and n != "ask_user" and n not in lines:
|
||||
lines[n] = tool_line(s)
|
||||
names = sorted(lines)
|
||||
criteria = dict(_HEAD)
|
||||
for i, n in enumerate(names, 1):
|
||||
criteria[f"t{i}"] = lines[n]
|
||||
req = {
|
||||
"model": os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-s60"),
|
||||
"state": {"agent": _AGENT, "conversation": _conversation(messages), "user_message": user_message},
|
||||
"questions": {"tool": {"type": "choice", "instructions": _INSTRUCTIONS, "criteria": criteria}},
|
||||
}
|
||||
http = urllib.request.Request(
|
||||
url + "/v1/systemone", data=json.dumps(req).encode(), headers={"content-type": "application/json"}
|
||||
)
|
||||
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "0.5"))
|
||||
deadline = time.time() + timeout
|
||||
while True:
|
||||
try:
|
||||
out = json.load(urllib.request.urlopen(http, timeout=max(0.2, deadline - time.time())))["answers"]["tool"]
|
||||
break
|
||||
except urllib.error.HTTPError as busy:
|
||||
# 529 = router busy with another request; retry within budget.
|
||||
if busy.code != 529 or time.time() + 0.15 >= deadline:
|
||||
raise
|
||||
decision["retries"] = decision.get("retries", 0) + 1
|
||||
time.sleep(0.1)
|
||||
probs = out.get("probabilities") or {}
|
||||
choice = out.get("choice")
|
||||
if not isinstance(probs, dict) or choice not in criteria:
|
||||
raise ValueError("invalid router choice")
|
||||
for key, probability in probs.items():
|
||||
if key not in criteria or isinstance(probability, bool):
|
||||
raise ValueError("invalid router probability key")
|
||||
number = float(probability)
|
||||
if not math.isfinite(number) or not 0 <= number <= 1:
|
||||
raise ValueError("invalid router probability")
|
||||
probs = {key: float(value) for key, value in probs.items()}
|
||||
min_conf = float(os.getenv("ODYSSEUS_SPEC_ROUTER_MIN_CONF", "0.3"))
|
||||
second_min = float(os.getenv("ODYSSEUS_SPEC_ROUTER_SECOND", "0.25"))
|
||||
ranked = sorted(
|
||||
((p, k) for k, p in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True
|
||||
)
|
||||
picks: List[str] = []
|
||||
if choice and choice.startswith("t") and choice[1:].isdigit() and float(probs.get(choice, 0.0)) >= min_conf:
|
||||
picks.append(names[int(choice[1:]) - 1])
|
||||
for p, k in ranked[:3]:
|
||||
nm = names[int(k[1:]) - 1]
|
||||
if nm not in picks and p >= second_min:
|
||||
picks.append(nm)
|
||||
_bmode = os.getenv("ODYSSEUS_SPEC_BROWSER_FALLBACK", "0")
|
||||
if _bmode != "0" and "private_browser" in picks:
|
||||
# This build disables page execution in private_browser (see
|
||||
# src/browser_identity.py). "mcp": use the connected Playwright MCP
|
||||
# browser (no run_code/evaluate); "web": fetch/search only.
|
||||
picks = [n for n in picks if n != "private_browser"]
|
||||
extra = list(_MCP_BROWSER_TOOLS) if _bmode == "mcp" else []
|
||||
for n in extra + ["web_search", "web_fetch"]:
|
||||
if n not in picks:
|
||||
picks.append(n)
|
||||
added.update(picks)
|
||||
decided = bool(picks) or (choice == "answer_directly" and float(probs.get("answer_directly", 0.0)) >= min_conf)
|
||||
decision.update(choice=choice, tools=picks, ms=round((time.time() - t0) * 1000, 1),
|
||||
top=[(names[int(k[1:]) - 1], round(p, 3)) for p, k in ranked[:3]],
|
||||
answer_directly=round(float(probs.get("answer_directly", 0.0)), 3),
|
||||
ask_user=round(float(probs.get("ask_user", 0.0)), 3))
|
||||
except Exception as exc: # fail open
|
||||
added = set(prior_tool_names(messages))
|
||||
decided = False
|
||||
decision.update(error=str(exc)[:200], ms=round((time.time() - t0) * 1000, 1))
|
||||
logger.warning("spec router failed open: %s", exc)
|
||||
decision["decided"] = decided
|
||||
_LAST_DECISION.set(decision)
|
||||
logger.info("spec router: %s", {key: value for key, value in decision.items() if key != "message"})
|
||||
return (added, decided) if return_decision else added
|
||||
|
||||
|
||||
_ORIGINAL_SPEC_ROUTE_TOOLS = spec_route_tools
|
||||
|
||||
|
||||
async def spec_route_tools_async(
|
||||
user_message: str,
|
||||
schemas: Iterable[Dict[str, Any]],
|
||||
disabled_tools: Optional[Set[str]] = None,
|
||||
messages: Optional[Iterable[Dict[str, Any]]] = None,
|
||||
return_decision: bool = False,
|
||||
):
|
||||
"""Asynchronous non-blocking tool routing.
|
||||
|
||||
Preserves request-local ContextVar semantics, decision propagation,
|
||||
cancellation behavior, timeout limits, and safe fallback on router failure.
|
||||
"""
|
||||
if spec_route_tools is not _ORIGINAL_SPEC_ROUTE_TOOLS:
|
||||
return spec_route_tools(
|
||||
user_message,
|
||||
schemas,
|
||||
disabled_tools=disabled_tools,
|
||||
messages=messages,
|
||||
return_decision=return_decision,
|
||||
)
|
||||
|
||||
added: Set[str] = set(prior_tool_names(messages))
|
||||
decided = False
|
||||
clear_decision()
|
||||
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||
if not url or not (user_message or "").strip():
|
||||
return (added, False) if return_decision else added
|
||||
t0 = time.time()
|
||||
decision: Dict[str, Any] = {"ts": time.time(), "message": user_message[:160], "sticky": sorted(added)}
|
||||
try:
|
||||
disabled = set(disabled_tools or ())
|
||||
lines: Dict[str, str] = {}
|
||||
for s in schemas:
|
||||
n = str(_fn(s).get("name") or "")
|
||||
if n and n not in disabled and n != "ask_user" and n not in lines:
|
||||
lines[n] = tool_line(s)
|
||||
names = sorted(lines)
|
||||
criteria = dict(_HEAD)
|
||||
for i, n in enumerate(names, 1):
|
||||
criteria[f"t{i}"] = lines[n]
|
||||
req = {
|
||||
"model": os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-s60"),
|
||||
"state": {"agent": _AGENT, "conversation": _conversation(messages), "user_message": user_message},
|
||||
"questions": {"tool": {"type": "choice", "instructions": _INSTRUCTIONS, "criteria": criteria}},
|
||||
}
|
||||
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "0.5"))
|
||||
deadline = time.time() + timeout
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
while True:
|
||||
remaining = max(0.2, deadline - time.time())
|
||||
try:
|
||||
resp = await client.post(
|
||||
url + "/v1/systemone",
|
||||
json=req,
|
||||
headers={"content-type": "application/json"},
|
||||
timeout=remaining,
|
||||
)
|
||||
if resp.status_code == 529:
|
||||
if time.time() + 0.15 >= deadline:
|
||||
resp.raise_for_status()
|
||||
decision["retries"] = decision.get("retries", 0) + 1
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
out = resp.json()["answers"]["tool"]
|
||||
break
|
||||
except httpx.HTTPStatusError as busy:
|
||||
if busy.response.status_code != 529 or time.time() + 0.15 >= deadline:
|
||||
raise
|
||||
decision["retries"] = decision.get("retries", 0) + 1
|
||||
await asyncio.sleep(0.1)
|
||||
probs = out.get("probabilities") or {}
|
||||
choice = out.get("choice")
|
||||
if not isinstance(probs, dict) or choice not in criteria:
|
||||
raise ValueError("invalid router choice")
|
||||
for key, probability in probs.items():
|
||||
if key not in criteria or isinstance(probability, bool):
|
||||
raise ValueError("invalid router probability key")
|
||||
number = float(probability)
|
||||
if not math.isfinite(number) or not 0 <= number <= 1:
|
||||
raise ValueError("invalid router probability")
|
||||
probs = {key: float(value) for key, value in probs.items()}
|
||||
min_conf = float(os.getenv("ODYSSEUS_SPEC_ROUTER_MIN_CONF", "0.3"))
|
||||
second_min = float(os.getenv("ODYSSEUS_SPEC_ROUTER_SECOND", "0.25"))
|
||||
ranked = sorted(
|
||||
((p, k) for k, p in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True
|
||||
)
|
||||
picks: List[str] = []
|
||||
if choice and choice.startswith("t") and choice[1:].isdigit() and float(probs.get(choice, 0.0)) >= min_conf:
|
||||
picks.append(names[int(choice[1:]) - 1])
|
||||
for p, k in ranked[:3]:
|
||||
nm = names[int(k[1:]) - 1]
|
||||
if nm not in picks and p >= second_min:
|
||||
picks.append(nm)
|
||||
_bmode = os.getenv("ODYSSEUS_SPEC_BROWSER_FALLBACK", "0")
|
||||
if _bmode != "0" and "private_browser" in picks:
|
||||
picks = [n for n in picks if n != "private_browser"]
|
||||
extra = list(_MCP_BROWSER_TOOLS) if _bmode == "mcp" else []
|
||||
for n in extra + ["web_search", "web_fetch"]:
|
||||
if n not in picks:
|
||||
picks.append(n)
|
||||
added.update(picks)
|
||||
decided = bool(picks) or (choice == "answer_directly" and float(probs.get("answer_directly", 0.0)) >= min_conf)
|
||||
decision.update(choice=choice, tools=picks, ms=round((time.time() - t0) * 1000, 1),
|
||||
top=[(names[int(k[1:]) - 1], round(p, 3)) for p, k in ranked[:3]],
|
||||
answer_directly=round(float(probs.get("answer_directly", 0.0)), 3),
|
||||
ask_user=round(float(probs.get("ask_user", 0.0)), 3))
|
||||
except Exception as exc: # fail open
|
||||
added = set(prior_tool_names(messages))
|
||||
decided = False
|
||||
decision.update(error=str(exc)[:200], ms=round((time.time() - t0) * 1000, 1))
|
||||
logger.warning("spec router failed open: %s", exc)
|
||||
decision["decided"] = decided
|
||||
_LAST_DECISION.set(decision)
|
||||
logger.info("spec router: %s", {key: value for key, value in decision.items() if key != "message"})
|
||||
return (added, decided) if return_decision else added
|
||||
|
||||
|
||||
# ---- Ajax Spec: system prompt trimmed to the tools actually offered ----------
|
||||
_CORE_PROMPT = (
|
||||
"You are Ajax, the assistant in Odysseus. Emit one native tool call, then stop. "
|
||||
"After a successful tool result, answer from it; never repeat the same tool with the same arguments."
|
||||
)
|
||||
_CODING_PROMPT = (
|
||||
"HARD CODING ORDER: for code changes call read_file, then edit_file or apply_patch, then host_shell for tests. "
|
||||
"Never run tests before the edit succeeds; after a read, your next call is the edit. "
|
||||
"edit_file JSON is {path,old_string,new_string}; write_file only for new files or full rewrites."
|
||||
)
|
||||
_TOOL_NOTES = {
|
||||
"manage_notes": "manage_notes: notes/checklists.",
|
||||
"manage_documents": "manage_documents: document library; actions list/read/delete/tidy, use list+search to find by title/content.",
|
||||
"create_document": "create_document: create editor-panel documents with title/content.",
|
||||
"edit_document": "edit_document: edit the active editor document using command=<<<FIND>>>old<<<REPLACE>>>new<<<END>>> or edits=[{find,replace}].",
|
||||
"update_document": "update_document: full active-document rewrite only.",
|
||||
"manage_calendar": "manage_calendar: calendar events; actions create_event, update_event, delete_event, list_events.",
|
||||
"manage_tasks": "manage_tasks: scheduled/recurring tasks; create uses action=create, name, prompt, task_type, schedule, scheduled_time, output_target; pause/resume/delete use task_id, so list/search first when only the name is known. Recurring reminders use manage_tasks.",
|
||||
"manage_memory": "manage_memory: saved memories.",
|
||||
"manage_contact": "manage_contact: address-book contacts.",
|
||||
"manage_research": "manage_research: saved research reports.",
|
||||
"manage_settings": "manage_settings: app settings.",
|
||||
"manage_endpoints": "manage_endpoints: configured API endpoints.",
|
||||
"list_models": "list_models: available models.",
|
||||
"manage_mcp": "manage_mcp: MCP servers.",
|
||||
"manage_skills": "manage_skills: skills.",
|
||||
"manage_webhooks": "manage_webhooks: webhooks.",
|
||||
"manage_bg_jobs": "manage_bg_jobs: running/background jobs.",
|
||||
"search_chats": "search_chats: past chats.",
|
||||
"web_search": "web_search: public web lookup, URLs, links.",
|
||||
"web_fetch": "web_fetch: fetch a concrete URL/domain.",
|
||||
"private_browser": "private_browser: rendered site interaction. Make separate calls: first action=open with the url, then action=snapshot; read/scroll/find as needed. Do not use action=batch. Only click/fill element refs returned by snapshot (for example @e12), never guessed labels.",
|
||||
"mcp__builtin_browser__browser_navigate": "Browser: browser_navigate {url} opens a page; then browser_snapshot returns the page with element refs; browser_click/browser_type take ref values from the latest snapshot, never guessed labels.",
|
||||
"youtube_tool": "youtube_tool: YouTube comments, transcripts, metadata, and latest channel video.",
|
||||
"read_file": "read_file: workspace text, PDF, or Office document path.",
|
||||
"edit_file": "edit_file: exact replacement in an existing workspace file.",
|
||||
"write_file": "write_file: create or fully rewrite a workspace file.",
|
||||
"apply_patch": "apply_patch: related multi-file workspace edits.",
|
||||
"host_shell": "host_shell: run tests, builds, and verification in the active workspace.",
|
||||
}
|
||||
_EMAIL_NOTES = (
|
||||
"Email: latest email/emails/inbox -> mcp__email__list_emails {folder:INBOX,max_results:1,unread_only:false}; "
|
||||
"account list -> list_email_accounts; subject find+read -> mcp__email__list_emails {folder:INBOX,max_results:20}; "
|
||||
"sender/topic search -> mcp__email__search_emails {max_results:10}; known id -> mcp__email__read_email; "
|
||||
"new draft -> mcp__email__draft_email; attachment -> mcp__email__download_attachment {uid,index,folder,account}; "
|
||||
"reply draft -> mcp__email__draft_email_reply {uid,account,body}; only explicit send-now -> mcp__email__reply_to_email {uid,account,body}; "
|
||||
"archive/delete/mark -> mcp__email__archive_email/mcp__email__delete_email/mcp__email__mark_email_read. Never emit manage_email."
|
||||
)
|
||||
_AJAX_PROMPT_START = "You are Ajax, the assistant in Odysseus."
|
||||
|
||||
|
||||
def trimmed_system_prompt(offered: Iterable[str]) -> str:
|
||||
offered = set(offered or ())
|
||||
parts = [_CORE_PROMPT]
|
||||
if offered & {"edit_file", "apply_patch", "host_shell"}:
|
||||
parts.append(_CODING_PROMPT)
|
||||
parts += [_TOOL_NOTES[n] for n in sorted(offered) if n in _TOOL_NOTES]
|
||||
if any(n.startswith("mcp__email__") or n in {"list_email_accounts"} for n in offered):
|
||||
parts.append(_EMAIL_NOTES)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def trim_system_prompt(messages: List[Dict[str, Any]], offered: Iterable[str]) -> List[Dict[str, Any]]:
|
||||
"""Swap the generic Ajax tool manual for one covering only the offered tools."""
|
||||
out: List[Dict[str, Any]] = []
|
||||
for m in messages:
|
||||
if (
|
||||
isinstance(m, dict)
|
||||
and m.get("role") == "system"
|
||||
and isinstance(m.get("content"), str)
|
||||
and m["content"].startswith(_AJAX_PROMPT_START)
|
||||
):
|
||||
new = dict(m)
|
||||
new["content"] = trimmed_system_prompt(offered)
|
||||
logger.info("[spec-harness] system prompt trimmed %d -> %d chars", len(m["content"]), len(new["content"]))
|
||||
out.append(new)
|
||||
else:
|
||||
out.append(m)
|
||||
return out
|
||||
|
||||
|
||||
def drop_skills_index(messages: List[Dict[str, Any]], offered: Iterable[str]) -> List[Dict[str, Any]]:
|
||||
"""Ajax Spec: omit the ~1000-token skills index unless the skills tool is offered."""
|
||||
if "manage_skills" in set(offered or ()):
|
||||
return messages
|
||||
out = [
|
||||
m for m in messages
|
||||
if not (isinstance(m, dict) and (m.get("metadata") or {}).get("source") == "available skills index")
|
||||
]
|
||||
if len(out) != len(messages):
|
||||
logger.info("[spec-harness] dropped skills index (%d chars)", sum(len(str(m.get("content") or "")) for m in messages if m not in out))
|
||||
return out
|
||||
|
||||
|
||||
# --- Router-decided thinking (progressive thinking trigger) -------------------------------------------------
|
||||
# The router classifies the user's request as THINK (needs reasoning: math/logic, tricky planning, subtle explanation or
|
||||
# weighing options, first-instinct-likely-wrong questions) or DIRECT (plain doing, lookup, chit-chat).
|
||||
# Zero-shot on 143 DeepSeek-labelled app requests (2026-10-13): odysseus-agentic-v1f acc 0.916, think recall 0.985,
|
||||
# think precision 0.859, ~33 ms. Off unless ODYSSEUS_SPEC_THINK_ROUTER=1. Never raises; returns None on any error.
|
||||
_THINK_DEF = ("This request needs genuine reasoning or figuring something out before answering: math and logic puzzles; "
|
||||
"planning a tricky multi-step approach; explaining something subtle or weighing options; or questions where "
|
||||
"a quick first instinct is likely wrong.")
|
||||
_DIRECT_DEF = ("This request is plain doing, lookup, or chit-chat that can be answered directly without reasoning: simple "
|
||||
"tool actions (set a reminder, search email, open a page, rename a file), simple facts, or small talk.")
|
||||
_THINK_INSTRUCTIONS = ("Classify whether this single user request needs the assistant to THINK (deep reasoning / figuring "
|
||||
"something out) before answering, or can be answered DIRECTLY (plain doing, lookup, or chit-chat). "
|
||||
"Answer with exactly one of the two options.")
|
||||
|
||||
|
||||
# 5-way request kind, exactly as trained into adapter thinkclass5 (2026-10-13; held-out 143: think/direct 96.5%,
|
||||
# think precision 98.8%, recall 95.2%). Enabled with ODYSSEUS_SPEC_THINK_QUESTION=kind5 + ODYSSEUS_SPEC_THINK_MODEL=thinkclass5.
|
||||
_KIND5_QUESTION = {"type": "choice", "instructions": "Read the user's request and classify it into exactly one of five kinds: math_logic, tricky_planning, explain_or_weigh, trap_question, or direct. Pick the option whose description fits best.", "criteria": {"math_logic": "Mathematical or logical computation/reasoning: arithmetic, word problems, logic puzzles, counting, proofs.", "tricky_planning": "Planning a multi-step approach with real constraints: schedules, itineraries, sequencing, resource or budget allocation, multi-part procedures.", "explain_or_weigh": "Explaining something subtle or weighing options/tradeoffs: comparisons, 'should I', pros and cons, recommendations with reasoning, nuanced why-explanations.", "trap_question": "A question where the obvious or first-instinct answer is likely wrong: riddles, trick questions, common misconceptions, counterintuitive facts.", "direct": "Plain doing, lookup, or chit-chat: simple tool actions, simple facts, small talk — no deep reasoning needed."}}
|
||||
|
||||
|
||||
def think_router_enabled() -> bool:
|
||||
return os.getenv("ODYSSEUS_SPEC_THINK_ROUTER", "0").strip() not in ("", "0", "false", "off")
|
||||
|
||||
|
||||
def route_thinking(user_message: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return {'think': bool, 'p_think': float, 'ms': float} or None (disabled / error / empty message)."""
|
||||
if not think_router_enabled():
|
||||
return None
|
||||
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||
text = (user_message or "").strip()
|
||||
if not url or not text:
|
||||
return None
|
||||
kind5 = os.getenv("ODYSSEUS_SPEC_THINK_QUESTION", "binary").strip().lower() == "kind5"
|
||||
question = _KIND5_QUESTION if kind5 else {"type": "choice", "instructions": _THINK_INSTRUCTIONS,
|
||||
"criteria": {"think": _THINK_DEF, "direct": _DIRECT_DEF}}
|
||||
req = {
|
||||
"model": os.getenv("ODYSSEUS_SPEC_THINK_MODEL", os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-s60")),
|
||||
"state": {"agent": _AGENT, "conversation": [], "user_message": text[:4000]},
|
||||
"questions": {"thinking": question},
|
||||
}
|
||||
t0 = time.time()
|
||||
try:
|
||||
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
|
||||
http = urllib.request.Request(url + "/v1/systemone", data=json.dumps(req).encode(),
|
||||
headers={"content-type": "application/json"})
|
||||
ans = json.load(urllib.request.urlopen(http, timeout=timeout))["answers"]["thinking"]
|
||||
probs = ans.get("probabilities") or {}
|
||||
# kind5: think = any of the four reasoning kinds (trained adapter thinkclass5); binary: P(think).
|
||||
p = float(1.0 - probs.get("direct", 1.0)) if kind5 else float(probs.get("think", 0.0))
|
||||
min_p = float(os.getenv("ODYSSEUS_SPEC_THINK_MIN_P", "0.5"))
|
||||
out = {"think": p >= min_p, "p_think": round(p, 4), "ms": round((time.time() - t0) * 1000, 1)}
|
||||
if kind5:
|
||||
out["kind"] = ans.get("choice")
|
||||
logger.info("spec think router: %s", out)
|
||||
return out
|
||||
except Exception as exc: # fail open: the existing progressive-thinking rules still apply
|
||||
logger.warning("spec think router failed (%s); falling back to rule-based thinking", exc)
|
||||
return None
|
||||
|
||||
|
||||
_ORIGINAL_ROUTE_THINKING = route_thinking
|
||||
|
||||
|
||||
async def route_thinking_async(user_message: str) -> Optional[Dict[str, Any]]:
|
||||
"""Asynchronous non-blocking route_thinking."""
|
||||
if route_thinking is not _ORIGINAL_ROUTE_THINKING:
|
||||
return route_thinking(user_message)
|
||||
if not think_router_enabled():
|
||||
return None
|
||||
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||
text = (user_message or "").strip()
|
||||
if not url or not text:
|
||||
return None
|
||||
kind5 = os.getenv("ODYSSEUS_SPEC_THINK_QUESTION", "binary").strip().lower() == "kind5"
|
||||
question = _KIND5_QUESTION if kind5 else {"type": "choice", "instructions": _THINK_INSTRUCTIONS,
|
||||
"criteria": {"think": _THINK_DEF, "direct": _DIRECT_DEF}}
|
||||
req = {
|
||||
"model": os.getenv("ODYSSEUS_SPEC_THINK_MODEL", os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-s60")),
|
||||
"state": {"agent": _AGENT, "conversation": [], "user_message": text[:4000]},
|
||||
"questions": {"thinking": question},
|
||||
}
|
||||
t0 = time.time()
|
||||
try:
|
||||
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(
|
||||
url + "/v1/systemone",
|
||||
json=req,
|
||||
headers={"content-type": "application/json"},
|
||||
timeout=timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
ans = resp.json()["answers"]["thinking"]
|
||||
probs = ans.get("probabilities") or {}
|
||||
p = float(1.0 - probs.get("direct", 1.0)) if kind5 else float(probs.get("think", 0.0))
|
||||
min_p = float(os.getenv("ODYSSEUS_SPEC_THINK_MIN_P", "0.5"))
|
||||
out = {"think": p >= min_p, "p_think": round(p, 4), "ms": round((time.time() - t0) * 1000, 1)}
|
||||
if kind5:
|
||||
out["kind"] = ans.get("choice")
|
||||
logger.info("spec think router: %s", out)
|
||||
return out
|
||||
except Exception as exc:
|
||||
logger.warning("spec think router failed (%s); falling back to rule-based thinking", exc)
|
||||
return None
|
||||
|
||||
|
||||
# ---- Ajax Spec driver: the router picks every step --------------------------------------------------------
|
||||
def next_action(user_message: str, latest_result: str, schemas: Iterable[Dict[str, Any]],
|
||||
messages: Optional[Iterable[Dict[str, Any]]] = None,
|
||||
disabled_tools: Optional[Set[str]] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Top-1 next action at any confidence: {'kind': 'tool'|'answer_directly'|'ask_user', 'name', 'p'}.
|
||||
|
||||
Same request as training (build_agentic_rows.py): sorted tool lines, and mid-turn the latest tool
|
||||
result appended to the user message as "\\n\\nLatest tool result:\\n...". Returns None on any error."""
|
||||
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||
if not url:
|
||||
return None
|
||||
t0 = time.time()
|
||||
disabled = set(disabled_tools or ())
|
||||
lines: Dict[str, str] = {}
|
||||
for s in schemas:
|
||||
n = str(_fn(s).get("name") or "")
|
||||
if n and n not in disabled and n != "ask_user" and n not in lines:
|
||||
lines[n] = tool_line(s)
|
||||
names = sorted(lines)
|
||||
criteria = dict(_HEAD)
|
||||
for i, n in enumerate(names, 1):
|
||||
criteria[f"t{i}"] = lines[n]
|
||||
um = (user_message or "")[-3000:]
|
||||
if latest_result:
|
||||
um += "\n\nLatest tool result:\n" + latest_result[-1500:]
|
||||
req = {
|
||||
"model": os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-agentic-v4"),
|
||||
"state": {"agent": _AGENT, "conversation": _conversation(messages), "user_message": um},
|
||||
"questions": {"tool": {"type": "choice", "instructions": _INSTRUCTIONS, "criteria": criteria}},
|
||||
}
|
||||
try:
|
||||
http = urllib.request.Request(url + "/v1/systemone", data=json.dumps(req).encode(),
|
||||
headers={"content-type": "application/json"})
|
||||
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
|
||||
deadline = time.time() + timeout
|
||||
while True:
|
||||
try:
|
||||
out = json.load(urllib.request.urlopen(http, timeout=max(0.2, deadline - time.time())))["answers"]["tool"]
|
||||
break
|
||||
except urllib.error.HTTPError as busy:
|
||||
if busy.code != 529 or time.time() + 0.15 >= deadline:
|
||||
raise
|
||||
time.sleep(0.1)
|
||||
probs = out.get("probabilities") or {}
|
||||
choice = str(out.get("choice") or "")
|
||||
if choice.startswith("t") and choice[1:].isdigit():
|
||||
action = {"kind": "tool", "name": names[int(choice[1:]) - 1]}
|
||||
elif choice in ("answer_directly", "ask_user"):
|
||||
action = {"kind": choice, "name": choice}
|
||||
else:
|
||||
return None
|
||||
ranked = sorted(((float(v), k) for k, v in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True)
|
||||
action.update(p=round(float(probs.get(choice, 0.0)), 3), ms=round((time.time() - t0) * 1000, 1),
|
||||
top=[(names[int(k[1:]) - 1], round(v, 3)) for v, k in ranked[:6]])
|
||||
logger.info("spec next action: %s", action)
|
||||
return action
|
||||
except Exception as exc:
|
||||
logger.warning("spec next action failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
_ORIGINAL_NEXT_ACTION = next_action
|
||||
|
||||
|
||||
async def next_action_async(user_message: str, latest_result: str, schemas: Iterable[Dict[str, Any]],
|
||||
messages: Optional[Iterable[Dict[str, Any]]] = None,
|
||||
disabled_tools: Optional[Set[str]] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Asynchronous non-blocking next_action."""
|
||||
if next_action is not _ORIGINAL_NEXT_ACTION:
|
||||
return next_action(user_message, latest_result, schemas, messages=messages, disabled_tools=disabled_tools)
|
||||
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||
if not url:
|
||||
return None
|
||||
t0 = time.time()
|
||||
disabled = set(disabled_tools or ())
|
||||
lines: Dict[str, str] = {}
|
||||
for s in schemas:
|
||||
n = str(_fn(s).get("name") or "")
|
||||
if n and n not in disabled and n != "ask_user" and n not in lines:
|
||||
lines[n] = tool_line(s)
|
||||
names = sorted(lines)
|
||||
criteria = dict(_HEAD)
|
||||
for i, n in enumerate(names, 1):
|
||||
criteria[f"t{i}"] = lines[n]
|
||||
um = (user_message or "")[-3000:]
|
||||
if latest_result:
|
||||
um += "\n\nLatest tool result:\n" + latest_result[-1500:]
|
||||
req = {
|
||||
"model": os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-agentic-v4"),
|
||||
"state": {"agent": _AGENT, "conversation": _conversation(messages), "user_message": um},
|
||||
"questions": {"tool": {"type": "choice", "instructions": _INSTRUCTIONS, "criteria": criteria}},
|
||||
}
|
||||
try:
|
||||
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
|
||||
deadline = time.time() + timeout
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
while True:
|
||||
remaining = max(0.2, deadline - time.time())
|
||||
try:
|
||||
resp = await client.post(
|
||||
url + "/v1/systemone",
|
||||
json=req,
|
||||
headers={"content-type": "application/json"},
|
||||
timeout=remaining,
|
||||
)
|
||||
if resp.status_code == 529:
|
||||
if time.time() + 0.15 >= deadline:
|
||||
resp.raise_for_status()
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
out = resp.json()["answers"]["tool"]
|
||||
break
|
||||
except httpx.HTTPStatusError as busy:
|
||||
if busy.response.status_code != 529 or time.time() + 0.15 >= deadline:
|
||||
raise
|
||||
await asyncio.sleep(0.1)
|
||||
probs = out.get("probabilities") or {}
|
||||
choice = str(out.get("choice") or "")
|
||||
if choice.startswith("t") and choice[1:].isdigit():
|
||||
action = {"kind": "tool", "name": names[int(choice[1:]) - 1]}
|
||||
elif choice in ("answer_directly", "ask_user"):
|
||||
action = {"kind": choice, "name": choice}
|
||||
else:
|
||||
return None
|
||||
ranked = sorted(((float(v), k) for k, v in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True)
|
||||
action.update(p=round(float(probs.get(choice, 0.0)), 3), ms=round((time.time() - t0) * 1000, 1),
|
||||
top=[(names[int(k[1:]) - 1], round(v, 3)) for v, k in ranked[:6]])
|
||||
logger.info("spec next action: %s", action)
|
||||
return action
|
||||
except Exception as exc:
|
||||
logger.warning("spec next action failed: %s", exc)
|
||||
return None
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user