mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-01 10:42:23 +01:00
Compare 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 | ||
|
|
0b8176b11e | ||
|
|
3246ae5d9c | ||
|
|
03baea7700 | ||
|
|
020fe02aa0 | ||
|
|
375e263419 | ||
|
|
8865c975aa | ||
|
|
a58a5e6632 | ||
|
|
106b1e7c86 | ||
|
|
49d57a43fe | ||
|
|
e67cb8bbb9 | ||
|
|
d9797ce10f | ||
|
|
9ab440c4cb | ||
|
|
ff1d7df8b0 | ||
|
|
6c2a1e6873 | ||
|
|
fa29a86696 | ||
|
|
8cfdb8e2e5 | ||
|
|
871c0c0687 | ||
|
|
5c6bb9798b | ||
|
|
56b85c359c | ||
|
|
ae28501e7f | ||
|
|
08452ab5f9 | ||
|
|
aa91e69b2a | ||
|
|
c54bc1d2d3 | ||
|
|
9ab3f70fe4 | ||
|
|
89b41b9224 | ||
|
|
e8551e9cbc | ||
|
|
3d47888559 | ||
|
|
36a71886bb | ||
|
|
39ca749f94 | ||
|
|
444b60d14e | ||
|
|
93da30c4af | ||
|
|
b3f86a0724 | ||
|
|
94dc74e500 | ||
|
|
fd27e9a429 | ||
|
|
30430acfea | ||
|
|
76abce349b | ||
|
|
4954cc2e8c | ||
|
|
bf3c000d1c | ||
|
|
7fd4e0e9bb | ||
|
|
8cd96aad46 | ||
|
|
5fac693c1c | ||
|
|
eb940f24a3 | ||
|
|
6123b70b5c | ||
|
|
bfa514f6e8 | ||
|
|
9bdea753ae | ||
|
|
364229f2dc | ||
|
|
1ff2fd0054 | ||
|
|
ad3e309074 | ||
|
|
15c4cef36a | ||
|
|
84cdd866c5 | ||
|
|
50b05bf54f | ||
|
|
dbfc96b8f3 | ||
|
|
a7c03f85bf |
@@ -76,7 +76,7 @@ jobs:
|
|||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
# Byte-compile sources — catches syntax errors without installing deps.
|
# Byte-compile sources — catches syntax errors without installing deps.
|
||||||
@@ -91,7 +91,7 @@ jobs:
|
|||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||||
with:
|
with:
|
||||||
node-version: "22.23.4"
|
node-version: "22.23.4"
|
||||||
- run: npm ci --ignore-scripts --no-audit --no-fund
|
- run: npm ci --ignore-scripts --no-audit --no-fund
|
||||||
@@ -151,7 +151,7 @@ jobs:
|
|||||||
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
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'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
@@ -168,7 +168,7 @@ jobs:
|
|||||||
|
|
||||||
# uv resolves the same versions as pip but installs ~5x faster, and its
|
# uv resolves the same versions as pip but installs ~5x faster, and its
|
||||||
# cache keeps each shard from re-downloading the whole requirement set.
|
# 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'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
with:
|
with:
|
||||||
version: "0.13.5"
|
version: "0.13.5"
|
||||||
@@ -179,8 +179,9 @@ jobs:
|
|||||||
uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
|
uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
|
||||||
python scripts/check_ca_trust.py --python
|
python scripts/check_ca_trust.py --python
|
||||||
uv pip install --system -r requirements.txt
|
uv pip install --system -r requirements.txt
|
||||||
|
uv pip install --system "playwright==1.62.0"
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
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'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
with:
|
with:
|
||||||
node-version: "22.23.4"
|
node-version: "22.23.4"
|
||||||
@@ -256,3 +257,10 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
PYTEST_SHARD: ${{ matrix.shard }}
|
PYTEST_SHARD: ${{ matrix.shard }}
|
||||||
run: python -m pytest -q -rs --durations=25 --shard "$PYTEST_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:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
build-mode: none
|
build-mode: none
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||||
with:
|
with:
|
||||||
category: "/language:${{ matrix.language }}"
|
category: "/language:${{ matrix.language }}"
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Lint Dockerfile
|
- name: Lint Dockerfile
|
||||||
uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0
|
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
|
||||||
with:
|
with:
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
# DL3008: pinning apt package versions is impractical on a -slim base
|
# DL3008: pinning apt package versions is impractical on a -slim base
|
||||||
|
|||||||
@@ -61,14 +61,14 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Buildx
|
- name: Set up Buildx
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
with:
|
with:
|
||||||
driver: docker
|
driver: docker
|
||||||
|
|
||||||
# Build without pushing so a broken Dockerfile is caught here, and the
|
# Build without pushing so a broken Dockerfile is caught here, and the
|
||||||
# exact image we ship is what gets scanned.
|
# exact image we ship is what gets scanned.
|
||||||
- name: Build image
|
- name: Build image
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: false
|
push: false
|
||||||
@@ -107,12 +107,12 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Buildx
|
- name: Set up Buildx
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
with:
|
with:
|
||||||
driver: docker
|
driver: docker
|
||||||
|
|
||||||
- name: Build image
|
- name: Build image
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: false
|
push: false
|
||||||
@@ -133,7 +133,7 @@ jobs:
|
|||||||
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
|
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
|
||||||
|
|
||||||
- name: Upload Trivy results
|
- 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:
|
with:
|
||||||
sarif_file: trivy-results.sarif
|
sarif_file: trivy-results.sarif
|
||||||
category: trivy-image
|
category: trivy-image
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
|
||||||
|
|||||||
@@ -47,4 +47,4 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Deploy to GitHub Pages
|
- name: Deploy to GitHub Pages
|
||||||
id: deployment
|
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
|
shell: bash
|
||||||
run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
||||||
- name: Set up Buildx
|
- 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
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build and push by digest
|
- name: Build and push by digest
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
pull: true
|
pull: true
|
||||||
@@ -78,7 +78,7 @@ jobs:
|
|||||||
digest="${{ steps.build.outputs.digest }}"
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
touch "/tmp/digests/${digest#sha256:}"
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
with:
|
with:
|
||||||
name: digest-${{ matrix.arch }}
|
name: digest-${{ matrix.arch }}
|
||||||
path: /tmp/digests/*
|
path: /tmp/digests/*
|
||||||
@@ -107,15 +107,15 @@ jobs:
|
|||||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||||
echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digest-*
|
pattern: digest-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
- name: Set up Buildx
|
- 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
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
@@ -62,9 +62,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
SEARXNG_TEST_IMAGE: ${{ steps.image.outputs.image }}
|
SEARXNG_TEST_IMAGE: ${{ steps.image.outputs.image }}
|
||||||
run: docker pull "$SEARXNG_TEST_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
|
- 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:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/searxng/Dockerfile
|
file: docker/searxng/Dockerfile
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ services/data/
|
|||||||
|
|
||||||
# Test capture artifacts (browser session dumps may contain personal content)
|
# Test capture artifacts (browser session dumps may contain personal content)
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -35,3 +35,15 @@ regexTarget = "secret"
|
|||||||
regexes = [
|
regexes = [
|
||||||
'''^7abc5b334a6c3c52dd3eba15d8bc516da728a67f14fb7f56d0bb5992d89a16ca$''',
|
'''^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 ----
|
# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ----
|
||||||
# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'],
|
# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'],
|
||||||
# which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so
|
# 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
|
# the final image / Cookbook never has to compile the broken sdists. See
|
||||||
# docker/build-realesrgan-wheels.sh for the full rationale.
|
# 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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
|
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
|
COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh
|
||||||
RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels
|
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.
|
# System deps. tmux is required by Cookbook for background downloads/serves.
|
||||||
# openssh-client is required for Cookbook remote server tests, setup, probes,
|
# 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 \
|
libglib2.0-0t64 \
|
||||||
libxcb1 \
|
libxcb1 \
|
||||||
libmagic1 \
|
libmagic1 \
|
||||||
|
&& python /usr/local/bin/check-brotli.py --node /usr/bin/node \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Refresh both Python trust bundles before installing application dependencies.
|
# 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.
|
# Check gRPC's effective defaults after all Python dependencies are installed.
|
||||||
# The application minimum fixes its bundled roots; the checked override also
|
# The application minimum fixes its bundled roots; the checked override also
|
||||||
# keeps the effective Docker selection consistent with system trust.
|
# 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 app code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -178,6 +178,14 @@ app.add_middleware(SecurityHeadersMiddleware)
|
|||||||
import asyncio as _asyncio
|
import asyncio as _asyncio
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware as _BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware as _BaseHTTPMiddleware
|
||||||
from starlette.responses import JSONResponse as _JSONResponse
|
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"))
|
REQUEST_HARD_TIMEOUT = float(os.getenv("REQUEST_HARD_TIMEOUT", "45"))
|
||||||
_TIMEOUT_EXEMPT_PREFIXES = (
|
_TIMEOUT_EXEMPT_PREFIXES = (
|
||||||
@@ -190,6 +198,7 @@ _TIMEOUT_EXEMPT_PREFIXES = (
|
|||||||
"/api/cookbook/setup", # remote pacman/apt installs
|
"/api/cookbook/setup", # remote pacman/apt installs
|
||||||
"/api/upload", # large files
|
"/api/upload", # large files
|
||||||
"/api/image", # diffusion proxies (inpaint/harmonize/upscale/etc.) — own 120s httpx timeout
|
"/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
|
"/api/memory/audit", # retains own 120s LLM inactivity timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -225,15 +234,43 @@ class _InteractiveActivityMiddleware(_BaseHTTPMiddleware):
|
|||||||
return await call_next(request)
|
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):
|
class _SlowRequestLogMiddleware(_BaseHTTPMiddleware):
|
||||||
async def dispatch(self, request, call_next):
|
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()
|
start = time.perf_counter()
|
||||||
status = 500
|
status = 500
|
||||||
try:
|
try:
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
status = getattr(response, "status_code", 0) or 0
|
status = getattr(response, "status_code", 0) or 0
|
||||||
return response
|
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:
|
finally:
|
||||||
|
if heavy:
|
||||||
|
activity.end_image_op(path)
|
||||||
elapsed = time.perf_counter() - start
|
elapsed = time.perf_counter() - start
|
||||||
try:
|
try:
|
||||||
threshold = float(os.getenv("ODYSSEUS_SLOW_REQUEST_LOG_SECONDS", "0.75") or "0.75")
|
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
|
from routes.editor_draft_routes import setup_editor_draft_routes
|
||||||
app.include_router(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
|
# Scheduled tasks + event bus
|
||||||
from src.task_scheduler import TaskScheduler
|
from src.task_scheduler import TaskScheduler
|
||||||
task_scheduler = TaskScheduler(session_manager)
|
task_scheduler = TaskScheduler(session_manager)
|
||||||
@@ -1000,6 +1041,16 @@ async def get_version():
|
|||||||
"source_commit": APP_SOURCE_COMMIT,
|
"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")
|
@app.get("/api/health")
|
||||||
async def health_check() -> Dict[str, str]:
|
async def health_check() -> Dict[str, str]:
|
||||||
return {"status": "healthy", "timestamp": datetime.now(timezone.utc).isoformat()}
|
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_host = os.getenv("APP_BIND", "127.0.0.1")
|
||||||
bind_port = int(os.getenv("APP_PORT", "7011"))
|
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_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
- 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
|
# Host workspace translation is opt-in. Keep the public compose file
|
||||||
# user-neutral; configure these in a local .env or use the host-workspace
|
# user-neutral; configure these in a local .env or use the host-workspace
|
||||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
- 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:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- 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
|
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||||
exec /usr/local/searxng/entrypoint.sh
|
exec /usr/local/searxng/entrypoint.sh
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- searxng-data:/etc/searxng
|
- searxng-data:/etc/searxng
|
||||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
- ./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_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
- 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
|
# Host workspace translation is opt-in. Keep the public compose file
|
||||||
# user-neutral; configure these in a local .env or use the host-workspace
|
# user-neutral; configure these in a local .env or use the host-workspace
|
||||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
- 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:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- 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
|
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||||
exec /usr/local/searxng/entrypoint.sh
|
exec /usr/local/searxng/entrypoint.sh
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- searxng-data:/etc/searxng
|
- searxng-data:/etc/searxng
|
||||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
- ./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_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
- 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
|
# Host workspace translation is opt-in. Keep the public compose file
|
||||||
# user-neutral; configure these in a local .env or use the host-workspace
|
# user-neutral; configure these in a local .env or use the host-workspace
|
||||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
- 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:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- 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
|
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||||
exec /usr/local/searxng/entrypoint.sh
|
exec /usr/local/searxng/entrypoint.sh
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- searxng-data:/etc/searxng
|
- searxng-data:/etc/searxng
|
||||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||||
|
|||||||
@@ -31,21 +31,43 @@ for spec in $SPECS; do
|
|||||||
name="${spec%%==*}"
|
name="${spec%%==*}"
|
||||||
ver="${spec##*==}"
|
ver="${spec##*==}"
|
||||||
# pip download builds metadata (and trips the same bug), so fetch the raw
|
# pip download builds metadata (and trips the same bug), so fetch the raw
|
||||||
# sdist URL from the PyPI JSON API instead.
|
# sdist URL from the PyPI JSON API instead. Use curl with retries to
|
||||||
url="$(python - "$name" "$ver" <<'PY'
|
# survive transient TLS / network failures that urllib cannot recover from.
|
||||||
import json, sys, urllib.request
|
metadata="${name}-${ver}.json"
|
||||||
name, ver = sys.argv[1], sys.argv[2]
|
curl --fail --silent --show-error --location \
|
||||||
data = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{ver}/json"))
|
--retry 8 \
|
||||||
for f in data["urls"]:
|
--retry-delay 3 \
|
||||||
if f["packagetype"] == "sdist":
|
--retry-max-time 120 \
|
||||||
print(f["url"]); break
|
--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:
|
else:
|
||||||
sys.exit(f"no sdist found for {name}=={ver}")
|
raise SystemExit("no sdist found")
|
||||||
PY
|
PY
|
||||||
)"
|
)"
|
||||||
|
rm -f "$metadata"
|
||||||
|
|
||||||
echo ">> fetching ${name} ${ver}: ${url}"
|
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"
|
tar xzf "${name}.tar.gz"
|
||||||
|
rm -f "${name}.tar.gz"
|
||||||
done
|
done
|
||||||
|
|
||||||
echo ">> patching get_version()"
|
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 |
+23
-10
@@ -1185,6 +1185,9 @@ def _fixture_email_record(row: dict, uid_num: int, owner: str) -> dict:
|
|||||||
"subject": subject,
|
"subject": subject,
|
||||||
"from": sender_name or sender_addr or sender,
|
"from": sender_name or sender_addr or sender,
|
||||||
"from_address": sender_addr,
|
"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": date_str,
|
||||||
"date_epoch": date_epoch,
|
"date_epoch": date_epoch,
|
||||||
"summary": body[:240],
|
"summary": body[:240],
|
||||||
@@ -1557,6 +1560,7 @@ def _fixture_update_email(uid=None, source_folder="INBOX", account=None, **updat
|
|||||||
if not isinstance(rows, list):
|
if not isinstance(rows, list):
|
||||||
return False
|
return False
|
||||||
owner = _current_owner()
|
owner = _current_owner()
|
||||||
|
matches = []
|
||||||
for index, row in enumerate(rows, start=1):
|
for index, row in enumerate(rows, start=1):
|
||||||
if not isinstance(row, dict):
|
if not isinstance(row, dict):
|
||||||
continue
|
continue
|
||||||
@@ -1568,14 +1572,19 @@ def _fixture_update_email(uid=None, source_folder="INBOX", account=None, **updat
|
|||||||
continue
|
continue
|
||||||
if not _fixture_folder_matches(row.get("folder") or "INBOX", source_folder):
|
if not _fixture_folder_matches(row.get("folder") or "INBOX", source_folder):
|
||||||
continue
|
continue
|
||||||
for key, value in updates.items():
|
if not _fixture_row_matches_account(_fixture_email_record(row, index, owner or row_owner), account):
|
||||||
if value is None:
|
continue
|
||||||
row.pop(key, None)
|
matches.append(row)
|
||||||
else:
|
if len(matches) != 1:
|
||||||
row[key] = value
|
return False
|
||||||
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
row = matches[0]
|
||||||
return True
|
for key, value in updates.items():
|
||||||
return False
|
if value is None:
|
||||||
|
row.pop(key, None)
|
||||||
|
else:
|
||||||
|
row[key] = value
|
||||||
|
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
# ── Tool implementations ──
|
# ── 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):
|
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."""
|
"""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:
|
if fixture is not None:
|
||||||
sender = str(fixture.get("from_address") or fixture.get("from") or "")
|
sender = str(fixture.get("from_address") or fixture.get("from") or "")
|
||||||
_, sender_addr = email.utils.parseaddr(sender)
|
_, 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):
|
def _set_flag(uid, folder, flag, add=True, account=None):
|
||||||
"""Add or remove an IMAP flag (e.g. \\Seen, \\Answered, \\Deleted)."""
|
"""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":
|
if flag == "\\Seen":
|
||||||
return _fixture_update_email(uid=uid, source_folder=folder, account=account, read=bool(add))
|
return _fixture_update_email(uid=uid, source_folder=folder, account=account, read=bool(add))
|
||||||
if flag == "\\Answered":
|
if flag == "\\Answered":
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ async def list_tools() -> list[Tool]:
|
|||||||
"prompt": {"type": "string", "description": "Image description prompt"},
|
"prompt": {"type": "string", "description": "Image description prompt"},
|
||||||
"model": {"type": "string", "description": "Model name (auto-detects if omitted)"},
|
"model": {"type": "string", "description": "Model name (auto-detects if omitted)"},
|
||||||
"size": {"type": "string", "description": "Image size (default 1024x1024)"},
|
"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"],
|
"required": ["prompt"],
|
||||||
},
|
},
|
||||||
@@ -66,7 +66,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
|
|
||||||
if not model_spec:
|
if not model_spec:
|
||||||
model_spec = _settings.get("image_model", "")
|
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"]
|
quality = _settings["image_quality"]
|
||||||
|
|
||||||
# Auto-detect best available image model
|
# 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": "playwright test --config tests/e2e/playwright.config.js",
|
||||||
"test:photo-editor:install": "playwright install chromium firefox webkit",
|
"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: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": {
|
"devDependencies": {
|
||||||
"@antithesishq/bombadil": "^0.7.0",
|
"@antithesishq/bombadil": "^0.7.0",
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
-r requirements.txt
|
-r requirements.txt
|
||||||
# psutil lets `-n auto` use physical cores instead of logical CPU threads.
|
# psutil lets `-n auto` use physical cores instead of logical CPU threads.
|
||||||
pytest-xdist[psutil]>=3.8,<4
|
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)."""
|
"""Create a new integration (admin only)."""
|
||||||
_require_admin_user(request)
|
_require_admin_user(request)
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise HTTPException(400, "Request body must be a JSON object")
|
||||||
item = add_integration(body)
|
item = add_integration(body)
|
||||||
return {"ok": True, "integration": mask_integration_secret(item)}
|
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)."""
|
"""Update an existing integration (admin only)."""
|
||||||
_require_admin_user(request)
|
_require_admin_user(request)
|
||||||
body = await request.json()
|
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)
|
item = update_integration(integration_id, body)
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(404, "Integration not found")
|
raise HTTPException(404, "Integration not found")
|
||||||
|
|||||||
+16
-16
@@ -80,6 +80,18 @@ def _require_user(request: Request) -> str:
|
|||||||
return FALLBACK_OWNER
|
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:
|
def _get_or_404_calendar(db, cal_id: str, owner: str) -> CalendarCal:
|
||||||
cal = db.query(CalendarCal).filter(CalendarCal.id == cal_id).first()
|
cal = db.query(CalendarCal).filter(CalendarCal.id == cal_id).first()
|
||||||
if not cal:
|
if not cal:
|
||||||
@@ -994,10 +1006,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
async def save_config(request: Request):
|
async def save_config(request: Request):
|
||||||
"""Legacy single-account endpoint — upserts the first account."""
|
"""Legacy single-account endpoint — upserts the first account."""
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
try:
|
body = await _json_object_body(request)
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
accounts = _get_caldav_accounts(owner)
|
accounts = _get_caldav_accounts(owner)
|
||||||
if not (body.get("url") or "").strip():
|
if not (body.get("url") or "").strip():
|
||||||
_save_caldav_accounts(owner, [])
|
_save_caldav_accounts(owner, [])
|
||||||
@@ -1052,10 +1061,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
"""Add a new CalDAV account."""
|
"""Add a new CalDAV account."""
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
try:
|
body = await _json_object_body(request)
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
from src.caldav_sync import validate_caldav_url
|
from src.caldav_sync import validate_caldav_url
|
||||||
try:
|
try:
|
||||||
url = validate_caldav_url(body.get("url", ""))
|
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):
|
async def update_caldav_account(account_id: str, request: Request):
|
||||||
"""Update an existing CalDAV account by id."""
|
"""Update an existing CalDAV account by id."""
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
try:
|
body = await _json_object_body(request)
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
accounts = _get_caldav_accounts(owner)
|
accounts = _get_caldav_accounts(owner)
|
||||||
idx = next((i for i, a in enumerate(accounts) if a.get("id") == account_id), None)
|
idx = next((i for i, a in enumerate(accounts) if a.get("id") == account_id), None)
|
||||||
if idx is 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
|
test an already-saved account. Falls back to the first saved account
|
||||||
when nothing is provided."""
|
when nothing is provided."""
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
try:
|
body = await _json_object_body(request)
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
url = (body.get("url") or "").strip()
|
url = (body.get("url") or "").strip()
|
||||||
user = (body.get("username") or "").strip()
|
user = (body.get("username") or "").strip()
|
||||||
pw = body.get("password") or ""
|
pw = body.get("password") or ""
|
||||||
|
|||||||
+125
-13
@@ -569,8 +569,72 @@ async def auto_name_session(session_manager, sess):
|
|||||||
if not first_msg:
|
if not first_msg:
|
||||||
return
|
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 "")
|
endpoint_url = str(getattr(sess, "endpoint_url", "") or "")
|
||||||
model_name = str(getattr(sess, "model", "") 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 (
|
if (
|
||||||
"ttft" in model_name.lower()
|
"ttft" in model_name.lower()
|
||||||
or re.search(r":18\d{3}\b", endpoint_url)
|
or re.search(r":18\d{3}\b", endpoint_url)
|
||||||
@@ -581,6 +645,14 @@ async def auto_name_session(session_manager, sess):
|
|||||||
return
|
return
|
||||||
|
|
||||||
owner = getattr(sess, "owner", None)
|
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(
|
t_url, t_model, t_headers = resolve_task_endpoint(
|
||||||
sess.endpoint_url, sess.model, sess.headers, owner=owner
|
sess.endpoint_url, sess.model, sess.headers, owner=owner
|
||||||
)
|
)
|
||||||
@@ -588,24 +660,64 @@ async def auto_name_session(session_manager, sess):
|
|||||||
logger.debug("[auto-name] No model provided, skipping")
|
logger.debug("[auto-name] No model provided, skipping")
|
||||||
return
|
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,
|
# max_tokens big enough that reasoning models (Minimax M2,
|
||||||
# DeepSeek R1, QwQ, etc.) have headroom for <think>…</think>
|
# DeepSeek R1, QwQ, etc.) have headroom for <think>…</think>
|
||||||
# plus the actual title — 200 used to clip them mid-reasoning
|
# plus the actual title — 200 used to clip them mid-reasoning
|
||||||
# so strip_think left an empty string and no rename happened.
|
# so strip_think left an empty string and no rename happened.
|
||||||
# Timeout matches: 60s gives slow local reasoners room to finish.
|
# Timeout matches: 60s gives slow local reasoners room to finish.
|
||||||
title = await llm_call_async(
|
try:
|
||||||
t_url,
|
title = await llm_call_async(
|
||||||
t_model,
|
t_url,
|
||||||
[
|
t_model,
|
||||||
{"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 — just the title."},
|
[
|
||||||
{"role": "user", "content": first_msg},
|
{"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 — just the title."},
|
||||||
],
|
{"role": "user", "content": first_msg},
|
||||||
temperature=0.3,
|
],
|
||||||
max_tokens=64,
|
temperature=0.3,
|
||||||
headers=t_headers,
|
max_tokens=64,
|
||||||
timeout=15,
|
headers=t_headers,
|
||||||
reasoning_effort=effort_for_call(t_url, t_model, owner, 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()
|
title = title.strip().strip('"\'').strip()
|
||||||
# Strip <think>/<thinking> blocks (closed, dangling, or stray tags)
|
# 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 (
|
from src.agent_loop import (
|
||||||
stream_agent_loop,
|
stream_agent_loop,
|
||||||
_configured_model_tool_surface,
|
_configured_model_tool_surface,
|
||||||
|
_configured_model_tool_surface_raw,
|
||||||
_local_media_needs_browser_render,
|
_local_media_needs_browser_render,
|
||||||
_looks_like_workspace_coding_request,
|
_looks_like_workspace_coding_request,
|
||||||
)
|
)
|
||||||
from src.agent_loop import _normalize_ody_qwen_text_artifacts
|
from src.agent_loop import _normalize_ody_qwen_text_artifacts
|
||||||
from src import agent_runs
|
from src import agent_runs
|
||||||
|
from src import shutdown as _shutdown
|
||||||
from src.model_context import estimate_tokens
|
from src.model_context import estimate_tokens
|
||||||
from src.context_compactor import (
|
from src.context_compactor import (
|
||||||
apply_compaction_state,
|
apply_compaction_state,
|
||||||
@@ -329,6 +331,7 @@ def _is_external_discovery_request(text: str) -> bool:
|
|||||||
|
|
||||||
_URL_SCHEME_RE = re.compile(r"https?://", re.I)
|
_URL_SCHEME_RE = re.compile(r"https?://", re.I)
|
||||||
_PDF_URL_TAIL_RE = re.compile(r"\.pdf\b|/pdf/", 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:
|
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:
|
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
|
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
|
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))
|
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
|
||||||
except Exception:
|
except Exception:
|
||||||
visible = cached
|
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}:
|
if current_model and current_model in {str(item).strip() for item in visible}:
|
||||||
return False
|
return False
|
||||||
if is_chatgpt_subscription:
|
if is_chatgpt_subscription:
|
||||||
@@ -2688,6 +2694,23 @@ def setup_chat_routes(
|
|||||||
logger.info("chat→agent auto-escalation: explicit private browser workflow")
|
logger.info("chat→agent auto-escalation: explicit private browser workflow")
|
||||||
active_doc_id = form_data.get("active_doc_id", "").strip()
|
active_doc_id = form_data.get("active_doc_id", "").strip()
|
||||||
active_doc_state = form_data.get("active_doc_state", "").strip().casefold()
|
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(
|
logger.info(
|
||||||
"[doc-inject] chat_mode=%s, active_doc_id=%r, active_doc_state=%r",
|
"[doc-inject] chat_mode=%s, active_doc_id=%r, active_doc_state=%r",
|
||||||
chat_mode, active_doc_id, active_doc_state,
|
chat_mode, active_doc_id, active_doc_state,
|
||||||
@@ -3270,7 +3293,34 @@ def setup_chat_routes(
|
|||||||
active_document=bool(active_doc), workspace=bool(workspace),
|
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 [])),
|
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 _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
|
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 a.get('id') and str(a.get('mime') or '').startswith('image/')]
|
||||||
if image_refs:
|
if image_refs:
|
||||||
@@ -3307,6 +3357,11 @@ def setup_chat_routes(
|
|||||||
# pasted revision request. This only offers permitted schemas;
|
# pasted revision request. This only offers permitted schemas;
|
||||||
# it never requires or performs a document mutation.
|
# it never requires or performs a document mutation.
|
||||||
_turn_capabilities = _turn_capabilities | {"documents"}
|
_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
|
# Same decision that prepared the turn; it only stamps the contract
|
||||||
# inside the agent-contract branch below.
|
# inside the agent-contract branch below.
|
||||||
_clean_v3_preview = _compact_preview_turn
|
_clean_v3_preview = _compact_preview_turn
|
||||||
@@ -3474,7 +3529,7 @@ def setup_chat_routes(
|
|||||||
if not _privs.get("can_use_documents", True):
|
if not _privs.get("can_use_documents", True):
|
||||||
disabled_tools.update({"manage_documents", "create_document", "edit_document", "update_document", "suggest_document"})
|
disabled_tools.update({"manage_documents", "create_document", "edit_document", "update_document", "suggest_document"})
|
||||||
if not _privs.get("can_generate_images", True):
|
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):
|
if not _privs.get("can_manage_memory", True):
|
||||||
disabled_tools.update({"manage_memory", "manage_skills"})
|
disabled_tools.update({"manage_memory", "manage_skills"})
|
||||||
if not _privs.get("can_use_research", True):
|
if not _privs.get("can_use_research", True):
|
||||||
@@ -3556,9 +3611,22 @@ def setup_chat_routes(
|
|||||||
"document_editor": not {
|
"document_editor": not {
|
||||||
"manage_documents", "create_document", "edit_document", "update_document",
|
"manage_documents", "create_document", "edit_document", "update_document",
|
||||||
}.issubset(disabled_tools),
|
}.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.
|
# Capture permission state before schema selection/reconciliation.
|
||||||
# Only deterministic request intent supplies grants, never inventory.
|
# 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_authority = request_authority_for_http(
|
||||||
request, message, owner=_user, session_id=session, workspace=workspace,
|
request, message, owner=_user, session_id=session, workspace=workspace,
|
||||||
history=_turn_history, policy=tool_policy,
|
history=_turn_history, policy=tool_policy,
|
||||||
@@ -3566,9 +3634,9 @@ def setup_chat_routes(
|
|||||||
active_document=bool(active_doc),
|
active_document=bool(active_doc),
|
||||||
image_attachment=any(str(a.get('mime') or '').startswith('image/')
|
image_attachment=any(str(a.get('mime') or '').startswith('image/')
|
||||||
for a in (ctx.preprocessed.attachment_meta or [])),
|
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
|
_explicit_browser_intent or _external_discovery_intent
|
||||||
) else ()),
|
) else set())),
|
||||||
)
|
)
|
||||||
if exact_tool_approval is not None:
|
if exact_tool_approval is not None:
|
||||||
from src.agent_runtime.authority import RequestAuthority
|
from src.agent_runtime.authority import RequestAuthority
|
||||||
@@ -3758,7 +3826,9 @@ def setup_chat_routes(
|
|||||||
# personal manager; core shell/Web tools are not task
|
# personal manager; core shell/Web tools are not task
|
||||||
# fallbacks. Other turns retain warm and workspace tools.
|
# fallbacks. Other turns retain warm and workspace tools.
|
||||||
extra_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 (
|
if _exact_selected_native_chain or _active_turn_capabilities == frozenset({"tasks"}) or (
|
||||||
_active_turn_capabilities in (
|
_active_turn_capabilities in (
|
||||||
frozenset({"transcription"}), frozenset({"ocr"}),
|
frozenset({"transcription"}), frozenset({"ocr"}),
|
||||||
@@ -3856,6 +3926,19 @@ def setup_chat_routes(
|
|||||||
if ctx.preprocessed.attachment_meta:
|
if ctx.preprocessed.attachment_meta:
|
||||||
yield f"data: {json.dumps({'type': 'attachments', 'data': ctx.preprocessed.attachment_meta})}\n\n"
|
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
|
# Announce any docs auto-created during preprocess (e.g. fillable
|
||||||
# PDF → editable markdown) so the editor pane switches to them
|
# PDF → editable markdown) so the editor pane switches to them
|
||||||
# before the model starts streaming.
|
# before the model starts streaming.
|
||||||
@@ -3960,7 +4043,7 @@ def setup_chat_routes(
|
|||||||
_heartbeat_counter = 0
|
_heartbeat_counter = 0
|
||||||
_last_progress = {}
|
_last_progress = {}
|
||||||
_sent_avg = False
|
_sent_avg = False
|
||||||
while True:
|
while not _shutdown.is_shutting_down():
|
||||||
status = research_handler.get_status(session)
|
status = research_handler.get_status(session)
|
||||||
if not status or status["status"] != "running":
|
if not status or status["status"] != "running":
|
||||||
break
|
break
|
||||||
@@ -4222,8 +4305,6 @@ def setup_chat_routes(
|
|||||||
# indicator, but don't fold them into the saved
|
# indicator, but don't fold them into the saved
|
||||||
# reply (mirrors the rewrite path below).
|
# reply (mirrors the rewrite path below).
|
||||||
if data.get("thinking"):
|
if data.get("thinking"):
|
||||||
if thinking_mode == "off":
|
|
||||||
continue
|
|
||||||
thinking_response += data["delta"]
|
thinking_response += data["delta"]
|
||||||
else:
|
else:
|
||||||
full_response += data["delta"]
|
full_response += data["delta"]
|
||||||
@@ -4667,8 +4748,6 @@ def setup_chat_routes(
|
|||||||
# Forward them for the live indicator, but keep
|
# Forward them for the live indicator, but keep
|
||||||
# them out of the saved reply (same as chat mode).
|
# them out of the saved reply (same as chat mode).
|
||||||
if data.get("thinking"):
|
if data.get("thinking"):
|
||||||
if thinking_mode == "off":
|
|
||||||
continue
|
|
||||||
thinking_response += data["delta"]
|
thinking_response += data["delta"]
|
||||||
else:
|
else:
|
||||||
full_response = _render_state.content
|
full_response = _render_state.content
|
||||||
@@ -4852,6 +4931,25 @@ def setup_chat_routes(
|
|||||||
_terminal_saved = True
|
_terminal_saved = True
|
||||||
if _saved_id:
|
if _saved_id:
|
||||||
yield f'data: {json.dumps(_render_state.message_saved(_saved_id))}\n\n'
|
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'
|
yield f'data: {json.dumps(_metrics_event)}\n\n'
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
yield chunk
|
yield chunk
|
||||||
@@ -4962,6 +5060,8 @@ def setup_chat_routes(
|
|||||||
raises before reaching a mode-specific finally block."""
|
raises before reaching a mode-specific finally block."""
|
||||||
try:
|
try:
|
||||||
async for chunk in stream_with_save():
|
async for chunk in stream_with_save():
|
||||||
|
if _shutdown.is_shutting_down():
|
||||||
|
break
|
||||||
yield chunk
|
yield chunk
|
||||||
finally:
|
finally:
|
||||||
_active_streams.pop(session, None)
|
_active_streams.pop(session, None)
|
||||||
@@ -5056,7 +5156,12 @@ def setup_chat_routes(
|
|||||||
if rec is None:
|
if rec is None:
|
||||||
if agent_runs.is_active(session_id):
|
if agent_runs.is_active(session_id):
|
||||||
return {"status": "streaming", "detached": True}
|
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
|
return rec
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -5155,6 +5260,8 @@ def setup_chat_routes(
|
|||||||
max_tokens=0,
|
max_tokens=0,
|
||||||
tools=None,
|
tools=None,
|
||||||
):
|
):
|
||||||
|
if _shutdown.is_shutting_down():
|
||||||
|
break
|
||||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||||
try:
|
try:
|
||||||
data = json.loads(chunk[6:])
|
data = json.loads(chunk[6:])
|
||||||
|
|||||||
+179
-1
@@ -14,6 +14,7 @@ from fastapi import HTTPException
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from routes._validators import validate_remote_host, validate_ssh_port
|
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
|
from core.platform_compat import _ssh_exec_argv
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -378,6 +379,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
|||||||
"""
|
"""
|
||||||
lines = [
|
lines = [
|
||||||
"import json, os, re, shutil, subprocess, urllib.request",
|
"import json, os, re, shutil, subprocess, urllib.request",
|
||||||
|
*HF_PARTIAL_BLOB_HELPER.splitlines(),
|
||||||
"models = []",
|
"models = []",
|
||||||
"seen = set()",
|
"seen = set()",
|
||||||
"BLOCKED_ROOTS = ('/sys', '/proc', '/dev', '/run', '/var/run')",
|
"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",
|
" sz, nf, ic = 0, 0, False",
|
||||||
" if os.path.isdir(blobs):",
|
" if os.path.isdir(blobs):",
|
||||||
" for f in os.scandir(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.is_file(): nf += 1; sz += f.stat().st_size",
|
||||||
" if f.name.endswith('.incomplete'): ic = True",
|
|
||||||
" snap = os.path.join(cache, d, 'snapshots')",
|
" snap = os.path.join(cache, d, 'snapshots')",
|
||||||
" def snapshot_size():",
|
" def snapshot_size():",
|
||||||
" total, count, incomplete = 0, 0, False",
|
" 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')
|
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:
|
def _llama_cpp_rebuild_cmd(update_source: bool = False) -> str:
|
||||||
"""Shell command that clears the Cookbook-managed llama.cpp build.
|
"""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.",
|
"Port is already in use.",
|
||||||
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
|
[{"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",
|
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
|
||||||
"No GPUs are visible to the serve process.",
|
"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)
|
_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
|
# Probe scripts for the dead-session download check, run as
|
||||||
# `python3 -c <PROBE> <repo_id> <cache_root>` (locally or over SSH).
|
# `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.
|
# 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
|
# 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.
|
# the probe process's own environment knows nothing about it.
|
||||||
HF_CACHE_COMPLETE_PROBE = (
|
HF_CACHE_COMPLETE_PROBE = (
|
||||||
"import os,sys;"
|
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER +
|
||||||
"repo=sys.argv[1];"
|
"repo=sys.argv[1];"
|
||||||
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
|
"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'));"
|
"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));"
|
"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;"
|
"inc=False;"
|
||||||
"blobs=os.path.join(d,'blobs');"
|
"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)"
|
"sys.exit(0 if ok and not inc else 1)"
|
||||||
)
|
)
|
||||||
|
|
||||||
HF_CACHE_INCOMPLETE_PROBE = (
|
HF_CACHE_INCOMPLETE_PROBE = (
|
||||||
"import os,sys;"
|
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER +
|
||||||
"repo=sys.argv[1];"
|
"repo=sys.argv[1];"
|
||||||
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
|
"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'));"
|
"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('/','--'));"
|
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
|
||||||
"blobs=os.path.join(d,'blobs');"
|
"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)"
|
"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,
|
_pip_install_no_cache, _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
|
||||||
_diagnose_serve_output, run_ssh_command_async,
|
_diagnose_serve_output, run_ssh_command_async,
|
||||||
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache,
|
_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,
|
_append_pip_install_runner_lines, _pip_install_command_without_break_system_packages,
|
||||||
_normalize_llama_cpp_python_cache_types,
|
_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,
|
ModelDownloadRequest, ServeRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -466,175 +468,6 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
task["payload"].pop("hf_token", None)
|
task["payload"].pop("hf_token", None)
|
||||||
return state
|
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):
|
def _state_for_client(state):
|
||||||
"""Return cookbook state without raw secrets for browser clients."""
|
"""Return cookbook state without raw secrets for browser clients."""
|
||||||
_strip_task_secrets(state)
|
_strip_task_secrets(state)
|
||||||
@@ -2161,7 +1994,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
ps_lines.append('}')
|
ps_lines.append('}')
|
||||||
elif "llama_cpp" in req.cmd or "llama-server" in req.cmd:
|
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('# 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('if ($LASTEXITCODE -ne 0) {')
|
||||||
ps_lines.append(' Write-Host "Installing llama-cpp-python..."')
|
ps_lines.append(' Write-Host "Installing llama-cpp-python..."')
|
||||||
ps_lines.append(' python -m pip install llama-cpp-python[server]')
|
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).
|
# ollama is found (otherwise macOS falls back to a slow source build).
|
||||||
# /opt/homebrew = Apple Silicon, /usr/local = Intel; harmless on Linux.
|
# /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"')
|
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('if [ -d /data/data/com.termux ]; then')
|
||||||
runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).')
|
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(' pkg install -y cmake 2>/dev/null')
|
||||||
runner_lines.append(' pip install numpy diskcache jinja2 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(' 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(' 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(' 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(' mkdir -p ~/bin')
|
||||||
runner_lines.append(' cd ~ && [ -d llama.cpp ] || git clone --depth 1 https://github.com/ggml-org/llama.cpp')
|
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
|
# would reuse the bad settings and fail again. CMAKE_BUILD_TYPE is
|
||||||
# explicit so the binary is optimized (Metal auto-enables on macOS).
|
# 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(' 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(' && 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(' else')
|
runner_lines.append(' else')
|
||||||
_append_llama_cpp_linux_accel_build_lines(runner_lines)
|
_append_llama_cpp_linux_accel_build_lines(runner_lines)
|
||||||
runner_lines.append(' fi')
|
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')
|
runner_lines.append(' fi')
|
||||||
runner_lines.append(' fi')
|
runner_lines.append(' fi')
|
||||||
# SHORT-CIRCUIT before the build/pip fallback: if the
|
runner_lines.append(' _odysseus_resolve_llama_server || true')
|
||||||
# native binary is missing but llama_cpp Python is already
|
# If the native build failed, fall back to the Python bindings.
|
||||||
# installed, drop a wrapper at ~/bin/llama-server that
|
runner_lines.append(' if [ -z "$_odysseus_llama_bin" ] && ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||||
# 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(' echo "llama-server build failed — installing Python bindings as fallback..."')
|
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(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='pip')} || true")
|
||||||
runner_lines.append(' fi')
|
runner_lines.append(' fi')
|
||||||
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
|
_append_llama_server_finalization_lines(runner_lines)
|
||||||
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')
|
|
||||||
runner_lines.append('fi')
|
runner_lines.append('fi')
|
||||||
elif re.search(r"\bollama\s+serve\b", req.cmd):
|
elif re.search(r"\bollama\s+serve\b", req.cmd):
|
||||||
handled_ollama_serve = True
|
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}"}
|
return {"ok": False, "files": [], "error": f"HF API HTTP {resp.status_code}"}
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
except Exception:
|
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"}
|
return {"ok": False, "files": [], "error": "HF API request failed"}
|
||||||
files = [
|
files = [
|
||||||
str(s.get("rfilename") or "")
|
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
|
tmux output can stop at a stale progress line if the pane/session
|
||||||
disappears before Cookbook captures the final DOWNLOAD_OK marker.
|
disappears before Cookbook captures the final DOWNLOAD_OK marker.
|
||||||
In that case, trust the cache shape: a snapshot directory with files
|
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
|
model. cache_root is the task's custom download dir — the runner
|
||||||
pointed HF_HOME there, so the cache lives under <cache_root>/hub,
|
pointed HF_HOME there, so the cache lives under <cache_root>/hub,
|
||||||
not wherever this probe's environment says.
|
not wherever this probe's environment says.
|
||||||
@@ -4368,7 +4158,8 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
"""Best-effort check for resumable HF partial blobs.
|
"""Best-effort check for resumable HF partial blobs.
|
||||||
|
|
||||||
A lost SSH/tmux session can leave a real download still incomplete.
|
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.
|
"100%" lines in the captured pane output.
|
||||||
"""
|
"""
|
||||||
if not repo_id or "/" not in repo_id:
|
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
|
# fills the visible tail. Without this, output_tail ends up
|
||||||
# as just "Locale: C / Ubuntu_Odysseus ❯" and the agent
|
# as just "Locale: C / Ubuntu_Odysseus ❯" and the agent
|
||||||
# can't diagnose the actual error.
|
# 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:
|
elif IS_WINDOWS:
|
||||||
# LOCAL Windows task: launched as a detached process (no tmux).
|
# LOCAL Windows task: launched as a detached process (no tmux).
|
||||||
# Liveness comes from the <session>.pid file, output from the
|
# Liveness comes from the <session>.pid file, output from the
|
||||||
@@ -4495,7 +4288,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
capture_cmd = None
|
capture_cmd = None
|
||||||
else:
|
else:
|
||||||
check_cmd = ["tmux", "has-session", "-t", session_id]
|
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
|
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 core.database import Session as DbSession
|
||||||
from src.auth_helpers import get_current_user, _auth_disabled
|
from src.auth_helpers import get_current_user, _auth_disabled
|
||||||
from src.constants import MAIL_ATTACHMENTS_DIR
|
from src.constants import MAIL_ATTACHMENTS_DIR
|
||||||
|
from src.session_search import _escape_like
|
||||||
from src.upload_handler import reserve_upload_references
|
from src.upload_handler import reserve_upload_references
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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.
|
# match docs containing both words regardless of position/order.
|
||||||
if search:
|
if search:
|
||||||
for tok in search.split():
|
for tok in search.split():
|
||||||
term = f"%{tok}%"
|
term = f"%{_escape_like(tok)}%"
|
||||||
q = q.filter(
|
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
|
# 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:
|
except Exception as e:
|
||||||
logger.warning("Failed to parse export request body, defaulting to empty", exc_info=e)
|
logger.warning("Failed to parse export request body, defaulting to empty", exc_info=e)
|
||||||
data = {}
|
data = {}
|
||||||
ids = data.get("ids") or []
|
ids = data.get("ids") if isinstance(data, dict) else None
|
||||||
if not ids:
|
if not ids:
|
||||||
raise HTTPException(400, "No documents specified")
|
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 = {
|
_ext = {
|
||||||
"javascript": ".js", "python": ".py", "html": ".html", "css": ".css",
|
"javascript": ".js", "python": ".py", "html": ".html", "css": ".css",
|
||||||
"markdown": ".md", "json": ".json", "yaml": ".yml", "bash": ".sh",
|
"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")
|
ext = _ext.get(doc.language or "text", ".txt")
|
||||||
base = (doc.title or "document").strip() or "document"
|
base = (doc.title or "document").strip() or "document"
|
||||||
base = re.sub(r"[^\w\-. ]+", "", base)[:60].strip() or doc.id
|
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
|
i = 1
|
||||||
while name in used:
|
while name in used:
|
||||||
name = f"{base}-{i}" + ("" if "." in base else ext)
|
name = f"{stem}-{i}{suffix}"
|
||||||
i += 1
|
i += 1
|
||||||
used.add(name)
|
used.add(name)
|
||||||
content = doc.current_content or ""
|
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]:
|
async def ai_tidy_documents(request: Request) -> Dict[str, Any]:
|
||||||
"""Use AI to judge if documents are junk/test/accidental, then delete them.
|
"""Use AI to judge if documents are junk/test/accidental, then delete them.
|
||||||
Caches verdicts so previously-reviewed docs are skipped."""
|
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.endpoint_resolver import resolve_endpoint
|
||||||
from src.llm_core import llm_call_async
|
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)
|
+ "\n".join(doc_list)
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await llm_call_async(
|
response = await task_llm_call_async(
|
||||||
url, model,
|
|
||||||
[{"role": "system", "content": "You classify documents as junk or keep. Respond only with a JSON array."},
|
[{"role": "system", "content": "You classify documents as junk or keep. Respond only with a JSON array."},
|
||||||
{"role": "user", "content": prompt}],
|
{"role": "user", "content": prompt}],
|
||||||
|
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
||||||
|
owner=user or None,
|
||||||
temperature=0.1,
|
temperature=0.1,
|
||||||
max_tokens=200,
|
max_tokens=200,
|
||||||
headers=headers,
|
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+206
-63
@@ -43,6 +43,7 @@ from starlette.concurrency import run_in_threadpool
|
|||||||
from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request
|
from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
from src.constants import DATA_DIR
|
from src.constants import DATA_DIR
|
||||||
|
from src import shutdown as _shutdown
|
||||||
from src.path_confinement import confine
|
from src.path_confinement import confine
|
||||||
|
|
||||||
from src.llm_core import llm_call_async
|
from src.llm_core import llm_call_async
|
||||||
@@ -1826,20 +1827,75 @@ def setup_email_routes():
|
|||||||
# a cached message body.
|
# a cached message body.
|
||||||
return (account_id or "", folder, str(uid), owner)
|
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):
|
def _list_cache_get(key):
|
||||||
v = _LIST_CACHE.get(key)
|
with _LIST_LOCK:
|
||||||
if not v: return None
|
v = _LIST_CACHE.get(key)
|
||||||
if v[0] < _time.monotonic():
|
if not v: return None
|
||||||
_LIST_CACHE.pop(key, None)
|
if v[0] < _time.monotonic():
|
||||||
return None
|
_LIST_CACHE.pop(key, None)
|
||||||
return v[1]
|
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):
|
def _list_cache_put(key, value):
|
||||||
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
|
with _LIST_LOCK:
|
||||||
# Cap size
|
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
|
||||||
if len(_LIST_CACHE) > 64:
|
# Also snapshot the last-good response for stale-while-revalidate.
|
||||||
for k in list(_LIST_CACHE.keys())[:-32]:
|
_list_stale_put(key, value)
|
||||||
_LIST_CACHE.pop(k, None)
|
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):
|
def _folder_cache_get(account_id, owner):
|
||||||
key = (account_id or "", owner or "")
|
key = (account_id or "", owner or "")
|
||||||
@@ -1874,37 +1930,74 @@ def setup_email_routes():
|
|||||||
hook(account_id, folder)
|
hook(account_id, folder)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if account_id is None and folder is None:
|
with _LIST_LOCK:
|
||||||
_LIST_CACHE.clear()
|
_LIST_EPOCH[0] += 1
|
||||||
return
|
if account_id is None and folder is None:
|
||||||
for k in list(_LIST_CACHE.keys()):
|
_LIST_CACHE.clear()
|
||||||
k_acct = k[0] if len(k) > 0 else ""
|
_LIST_STALE.clear()
|
||||||
k_folder = k[1] if len(k) > 1 else ""
|
return
|
||||||
if (account_id is None or k_acct == (account_id or "")) and \
|
for cache in (_LIST_CACHE, _LIST_STALE):
|
||||||
(folder is None or k_folder == folder):
|
for k in list(cache.keys()):
|
||||||
_LIST_CACHE.pop(k, None)
|
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):
|
||||||
|
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):
|
def _update_list_cache_seen(account_id, folder, uid, seen: bool):
|
||||||
uid_s = str(uid)
|
uid_s = str(uid)
|
||||||
for key, (expires_at, value) in list(_LIST_CACHE.items()):
|
with _LIST_LOCK:
|
||||||
if key[0] != (account_id or "") or key[1] != folder:
|
for key, (expires_at, value) in list(_LIST_CACHE.items()):
|
||||||
continue
|
if key[0] != (account_id or "") or key[1] != folder:
|
||||||
emails = list((value or {}).get("emails") or [])
|
continue
|
||||||
changed = False
|
emails = list((value or {}).get("emails") or [])
|
||||||
if seen and key[2] == "unread":
|
changed = False
|
||||||
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
|
if seen and key[2] == "unread":
|
||||||
if len(kept) != len(emails):
|
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
|
||||||
value = dict(value)
|
if len(kept) != len(emails):
|
||||||
value["emails"] = kept
|
value = dict(value)
|
||||||
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
|
value["emails"] = kept
|
||||||
changed = True
|
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
|
||||||
else:
|
|
||||||
for e in emails:
|
|
||||||
if str((e or {}).get("uid") or "") == uid_s:
|
|
||||||
e["is_read"] = bool(seen)
|
|
||||||
changed = True
|
changed = True
|
||||||
if changed:
|
else:
|
||||||
_LIST_CACHE[key] = (expires_at, value)
|
for e in emails:
|
||||||
|
if str((e or {}).get("uid") or "") == uid_s:
|
||||||
|
e["is_read"] = bool(seen)
|
||||||
|
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):
|
def _read_cache_get(key):
|
||||||
v = _READ_CACHE.get(key)
|
v = _READ_CACHE.get(key)
|
||||||
@@ -2284,6 +2377,8 @@ def setup_email_routes():
|
|||||||
SECURITY: `owner` is propagated so when `account_id` is missing,
|
SECURITY: `owner` is propagated so when `account_id` is missing,
|
||||||
the fallback config lookup is scoped to this user's accounts only.
|
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 = None
|
||||||
conn_ok = False
|
conn_ok = False
|
||||||
try:
|
try:
|
||||||
@@ -2578,7 +2673,9 @@ def setup_email_routes():
|
|||||||
if parsed:
|
if parsed:
|
||||||
fetched_emails.append(parsed)
|
fetched_emails.append(parsed)
|
||||||
cached_rows[parsed["uid"]] = parsed
|
cached_rows[parsed["uid"]] = parsed
|
||||||
_email_index_upsert(owner, account_id, folder, fetched_emails)
|
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:
|
for uid_num in uid_order:
|
||||||
row = cached_rows.get(uid_num)
|
row = cached_rows.get(uid_num)
|
||||||
if not row:
|
if not row:
|
||||||
@@ -2867,6 +2964,48 @@ def setup_email_routes():
|
|||||||
owner, account_id or "", folder, filter, limit, offset, elapsed_ms,
|
owner, account_id or "", folder, filter, limit, offset, elapsed_ms,
|
||||||
)
|
)
|
||||||
return cached
|
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(
|
result = await _asyncio.to_thread(
|
||||||
_list_emails_sync, folder, limit, offset, filter, account_id, from_addr,
|
_list_emails_sync, folder, limit, offset, filter, account_id, from_addr,
|
||||||
bool(has_attachments), owner, manual_refresh, date_from or "", date_to or "",
|
bool(has_attachments), owner, manual_refresh, date_from or "", date_to or "",
|
||||||
@@ -2875,7 +3014,9 @@ def setup_email_routes():
|
|||||||
if offset == 0 and not from_addr and not has_attachments and filter in ("all", "unread", "unanswered", "undone"):
|
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 [])
|
_record_email_received_events(owner, account_id, folder, result.get("emails") or [])
|
||||||
_schedule_recent_email_warm(result.get("emails") or [], folder, account_id, owner)
|
_schedule_recent_email_warm(result.get("emails") or [], folder, account_id, owner)
|
||||||
_list_cache_put(ck, result)
|
with _LIST_CACHE_LOCK:
|
||||||
|
if cache_generation == _LIST_CACHE_GENERATION[0]:
|
||||||
|
_list_cache_put(ck, result)
|
||||||
elapsed_ms = int((_time.monotonic() - started_at) * 1000)
|
elapsed_ms = int((_time.monotonic() - started_at) * 1000)
|
||||||
if elapsed_ms > 1500:
|
if elapsed_ms > 1500:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -3223,7 +3364,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
moved = _move_email_message(conn, uid, "Junk", role="junk")
|
moved = _move_email_message(conn, uid, "Junk", role="junk")
|
||||||
if moved:
|
if moved:
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_index_delete(owner, account_id, folder, uid)
|
||||||
_invalidate_list_cache(account_id)
|
_invalidate_list_cache(account_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("unsubscribe move-to-spam skipped", exc_info=True)
|
logger.debug("unsubscribe move-to-spam skipped", exc_info=True)
|
||||||
@@ -3240,7 +3381,7 @@ def setup_email_routes():
|
|||||||
if deleted:
|
if deleted:
|
||||||
conn.expunge()
|
conn.expunge()
|
||||||
if deleted:
|
if deleted:
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_index_delete(owner, account_id, folder, uid)
|
||||||
_invalidate_list_cache(account_id)
|
_invalidate_list_cache(account_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
delete_error = "Email was unsubscribed but could not be moved to Trash"
|
delete_error = "Email was unsubscribed but could not be moved to Trash"
|
||||||
@@ -3322,7 +3463,7 @@ def setup_email_routes():
|
|||||||
if moved:
|
if moved:
|
||||||
changed += 1
|
changed += 1
|
||||||
cleaned_uids.append(uid)
|
cleaned_uids.append(uid)
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_index_delete(owner, account_id, folder, uid)
|
||||||
else:
|
else:
|
||||||
failed += 1
|
failed += 1
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -3411,6 +3552,8 @@ def setup_email_routes():
|
|||||||
folder (Gmail does), we transparently swap to All Mail so the
|
folder (Gmail does), we transparently swap to All Mail so the
|
||||||
search surfaces archived / labelled emails too. Plain IMAP
|
search surfaces archived / labelled emails too. Plain IMAP
|
||||||
accounts fall back to whatever folder the caller specified."""
|
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:
|
if not q or len(q) < 2:
|
||||||
return {"emails": [], "total": 0, "query": q}
|
return {"emails": [], "total": 0, "query": q}
|
||||||
# CRLF in q would terminate the IMAP command early — reject defensively.
|
# CRLF in q would terminate the IMAP command early — reject defensively.
|
||||||
@@ -3554,7 +3697,9 @@ def setup_email_routes():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error parsing search result {uid}: {e}")
|
logger.warning(f"Error parsing search result {uid}: {e}")
|
||||||
continue
|
continue
|
||||||
_email_index_upsert(owner, account_id, effective_folder, fetched_emails)
|
with _LIST_CACHE_LOCK:
|
||||||
|
if index_generation == _LIST_CACHE_GENERATION[0]:
|
||||||
|
_email_index_upsert(owner, account_id, effective_folder, fetched_emails)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"emails": emails,
|
"emails": emails,
|
||||||
@@ -3691,8 +3836,7 @@ def setup_email_routes():
|
|||||||
# accepted it, so the index and list cache cannot drift ahead of
|
# accepted it, so the index and list cache cannot drift ahead of
|
||||||
# the mailbox.
|
# the mailbox.
|
||||||
if mark_seen and not mark_seen_failed:
|
if mark_seen and not mark_seen_failed:
|
||||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||||
_update_list_cache_seen(account_id, folder, uid, True)
|
|
||||||
|
|
||||||
related_attachments = []
|
related_attachments = []
|
||||||
if full and not _has_visible_attachments(msg):
|
if full and not _has_visible_attachments(msg):
|
||||||
@@ -3831,8 +3975,7 @@ def setup_email_routes():
|
|||||||
status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "(\\Seen)")
|
status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "(\\Seen)")
|
||||||
if status != "OK":
|
if status != "OK":
|
||||||
return False
|
return False
|
||||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||||
_update_list_cache_seen(account_id, folder, uid, True)
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"mark-seen after cached read failed uid={uid}: {e}")
|
logger.warning(f"mark-seen after cached read failed uid={uid}: {e}")
|
||||||
@@ -5064,7 +5207,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _store_email_flag(conn, uid, "\\Seen", add=False):
|
if not _store_email_flag(conn, uid, "\\Seen", add=False):
|
||||||
return {"success": False, "error": "Email not found"}
|
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)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5084,7 +5227,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _store_email_flag(conn, uid, "\\Flagged", add=bool(on)):
|
if not _store_email_flag(conn, uid, "\\Flagged", add=bool(on)):
|
||||||
return {"success": False, "error": "Email not found"}
|
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)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True, "flagged": bool(on)}
|
return {"success": True, "flagged": bool(on)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5102,7 +5245,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _store_email_flag(conn, uid, "\\Seen", add=True):
|
if not _store_email_flag(conn, uid, "\\Seen", add=True):
|
||||||
return {"success": False, "error": "Email not found"}
|
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)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5122,7 +5265,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _move_email_message(conn, uid, "Archive", role="archive"):
|
if not _move_email_message(conn, uid, "Archive", role="archive"):
|
||||||
return {"success": False, "error": "Email not found"}
|
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)
|
_invalidate_list_cache(account_id)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5153,7 +5296,7 @@ def setup_email_routes():
|
|||||||
# click or by another mailbox client; it is already gone
|
# click or by another mailbox client; it is already gone
|
||||||
# from the requested folder, so do not trap the UI on a
|
# from the requested folder, so do not trap the UI on a
|
||||||
# permanent “Email not found” error.
|
# 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)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True, "already_deleted": True}
|
return {"success": True, "already_deleted": True}
|
||||||
trash_folder = _resolve_mail_folder(conn, "Trash", "trash")
|
trash_folder = _resolve_mail_folder(conn, "Trash", "trash")
|
||||||
@@ -5171,9 +5314,9 @@ def setup_email_routes():
|
|||||||
if not _move_email_message(conn, resolved_uid, trash_folder, role="trash"):
|
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)
|
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"}
|
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):
|
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)
|
_invalidate_list_cache(account_id)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5189,7 +5332,7 @@ def setup_email_routes():
|
|||||||
if not _store_email_flag(conn, uid, "\\Deleted", add=True):
|
if not _store_email_flag(conn, uid, "\\Deleted", add=True):
|
||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
conn.expunge()
|
conn.expunge()
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_index_delete(owner, account_id, folder, uid)
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5257,7 +5400,7 @@ def setup_email_routes():
|
|||||||
pending_expunge.append(uid)
|
pending_expunge.append(uid)
|
||||||
continue
|
continue
|
||||||
deleted_uids.append(uid)
|
deleted_uids.append(uid)
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_index_delete(owner, account_id, folder, uid)
|
||||||
except Exception:
|
except Exception:
|
||||||
failed_uids.append(uid)
|
failed_uids.append(uid)
|
||||||
logger.debug("Bulk email delete failed for uid=%s", uid, exc_info=True)
|
logger.debug("Bulk email delete failed for uid=%s", uid, exc_info=True)
|
||||||
@@ -5279,7 +5422,7 @@ def setup_email_routes():
|
|||||||
if expunge_ok:
|
if expunge_ok:
|
||||||
for uid in pending_expunge:
|
for uid in pending_expunge:
|
||||||
deleted_uids.append(uid)
|
deleted_uids.append(uid)
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_index_delete(owner, account_id, folder, uid)
|
||||||
else:
|
else:
|
||||||
# Prioritize preservation over cleanup: unflag \Deleted from our
|
# Prioritize preservation over cleanup: unflag \Deleted from our
|
||||||
# copied messages so future EXPUNGE operations do not destroy them.
|
# copied messages so future EXPUNGE operations do not destroy them.
|
||||||
@@ -5398,7 +5541,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _move_email_message(conn, uid, dest):
|
if not _move_email_message(conn, uid, dest):
|
||||||
return {"success": False, "error": f"Failed to move to {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)
|
_invalidate_list_cache(account_id)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5484,7 +5627,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _store_email_flag(conn, uid, "\\Answered", add=True):
|
if not _store_email_flag(conn, uid, "\\Answered", add=True):
|
||||||
return {"success": False, "error": "Email not found"}
|
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)
|
_clear_done_response_tags(owner, account_id, folder, uid)
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
@@ -5500,7 +5643,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _store_email_flag(conn, uid, "\\Answered", add=False):
|
if not _store_email_flag(conn, uid, "\\Answered", add=False):
|
||||||
return {"success": False, "error": "Email not found"}
|
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)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -6167,7 +6310,7 @@ def setup_email_routes():
|
|||||||
try:
|
try:
|
||||||
st, _sel = imap.select(_q(_source_folder), readonly=False)
|
st, _sel = imap.select(_q(_source_folder), readonly=False)
|
||||||
if st == "OK" and _store_email_flag(imap, _source_uid, "\\Answered", add=True):
|
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)
|
_clear_done_response_tags(owner, _account_id, _source_folder, _source_uid)
|
||||||
_invalidate_list_cache(_account_id, _source_folder)
|
_invalidate_list_cache(_account_id, _source_folder)
|
||||||
logger.info(f"Marked source UID {_source_uid} as \\Answered in {_source_folder}")
|
logger.info(f"Marked source UID {_source_uid} as \\Answered in {_source_folder}")
|
||||||
@@ -6717,7 +6860,7 @@ def setup_email_routes():
|
|||||||
await queue.put({'type': 'result', **result})
|
await queue.put({'type': 'result', **result})
|
||||||
task = asyncio.create_task(run())
|
task = asyncio.create_task(run())
|
||||||
try:
|
try:
|
||||||
while True:
|
while not _shutdown.is_shutting_down():
|
||||||
item = await queue.get()
|
item = await queue.get()
|
||||||
yield 'data: ' + json.dumps(item) + '\n\n'
|
yield 'data: ' + json.dumps(item) + '\n\n'
|
||||||
if item.get('type') == 'result':
|
if item.get('type') == 'result':
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ def _split_family_token(token):
|
|||||||
def _derive_family(filename):
|
def _derive_family(filename):
|
||||||
"""Derive a font-family name from a filename like 'JetBrainsMono-Regular.woff2' → 'JetBrains Mono'."""
|
"""Derive a font-family name from a filename like 'JetBrainsMono-Regular.woff2' → 'JetBrains Mono'."""
|
||||||
name = os.path.splitext(filename)[0]
|
name = os.path.splitext(filename)[0]
|
||||||
# Strip common weight/style suffixes
|
# Strip weight/style suffixes, including BoldItalic and Bold-Italic.
|
||||||
name = re.sub(
|
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
|
'', name, flags=re.IGNORECASE
|
||||||
)
|
)
|
||||||
# Replace dashes/underscores with spaces
|
# 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:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
keep_count = body.get("keep_count", 0)
|
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,
|
# Get the source session. keep_count indexes into source.history,
|
||||||
# so this must go through get_session — reading the cache directly
|
# so this must go through get_session — reading the cache directly
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import html
|
import html
|
||||||
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import APIRouter, Form, HTTPException, Request
|
from fastapi import APIRouter, Form, HTTPException, Request
|
||||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||||
@@ -12,7 +13,7 @@ import logging
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from core.database import McpServer, SessionLocal
|
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.constants import DATA_DIR, MCP_OAUTH_DIR
|
||||||
from src.mcp_manager import McpManager
|
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"]}
|
Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]}
|
||||||
"""
|
"""
|
||||||
require_admin(request)
|
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()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||||
|
|||||||
+33
-10
@@ -7,6 +7,7 @@ import json
|
|||||||
import hashlib
|
import hashlib
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import socket
|
import socket
|
||||||
|
import secrets
|
||||||
import time as _time
|
import time as _time
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
@@ -473,7 +474,7 @@ def _truthy(value: str | None) -> bool:
|
|||||||
|
|
||||||
_ENDPOINT_KINDS = {"auto", "local", "api", "proxy"}
|
_ENDPOINT_KINDS = {"auto", "local", "api", "proxy"}
|
||||||
_REFRESH_MODES = {"auto", "manual", "disabled"}
|
_REFRESH_MODES = {"auto", "manual", "disabled"}
|
||||||
_MODEL_TOOL_MODES = {"none", "compact", "full"}
|
_MODEL_TOOL_MODES = {"none", "compact", "full", "spec"}
|
||||||
_MODEL_TOOL_MODE_ALIASES = {
|
_MODEL_TOOL_MODE_ALIASES = {
|
||||||
"regular": "full",
|
"regular": "full",
|
||||||
"odysseus_compact": "compact",
|
"odysseus_compact": "compact",
|
||||||
@@ -553,10 +554,10 @@ def _endpoint_refresh_timeout(ep: Any, category: str) -> float:
|
|||||||
val = 0
|
val = 0
|
||||||
if val > 0:
|
if val > 0:
|
||||||
return float(max(1, min(60, val)))
|
return float(max(1, min(60, val)))
|
||||||
# llama.cpp and other local OpenAI-compatible servers can block briefly
|
# Keep the background refresh short so a single dead endpoint cannot stall
|
||||||
# while warming/loading. A 2s local timeout makes working endpoints flicker
|
# the model picker. A timed-out refresh never clears a non-empty model
|
||||||
# offline before /v1/models is ready.
|
# cache, so a briefly-warming local server is simply retried next round.
|
||||||
return 10.0 if category == "local" else 2.0
|
return 2.0
|
||||||
|
|
||||||
|
|
||||||
def _manual_refresh_timeout(ep: Any, category: str, requested: Any = None) -> float:
|
def _manual_refresh_timeout(ep: Any, category: str, requested: Any = None) -> float:
|
||||||
@@ -658,7 +659,7 @@ _NON_CHAT_CONTAINS = (
|
|||||||
"gliner", "riva", "-parse", "-embedqa", "-nemoretriever",
|
"gliner", "riva", "-parse", "-embedqa", "-nemoretriever",
|
||||||
"topic-control", "calibration",
|
"topic-control", "calibration",
|
||||||
"ai-synthetic-video", "cosmos-reason2",
|
"ai-synthetic-video", "cosmos-reason2",
|
||||||
"bge", "llama-guard",
|
"bge", "llama-guard", "-embed", "rerank",
|
||||||
)
|
)
|
||||||
_NON_CHAT_EXACT_PREFIXES = (
|
_NON_CHAT_EXACT_PREFIXES = (
|
||||||
"gpt-audio", # gpt-audio, gpt-audio-mini etc. (not gpt-4o-audio-preview which is chat)
|
"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_inflight = {"v": False, "done": None} # coarse single-flight guard
|
||||||
_REFRESH_FAILURE_BASE = 300.0
|
_REFRESH_FAILURE_BASE = 300.0
|
||||||
_REFRESH_FAILURE_MAX = 3600.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:
|
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
|
# 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:
|
if mode in ("manual", "disabled") and not force:
|
||||||
return False, info
|
return False, info
|
||||||
fails = int(state.get("fail_count") or 0)
|
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)
|
last_failure = float(state.get("last_failure") or 0.0)
|
||||||
empty_local = (
|
empty_local = (
|
||||||
not cached
|
not cached
|
||||||
and category == "local"
|
and category == "local"
|
||||||
and str(getattr(ep, "id", "") or "").startswith("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
|
return False, info
|
||||||
if cached and not force:
|
if cached and not force:
|
||||||
interval = _endpoint_refresh_interval(ep, category)
|
interval = _endpoint_refresh_interval(ep, category)
|
||||||
@@ -2836,11 +2846,12 @@ def setup_model_routes(model_discovery):
|
|||||||
return {"endpoint_id": "", "endpoint_url": "", "model": ""}
|
return {"endpoint_id": "", "endpoint_url": "", "model": ""}
|
||||||
base = _normalize_base(ep.base_url)
|
base = _normalize_base(ep.base_url)
|
||||||
chat_url = build_chat_url(base)
|
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)):
|
if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)):
|
||||||
try:
|
try:
|
||||||
visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None))
|
visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None))
|
||||||
if visible:
|
model = next((mid for mid in visible if _is_chat_model(mid)), "")
|
||||||
model = visible[0]
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return {"endpoint_id": ep.id, "endpoint_url": chat_url, "model": model}
|
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):
|
def update_tools(body: ToolsUpdate, request: Request):
|
||||||
"""Update which tools are disabled."""
|
"""Update which tools are disabled."""
|
||||||
require_admin(request)
|
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 = _load_settings()
|
||||||
settings["disabled_tools"] = body.disabled
|
settings["disabled_tools"] = body.disabled
|
||||||
_save_settings(settings)
|
_save_settings(settings)
|
||||||
@@ -3089,4 +3106,10 @@ def setup_model_routes(model_discovery):
|
|||||||
|
|
||||||
router._should_refresh_endpoint = _should_refresh_endpoint
|
router._should_refresh_endpoint = _should_refresh_endpoint
|
||||||
router._search_endpoint_catalog = search_endpoint_catalog
|
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
|
return router
|
||||||
|
|||||||
@@ -728,32 +728,35 @@ def setup_note_routes(task_scheduler=None, upload_handler=None):
|
|||||||
)
|
)
|
||||||
if body.title is not None:
|
if body.title is not None:
|
||||||
note.title = body.title
|
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
|
note.content = body.content
|
||||||
if body.items is not None:
|
if "items" in supplied:
|
||||||
note.items = json.dumps(body.items)
|
note.items = json.dumps(body.items) if body.items is not None else None
|
||||||
flag_modified(note, "items")
|
flag_modified(note, "items")
|
||||||
if body.note_type is not None:
|
if body.note_type is not None:
|
||||||
note.note_type = body.note_type
|
note.note_type = body.note_type
|
||||||
if body.color is not None:
|
if "color" in supplied:
|
||||||
note.color = body.color
|
note.color = body.color
|
||||||
if body.label is not None:
|
if "label" in supplied:
|
||||||
note.label = body.label
|
note.label = body.label
|
||||||
if body.pinned is not None:
|
if body.pinned is not None:
|
||||||
note.pinned = body.pinned
|
note.pinned = body.pinned
|
||||||
if body.archived is not None:
|
if body.archived is not None:
|
||||||
note.archived = body.archived
|
note.archived = body.archived
|
||||||
if body.due_date is not None:
|
if "due_date" in supplied:
|
||||||
note.due_date = body.due_date
|
note.due_date = body.due_date
|
||||||
if body.image_url is not None:
|
if "image_url" in supplied:
|
||||||
note.image_url = body.image_url
|
note.image_url = body.image_url
|
||||||
if body.gallery_id is not None:
|
if "gallery_id" in supplied:
|
||||||
note.gallery_id = body.gallery_id
|
note.gallery_id = body.gallery_id
|
||||||
if body.repeat is not None:
|
if body.repeat is not None:
|
||||||
note.repeat = body.repeat
|
note.repeat = body.repeat
|
||||||
if body.sort_order is not None:
|
if body.sort_order is not None:
|
||||||
note.sort_order = body.sort_order
|
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
|
note.agent_session_id = body.agent_session_id
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -118,8 +118,12 @@ def setup_prefs_routes():
|
|||||||
async def set_pref(request: Request, key: str, body: dict):
|
async def set_pref(request: Request, key: str, body: dict):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
prefs = _load_for_user(user)
|
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)
|
_save_for_user(user, prefs)
|
||||||
return {"key": key, "value": prefs[key]}
|
return {"key": key, "value": prefs.get(key)}
|
||||||
|
|
||||||
return router
|
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.auth_helpers import _auth_disabled, get_current_user
|
||||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
||||||
from src.constants import DEEP_RESEARCH_DIR
|
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}$")
|
_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.
|
OAuth tokens in ProviderAuthSession, so ep.api_key is intentionally empty.
|
||||||
"""
|
"""
|
||||||
from src.endpoint_resolver import (
|
from src.endpoint_resolver import (
|
||||||
|
_endpoint_enabled_models,
|
||||||
build_chat_url,
|
build_chat_url,
|
||||||
build_headers,
|
build_headers,
|
||||||
resolve_endpoint_runtime as resolve_model_endpoint_runtime,
|
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()
|
ep_model = (model or "").strip()
|
||||||
if not ep_model:
|
if not ep_model:
|
||||||
try:
|
try:
|
||||||
models = json.loads(ep.cached_models) if ep.cached_models else []
|
models = _endpoint_enabled_models(ep)
|
||||||
if models:
|
if models:
|
||||||
ep_model = _first_chat_model(models)
|
ep_model = _first_chat_model(models)
|
||||||
except Exception:
|
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):
|
async def research_status(session_id: str, request: Request):
|
||||||
user = _require_user(request)
|
user = _require_user(request)
|
||||||
_validate_session_id(session_id)
|
_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):
|
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)
|
status = research_handler.get_status(session_id)
|
||||||
if status is None:
|
if status is None:
|
||||||
raise HTTPException(404, "No research found for this session")
|
return {"status": "none"}
|
||||||
try:
|
try:
|
||||||
source_state = research_handler.get_source_state(session_id)
|
source_state = research_handler.get_source_state(session_id)
|
||||||
if isinstance(source_state, str) and source_state:
|
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")
|
raise HTTPException(404, "No research found for this session")
|
||||||
async def _generate():
|
async def _generate():
|
||||||
last_payload = None
|
last_payload = None
|
||||||
while True:
|
while not _shutdown.is_shutting_down():
|
||||||
status = research_handler.get_status(session_id)
|
status = research_handler.get_status(session_id)
|
||||||
if status is None:
|
if status is None:
|
||||||
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
|
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:
|
if not ep_url or not ep_model:
|
||||||
# Last resort: this user's enabled endpoint, plus legacy shared rows.
|
# Last resort: this user's enabled endpoint, plus legacy shared rows.
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ep = _owned_enabled_endpoint(db, user)
|
ep = _owned_enabled_endpoint(db, user)
|
||||||
if ep:
|
if ep:
|
||||||
base = normalize_base(ep.base_url)
|
resolved = _resolve_endpoint_runtime(ep, owner=user)
|
||||||
fallback_url = build_chat_url(base)
|
if resolved:
|
||||||
fallback_headers = build_headers(ep.api_key, base)
|
_merge(*resolved)
|
||||||
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)
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
+15
-45
@@ -1315,32 +1315,22 @@ def setup_session_routes(
|
|||||||
def auto_sort_sessions(request: Request, skip_llm: bool = False):
|
def auto_sort_sessions(request: Request, skip_llm: bool = False):
|
||||||
"""Use AI to categorize all sessions into folders.
|
"""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
|
to assign folders. When `skip_llm=true` the endpoint returns
|
||||||
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
|
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
|
||||||
users can clean junk without spending tokens.
|
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
|
from src.utility_effort import effort_for_call
|
||||||
user = effective_user(request)
|
user = effective_user(request)
|
||||||
single_user_mode = not user and _auth_disabled()
|
single_user_mode = not user and _auth_disabled()
|
||||||
user_sessions = session_manager.get_sessions_for_user(user)
|
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
|
from core.database import ChatMessage as DbMsg
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
deleted_empty = 0
|
deleted_empty = 0
|
||||||
deleted_throwaway = 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:
|
try:
|
||||||
rows_q = db.query(DbSession).filter(DbSession.archived == False)
|
rows_q = db.query(DbSession).filter(DbSession.archived == False)
|
||||||
if user:
|
if user:
|
||||||
@@ -1349,15 +1339,11 @@ def setup_session_routes(
|
|||||||
rows_q = rows_q.filter(DbSession.owner == user)
|
rows_q = rows_q.filter(DbSession.owner == user)
|
||||||
rows = rows_q.limit(2000).all()
|
rows = rows_q.limit(2000).all()
|
||||||
folder_map = {r.id: r.folder for r in rows}
|
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
|
# instead of 1–3 queries PER session — with many chats the per-row
|
||||||
# loop was doing thousands of round-trips and blowing the timeout.
|
# loop was doing thousands of round-trips and blowing the timeout.
|
||||||
from sqlalchemy import func as _sa_func
|
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())
|
_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()
|
cleanup_now = utcnow_naive()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
# Never delete important sessions
|
# Never delete important sessions
|
||||||
@@ -1367,9 +1353,10 @@ def setup_session_routes(
|
|||||||
if (row.name or "").strip() == "Incognito":
|
if (row.name or "").strip() == "Incognito":
|
||||||
should_delete = True
|
should_delete = True
|
||||||
deleted_throwaway += 1
|
deleted_throwaway += 1
|
||||||
db.delete(row)
|
|
||||||
if hasattr(session_manager, 'delete_session'):
|
if hasattr(session_manager, 'delete_session'):
|
||||||
session_manager.delete_session(row.id)
|
session_manager.delete_session(row.id)
|
||||||
|
else:
|
||||||
|
db.delete(row)
|
||||||
continue
|
continue
|
||||||
if is_session_recently_active(row, now=cleanup_now):
|
if is_session_recently_active(row, now=cleanup_now):
|
||||||
continue
|
continue
|
||||||
@@ -1378,31 +1365,11 @@ def setup_session_routes(
|
|||||||
if msg_count == 0:
|
if msg_count == 0:
|
||||||
should_delete = True
|
should_delete = True
|
||||||
deleted_empty += 1
|
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:
|
if should_delete:
|
||||||
db.delete(row)
|
|
||||||
if hasattr(session_manager, 'delete_session'):
|
if hasattr(session_manager, 'delete_session'):
|
||||||
session_manager.delete_session(row.id)
|
session_manager.delete_session(row.id)
|
||||||
|
else:
|
||||||
|
db.delete(row)
|
||||||
if deleted_empty or deleted_throwaway:
|
if deleted_empty or deleted_throwaway:
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info(f"Auto-sort: deleted {deleted_empty} empty + {deleted_throwaway} throwaway sessions")
|
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"}
|
return {"status": "skipped", "reason": "No unfiled sessions to sort"}
|
||||||
|
|
||||||
# Pick an endpoint — prefer admin-configured task endpoint
|
# 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)
|
url, model, headers = resolve_task_endpoint(owner=user)
|
||||||
if not url:
|
if not url:
|
||||||
url, model, headers = _pick_endpoint_for_sort(owner=user)
|
url, model, headers = _pick_endpoint_for_sort(owner=user)
|
||||||
@@ -1493,9 +1460,12 @@ def setup_session_routes(
|
|||||||
# 16384 (was 4096): with many chats the folder JSON is large, and a
|
# 16384 (was 4096): with many chats the folder JSON is large, and a
|
||||||
# reasoning model spends tokens thinking first — 4096 truncated the
|
# reasoning model spends tokens thinking first — 4096 truncated the
|
||||||
# JSON mid-output, so it never parsed ("invalid JSON for auto-sort").
|
# JSON mid-output, so it never parsed ("invalid JSON for auto-sort").
|
||||||
raw = llm_call(url, model, [{"role": "user", "content": prompt}],
|
raw = llm_call_with_fallback(
|
||||||
temperature=0.3, max_tokens=16384, headers=headers, timeout=120,
|
resolve_task_candidates(fallback_url=url, fallback_model=model,
|
||||||
reasoning_effort=effort_for_call(url, model, user))
|
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]}")
|
logger.info(f"Auto-sort raw response ({len(raw)} chars): {raw[:300]}")
|
||||||
# Extract JSON from response — handle markdown fences, leading text,
|
# Extract JSON from response — handle markdown fences, leading text,
|
||||||
# reasoning-model <think> blocks, and trailing commas.
|
# reasoning-model <think> blocks, and trailing commas.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from src.host_docker_access import (
|
|||||||
from src.optional_deps import prepare_optional_dependency_import
|
from src.optional_deps import prepare_optional_dependency_import
|
||||||
from src.auth_helpers import _auth_disabled
|
from src.auth_helpers import _auth_disabled
|
||||||
from src import process_lifecycle
|
from src import process_lifecycle
|
||||||
|
from src import shutdown as _shutdown
|
||||||
|
|
||||||
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
|
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
|
||||||
# on Windows, so importing them unconditionally crashed app startup there
|
# 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
|
lines_sent = 0
|
||||||
exit_code = None
|
exit_code = None
|
||||||
|
|
||||||
while True:
|
while not _shutdown.is_shutting_down():
|
||||||
# Check client disconnect
|
# Check client disconnect
|
||||||
if await request.is_disconnected():
|
if await request.is_disconnected():
|
||||||
# tmux keeps running — that's the whole point
|
# tmux keeps running — that's the whole point
|
||||||
@@ -1142,7 +1143,7 @@ async def _generate_win_detached(cmd: str, request: Request):
|
|||||||
|
|
||||||
lines_sent = 0
|
lines_sent = 0
|
||||||
exit_code = None
|
exit_code = None
|
||||||
while True:
|
while not _shutdown.is_shutting_down():
|
||||||
if await request.is_disconnected():
|
if await request.is_disconnected():
|
||||||
yield f"data: {json.dumps({'stream': 'stdout', 'data': f'Disconnected. Background job {session_id} continues running.'})}\n\n"
|
yield f"data: {json.dumps({'stream': 'stdout', 'data': f'Disconnected. Background job {session_id} continues running.'})}\n\n"
|
||||||
return
|
return
|
||||||
@@ -1267,7 +1268,7 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
"""Read chunks, split on \\n or \\r for progress bar support."""
|
"""Read chunks, split on \\n or \\r for progress bar support."""
|
||||||
try:
|
try:
|
||||||
buf = b""
|
buf = b""
|
||||||
while True:
|
while not _shutdown.is_shutting_down():
|
||||||
chunk = await stream.read(4096)
|
chunk = await stream.read(4096)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
if buf:
|
if buf:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import httpx
|
|||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from services.memory.skill_format import slugify
|
||||||
from services.memory.skills import SkillsManager
|
from services.memory.skills import SkillsManager
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
from services.skills import evaluation as _evaluation
|
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)
|
updates = body.dict(exclude_none=True)
|
||||||
if not updates:
|
if not updates:
|
||||||
return {"ok": True}
|
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)
|
ok = skills_manager.update_skill(match.get("name"), updates, owner=user)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(404, "Skill not found")
|
raise HTTPException(404, "Skill not found")
|
||||||
|
|||||||
+11
-17
@@ -20,7 +20,7 @@ from src.task_action_policy import (
|
|||||||
is_admin_only_task_action,
|
is_admin_only_task_action,
|
||||||
owner_has_admin_task_privileges,
|
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
|
from routes.prefs_routes import _load_for_user, _save_for_user
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -289,15 +289,15 @@ def _resolve_run_endpoint(db, task: ScheduledTask, run: TaskRun) -> str:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from core.database import ModelEndpoint
|
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:
|
for ep in eps:
|
||||||
cached = []
|
if model in _endpoint_enabled_models(ep):
|
||||||
if ep.cached_models:
|
|
||||||
try:
|
|
||||||
cached = json.loads(ep.cached_models) or []
|
|
||||||
except Exception:
|
|
||||||
cached = []
|
|
||||||
if model in cached:
|
|
||||||
return ep.base_url or ""
|
return ep.base_url or ""
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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:
|
if req.trigger_type == "schedule" and req.schedule == "cron" and not req.cron_expression:
|
||||||
raise HTTPException(400, "Cron expression is required for cron schedule")
|
raise HTTPException(400, "Cron expression is required for cron schedule")
|
||||||
if req.trigger_type == "schedule" and req.schedule == "cron" and req.cron_expression:
|
if req.trigger_type == "schedule" and req.schedule == "cron" and req.cron_expression:
|
||||||
try:
|
if not is_valid_cron(req.cron_expression):
|
||||||
from croniter import croniter
|
|
||||||
croniter(req.cron_expression)
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(400, "Invalid cron expression")
|
raise HTTPException(400, "Invalid cron expression")
|
||||||
if req.trigger_type == "event" and not req.trigger_event:
|
if req.trigger_type == "event" and not req.trigger_event:
|
||||||
raise HTTPException(400, "Event name is required for event-triggered tasks")
|
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
|
task.character_id = req.character_id or None
|
||||||
if req.cron_expression is not None:
|
if req.cron_expression is not None:
|
||||||
if req.cron_expression:
|
if req.cron_expression:
|
||||||
try:
|
if not is_valid_cron(req.cron_expression):
|
||||||
from croniter import croniter
|
|
||||||
croniter(req.cron_expression)
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(400, "Invalid cron expression")
|
raise HTTPException(400, "Invalid cron expression")
|
||||||
task.cron_expression = req.cron_expression or None
|
task.cron_expression = req.cron_expression or None
|
||||||
|
|
||||||
|
|||||||
+92
-46
@@ -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.owner_identity import auth_disabled, is_request_sentinel_owner
|
||||||
from src.attachment_refs import attachment_refs_from_metadata
|
from src.attachment_refs import attachment_refs_from_metadata
|
||||||
from src.constants import GENERATED_IMAGES_DIR
|
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 (
|
from src.upload_handler import (
|
||||||
UploadCleanupSafetyError,
|
UploadCleanupSafetyError,
|
||||||
count_recent_uploads,
|
count_recent_uploads,
|
||||||
extract_upload_ids,
|
extract_upload_ids,
|
||||||
|
secure_filename,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -153,30 +154,47 @@ def setup_upload_routes(upload_handler):
|
|||||||
|
|
||||||
def _upload_root() -> str:
|
def _upload_root() -> str:
|
||||||
from src.constants import UPLOAD_DIR
|
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:
|
def _path_inside_upload_dir(path: str) -> bool:
|
||||||
return is_inside(_upload_root(), path)
|
return is_inside(_upload_root(), path)
|
||||||
|
|
||||||
def _resolve_upload_path(file_id: str) -> str:
|
def _resolve_upload_path(file_id: str) -> str:
|
||||||
from src.constants import UPLOAD_DIR
|
safe_id = secure_filename(os.path.basename(file_id))
|
||||||
upload_root = getattr(upload_handler, "upload_dir", UPLOAD_DIR)
|
if not safe_id:
|
||||||
direct = os.path.join(upload_root, file_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 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")
|
raise HTTPException(403, "Access denied")
|
||||||
if os.path.isfile(direct):
|
if os.path.isfile(canonical_direct):
|
||||||
return direct
|
return canonical_direct
|
||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
|
|
||||||
for root, _dirs, files in os.walk(upload_root, followlinks=False):
|
info = _load_upload_info(safe_id)
|
||||||
if file_id not in files:
|
stored_path = (info or {}).get("path")
|
||||||
continue
|
if stored_path:
|
||||||
path = os.path.join(root, file_id)
|
try:
|
||||||
if not _path_inside_upload_dir(path):
|
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")
|
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")
|
||||||
|
|
||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
@@ -435,52 +453,79 @@ def setup_upload_routes(upload_handler):
|
|||||||
logger.error(f"Failed to get upload stats: {e}")
|
logger.error(f"Failed to get upload stats: {e}")
|
||||||
raise HTTPException(500, "Failed to get upload statistics")
|
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):
|
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
|
"""Serve an uploaded file by its ID or probe via HEAD. `?thumb=1` returns
|
||||||
JPEG thumbnail for images (used by chat attachment previews) so the
|
a small cached JPEG thumbnail for images (used by chat attachment previews)
|
||||||
client isn't downloading the full-resolution photo just to show it tiny."""
|
so the client isn't downloading the full-resolution photo just to show it tiny."""
|
||||||
if not upload_handler.validate_upload_id(file_id):
|
if not upload_handler.validate_upload_id(file_id):
|
||||||
raise HTTPException(400, "Invalid file ID")
|
raise HTTPException(400, "Invalid file ID")
|
||||||
|
safe_file_id = secure_filename(os.path.basename(file_id))
|
||||||
import mimetypes as _mt
|
import mimetypes as _mt
|
||||||
# Look up original filename and owner from uploads.json
|
# 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
|
# _load_upload_index() tolerates a missing/corrupt uploads.json (it falls
|
||||||
# back to the .bak sibling, then to {}), so a truncated DB degrades to
|
# back to the .bak sibling, then to {}), so a truncated DB degrades to
|
||||||
# "no metadata" instead of a 500 from an unhandled JSONDecodeError.
|
# "no metadata" instead of a 500 from an unhandled JSONDecodeError.
|
||||||
db = upload_handler._load_upload_index()
|
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:
|
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
|
file_owner = info.get("owner") if info else None
|
||||||
_verify_upload_access(request, file_owner)
|
_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"
|
mime = (info or {}).get("mime") or _mt.guess_type(path)[0] or "application/octet-stream"
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
# Downscaled thumbnail for image previews — generated once and cached.
|
# 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/"):
|
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:
|
try:
|
||||||
from PIL import Image, ImageOps
|
thumb_path = confine(upload_root, thumb_path, allow_root=False)
|
||||||
thumb_dir = os.path.join(_upload_root(), ".thumbs")
|
except (PathEscape, ValueError):
|
||||||
os.makedirs(thumb_dir, exist_ok=True)
|
raise HTTPException(403, "Access denied")
|
||||||
thumb_path = os.path.join(thumb_dir, file_id + ".jpg")
|
if not thumb_path.startswith(thumb_dir + os.sep) or not _path_inside_upload_dir(thumb_path):
|
||||||
if (not os.path.exists(thumb_path)
|
raise HTTPException(403, "Access denied")
|
||||||
or os.path.getmtime(thumb_path) < os.path.getmtime(path)):
|
if request.method == "HEAD":
|
||||||
im = Image.open(path)
|
if (os.path.isfile(thumb_path)
|
||||||
# iPhone / camera JPEGs encode rotation in EXIF rather than
|
and os.path.getmtime(thumb_path) >= os.path.getmtime(path)):
|
||||||
# the pixel data. Browsers honour that on the original via
|
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
|
||||||
# image-orientation:from-image, but PIL strips EXIF when it
|
# GET may generate a JPEG or fall back to the original on failure.
|
||||||
# saves the JPEG thumb, leaving the pixels sideways. Bake
|
# Neither representation is known without decoding the image. HEAD
|
||||||
# the rotation into the pixels before thumbnailing.
|
# must not advertise the original/stale file's headers as a thumb.
|
||||||
im = ImageOps.exif_transpose(im)
|
from fastapi.responses import Response
|
||||||
im.thumbnail((320, 320))
|
response = Response(status_code=200, headers=UPLOAD_RESPONSE_HEADERS)
|
||||||
if im.mode not in ("RGB", "L"):
|
del response.headers["content-length"]
|
||||||
im = im.convert("RGB")
|
return response
|
||||||
im.save(thumb_path, "JPEG", quality=80)
|
else:
|
||||||
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
|
try:
|
||||||
except Exception as e:
|
from PIL import Image, ImageOps
|
||||||
logger.warning(f"Thumbnail generation failed for {file_id}: {e}")
|
os.makedirs(thumb_dir, exist_ok=True)
|
||||||
# Fall through to the full image.
|
if (not os.path.exists(thumb_path)
|
||||||
|
or os.path.getmtime(thumb_path) < os.path.getmtime(path)):
|
||||||
|
im = Image.open(path)
|
||||||
|
# iPhone / camera JPEGs encode rotation in EXIF rather than
|
||||||
|
# the pixel data. Browsers honour that on the original via
|
||||||
|
# image-orientation:from-image, but PIL strips EXIF when it
|
||||||
|
# saves the JPEG thumb, leaving the pixels sideways. Bake
|
||||||
|
# the rotation into the pixels before thumbnailing.
|
||||||
|
im = ImageOps.exif_transpose(im)
|
||||||
|
im.thumbnail((320, 320))
|
||||||
|
if im.mode not in ("RGB", "L"):
|
||||||
|
im = im.convert("RGB")
|
||||||
|
im.save(thumb_path, "JPEG", quality=80)
|
||||||
|
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Thumbnail generation failed for {file_id}: {e}")
|
||||||
|
# Fall through to the full image.
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
path,
|
path,
|
||||||
media_type=mime,
|
media_type=mime,
|
||||||
@@ -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)
|
return next((fi for fi in db.values() if fi.get("id") == file_id), None)
|
||||||
|
|
||||||
def _vision_cache_path(file_id: str) -> str:
|
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)
|
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:
|
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."""
|
"""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 "
|
"Overrides the source commit reported for runtime provenance, for builds "
|
||||||
"that ship without a git directory.",
|
"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.
|
album, and surfaces EXIF metadata.
|
||||||
|
|
||||||
odysseus-gallery list --limit 20 --favorites
|
odysseus-gallery list --limit 20 --favorites
|
||||||
|
odysseus-gallery list --tag "sunset,beach" # all tags, manual or AI
|
||||||
odysseus-gallery show IMAGE_ID
|
odysseus-gallery show IMAGE_ID
|
||||||
odysseus-gallery albums | jq -r '.[] | .name'
|
odysseus-gallery albums | jq -r '.[] | .name'
|
||||||
odysseus-gallery search "sunset" # matches prompt + tags
|
odysseus-gallery search "sunset" # matches prompt + tags
|
||||||
@@ -83,7 +84,13 @@ def cmd_list(args):
|
|||||||
fail(f"no album named {args.album!r}")
|
fail(f"no album named {args.album!r}")
|
||||||
q = q.filter(GalleryImage.album_id == al.id)
|
q = q.filter(GalleryImage.album_id == al.id)
|
||||||
if args.tag:
|
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)
|
q = q.order_by(GalleryImage.created_at.desc()).limit(args.limit)
|
||||||
emit([_serialize_image(i) for i in q.all()], args)
|
emit([_serialize_image(i) for i in q.all()], args)
|
||||||
finally:
|
finally:
|
||||||
@@ -159,7 +166,7 @@ def _build_parser():
|
|||||||
pl.add_argument("--limit", type=int, default=50)
|
pl.add_argument("--limit", type=int, default=50)
|
||||||
pl.add_argument("--favorites", action="store_true")
|
pl.add_argument("--favorites", action="store_true")
|
||||||
pl.add_argument("--album", help="filter by album name")
|
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)
|
pl.set_defaults(func=cmd_list)
|
||||||
|
|
||||||
psh = sub.add_parser("show", parents=[common])
|
psh = sub.add_parser("show", parents=[common])
|
||||||
|
|||||||
+12
-6
@@ -43,7 +43,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Anchor the path so the script works when invoked from anywhere.
|
# Anchor the path so the script works when invoked from anywhere.
|
||||||
@@ -136,7 +136,7 @@ def cmd_list(args) -> None:
|
|||||||
emit([], args)
|
emit([], args)
|
||||||
return
|
return
|
||||||
all_uids = data[0].split()
|
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]
|
uids = list(reversed(all_uids))[: args.limit]
|
||||||
out = []
|
out = []
|
||||||
for uid in uids:
|
for uid in uids:
|
||||||
@@ -165,10 +165,15 @@ def cmd_list(args) -> None:
|
|||||||
sender_name, sender_addr = parseaddr(sender)
|
sender_name, sender_addr = parseaddr(sender)
|
||||||
date_raw = msg.get("Date", "")
|
date_raw = msg.get("Date", "")
|
||||||
try:
|
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:
|
except Exception:
|
||||||
iso = ""
|
iso = ""
|
||||||
out.append({
|
date_epoch = float("-inf")
|
||||||
|
out.append((date_epoch, {
|
||||||
"uid": uid.decode(),
|
"uid": uid.decode(),
|
||||||
"date": iso,
|
"date": iso,
|
||||||
"from_addr": sender_addr,
|
"from_addr": sender_addr,
|
||||||
@@ -176,10 +181,11 @@ def cmd_list(args) -> None:
|
|||||||
"subject": subject,
|
"subject": subject,
|
||||||
"is_read": "\\Seen" in flags,
|
"is_read": "\\Seen" in flags,
|
||||||
"is_answered": "\\Answered" in flags,
|
"is_answered": "\\Answered" in flags,
|
||||||
})
|
}))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
sys.stderr.write(f"warn: skipping uid {uid!r}: {e}\n")
|
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 ────────────────────────────────────────────────────────────
|
# ─── 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
|
from urllib.request import urlopen
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
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():
|
def main():
|
||||||
@@ -36,7 +68,10 @@ def main():
|
|||||||
server = subprocess.Popen(
|
server = subprocess.Popen(
|
||||||
[sys.executable, "-m", "uvicorn", "app:app", "--host", "127.0.0.1", "--port", str(port)],
|
[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,
|
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
|
result = None
|
||||||
try:
|
try:
|
||||||
origin = f"http://127.0.0.1:{port}"
|
origin = f"http://127.0.0.1:{port}"
|
||||||
@@ -62,12 +97,7 @@ def main():
|
|||||||
print(result.stderr, file=sys.stderr)
|
print(result.stderr, file=sys.stderr)
|
||||||
return result.returncode
|
return result.returncode
|
||||||
finally:
|
finally:
|
||||||
server.terminate()
|
logs = _stop_owned_server(server, server_identity)
|
||||||
try:
|
|
||||||
logs, _ = server.communicate(timeout=10)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
server.kill()
|
|
||||||
logs, _ = server.communicate()
|
|
||||||
if result is None or result.returncode:
|
if result is None or result.returncode:
|
||||||
print(logs[-8000:], file=sys.stderr)
|
print(logs[-8000:], file=sys.stderr)
|
||||||
|
|
||||||
|
|||||||
@@ -202,17 +202,35 @@ def _infer_use_case(repo_id, pipeline_tag):
|
|||||||
return "general"
|
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):
|
def _entry_from_collection_item(collection, item, source):
|
||||||
repo_id = item.get("id") or ""
|
repo_id = item.get("id") or ""
|
||||||
if item.get("type") != "model" or not repo_id:
|
if item.get("type") != "model" or not repo_id:
|
||||||
return None
|
return None
|
||||||
|
if (item.get("pipeline_tag") or "").strip().lower() in _NON_LLM_PIPELINE_TAGS:
|
||||||
|
return None
|
||||||
repo_prefix = source.get("repo_prefix")
|
repo_prefix = source.get("repo_prefix")
|
||||||
if repo_prefix and not repo_id.startswith(repo_prefix):
|
if repo_prefix and not repo_id.startswith(repo_prefix):
|
||||||
return None
|
return None
|
||||||
raw_params = item.get("numParameters") or 0
|
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:
|
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)
|
param_label, raw_params = _format_params(raw_params)
|
||||||
if not raw_params:
|
if not raw_params:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from src.memory import MemoryStoreUnreadable
|
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"
|
"things the assistant said, one-off tasks, opinions on the current topic.\n\n"
|
||||||
"Rules:\n"
|
"Rules:\n"
|
||||||
"- MAX 2 facts per conversation — only the most important\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"
|
"- 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 a fact is similar to something likely already known, skip it\n"
|
||||||
"- If nothing durable was revealed, return []\n\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"
|
"Categories: 'identity', 'preference', 'fact', 'contact', 'project', 'goal'\n\n"
|
||||||
"Return ONLY valid JSON, no markdown fences."
|
"Return ONLY valid JSON, no markdown fences."
|
||||||
)
|
)
|
||||||
@@ -213,7 +216,10 @@ def _fallback_memory_candidates(messages) -> list[dict]:
|
|||||||
if not text:
|
if not text:
|
||||||
continue
|
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:
|
if m:
|
||||||
name = _clean_memory_value(m.group(1), 50)
|
name = _clean_memory_value(m.group(1), 50)
|
||||||
if name:
|
if name:
|
||||||
@@ -259,22 +265,183 @@ def _fallback_memory_candidates(messages) -> list[dict]:
|
|||||||
return candidates[:2]
|
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:
|
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)."""
|
"""Check if new_text states the same fact as an existing memory.
|
||||||
new_tokens = set(new_text.lower().split())
|
|
||||||
|
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:
|
if not new_tokens:
|
||||||
return False
|
return False
|
||||||
|
new_slot = _name_slot(new_text)
|
||||||
for entry in _memory_dicts(existing):
|
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:
|
if not old_tokens:
|
||||||
continue
|
continue
|
||||||
intersection = new_tokens & old_tokens
|
intersection = new_tokens & old_tokens
|
||||||
|
if not intersection:
|
||||||
|
continue
|
||||||
union = new_tokens | old_tokens
|
union = new_tokens | old_tokens
|
||||||
if len(intersection) / len(union) >= threshold:
|
if len(intersection) / len(union) >= threshold:
|
||||||
return True
|
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
|
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:
|
def _parse_extraction_json(raw: str) -> list:
|
||||||
"""Parse the extraction LLM's reply into a list of facts, tolerating
|
"""Parse the extraction LLM's reply into a list of facts, tolerating
|
||||||
reasoning-model noise.
|
reasoning-model noise.
|
||||||
@@ -420,8 +587,13 @@ async def extract_and_store(
|
|||||||
if not isinstance(facts, list):
|
if not isinstance(facts, list):
|
||||||
facts = []
|
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:
|
if fallback_facts:
|
||||||
facts = list(facts) + fallback_facts
|
facts.extend(fallback_facts)
|
||||||
|
|
||||||
if not facts:
|
if not facts:
|
||||||
logger.info("Auto memory extraction ran: 0 candidates")
|
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:
|
if not fact_text or len(fact_text) < 5:
|
||||||
continue
|
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.
|
# Dedup: check vector similarity first (fast), then exact text match.
|
||||||
# A runtime embedding/ChromaDB failure (backend OOM, model evicted,
|
# A runtime embedding/ChromaDB failure (backend OOM, model evicted,
|
||||||
# remote endpoint down) must not abort the whole batch — fall through
|
# 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
|
# silently dropped. Mirror the owner predicate used by the
|
||||||
# text dedup below; cross-tenant/stale matches fall through.
|
# text dedup below; cross-tenant/stale matches fall through.
|
||||||
_match = next((e for e in existing if e.get("id") == existing_id), None)
|
_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}")
|
logger.debug(f"Memory dedup (vector): '{fact_text[:50]}' matches {existing_id}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Text dedup fallback: exact match + fuzzy similarity
|
# 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):
|
if memory_manager.find_duplicates(fact_text, user_existing):
|
||||||
continue
|
continue
|
||||||
# Fuzzy text similarity check (catches rephrased duplicates when vector index is unavailable)
|
# 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*(.*)$")
|
_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()
|
raw = raw.strip()
|
||||||
if raw == "":
|
if raw == "":
|
||||||
return ""
|
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("]"):
|
if raw.startswith("[") and raw.endswith("]"):
|
||||||
inner = raw[1:-1].strip()
|
inner = raw[1:-1].strip()
|
||||||
if not inner:
|
if not inner:
|
||||||
@@ -126,10 +130,15 @@ def _parse_scalar(raw: str) -> Any:
|
|||||||
def _split_top_level(s: str, sep: str) -> List[str]:
|
def _split_top_level(s: str, sep: str) -> List[str]:
|
||||||
"""Split `s` on `sep` ignoring separators inside [] or quotes."""
|
"""Split `s` on `sep` ignoring separators inside [] or quotes."""
|
||||||
out, buf, depth, quote = [], [], 0, None
|
out, buf, depth, quote = [], [], 0, None
|
||||||
|
escaped = False
|
||||||
for ch in s:
|
for ch in s:
|
||||||
if quote:
|
if quote:
|
||||||
buf.append(ch)
|
buf.append(ch)
|
||||||
if ch == quote:
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
elif ch == "\\" and quote == '"':
|
||||||
|
escaped = True
|
||||||
|
elif ch == quote:
|
||||||
quote = None
|
quote = None
|
||||||
continue
|
continue
|
||||||
if ch in ("'", '"'):
|
if ch in ("'", '"'):
|
||||||
@@ -171,7 +180,7 @@ def parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
|
|||||||
pending_key = key
|
pending_key = key
|
||||||
fm[key] = []
|
fm[key] = []
|
||||||
else:
|
else:
|
||||||
fm[key] = _parse_scalar(val)
|
fm[key] = _parse_scalar(val, string_only=(key == "version"))
|
||||||
pending_key = None
|
pending_key = None
|
||||||
continue
|
continue
|
||||||
m2 = _FM_BLOCK_LIST_RE.match(line)
|
m2 = _FM_BLOCK_LIST_RE.match(line)
|
||||||
|
|||||||
@@ -500,6 +500,19 @@ class SkillsManager:
|
|||||||
sk.path = self._skill_file(cat, nm)
|
sk.path = self._skill_file(cat, nm)
|
||||||
return sk.to_dict()
|
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:
|
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
|
"""`skill_id` is the slug name. Allows updating any field plus
|
||||||
renames if `name` changes (file is moved on disk).
|
renames if `name` changes (file is moved on disk).
|
||||||
@@ -551,6 +564,9 @@ class SkillsManager:
|
|||||||
# Rename
|
# Rename
|
||||||
new_name = slugify(updates.get("name") or sk.name)
|
new_name = slugify(updates.get("name") or sk.name)
|
||||||
if new_name != 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
|
sk.name = new_name
|
||||||
|
|
||||||
# Write to potentially new path
|
# 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:
|
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")
|
start = match.start("path")
|
||||||
prefix = instruction[max(0, start - 80):start]
|
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:
|
def _workspace_path_identity(value: str) -> str:
|
||||||
|
|||||||
+393
-29
@@ -162,7 +162,7 @@ def _resolved_tool_call_id(
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_MODEL_TOOL_SURFACES = {"none", "compact", "full"}
|
_MODEL_TOOL_SURFACES = {"none", "compact", "full", "spec"}
|
||||||
_ROUTE_THINKING_MODES = {"auto", "on", "off"}
|
_ROUTE_THINKING_MODES = {"auto", "on", "off"}
|
||||||
_NO_THINKING_COMPACT_DOMAINS = {
|
_NO_THINKING_COMPACT_DOMAINS = {
|
||||||
"email",
|
"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):
|
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"})
|
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):
|
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):
|
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"})
|
return "manage_settings", json.dumps({"action": "disable_tool", "tool": "images"})
|
||||||
if listish and re.search(r"\bapi\s+tokens?\b|\btokens?\b", q):
|
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_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_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_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": """\
|
||||||
```manage_notes
|
```manage_notes
|
||||||
{"action": "add", "title": "<short todo>", "due_date": "<natural language or ISO datetime>"}
|
{"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
|
return is_api_model, is_ollama_native, ollama_openai_compat
|
||||||
|
|
||||||
|
|
||||||
def _configured_model_tool_surface(
|
def _configured_model_tool_surface_raw(
|
||||||
endpoint_url: str,
|
endpoint_url: str,
|
||||||
model: str,
|
model: str,
|
||||||
owner: Optional[str] = None,
|
owner: Optional[str] = None,
|
||||||
@@ -8561,6 +8561,19 @@ _ADMIN_KEYWORDS = [
|
|||||||
"note", "notes", "todo", "todos", "reminder", "reminders",
|
"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:
|
def _detect_admin_intent(messages: List[Dict]) -> bool:
|
||||||
"""Check if the last user message suggests admin/management tool usage."""
|
"""Check if the last user message suggests admin/management tool usage."""
|
||||||
for msg in reversed(messages):
|
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."""
|
"""Return a deterministic user-facing answer for tools we can render safely."""
|
||||||
tool_name = _resolved_tool_event_name(tool_event)
|
tool_name = _resolved_tool_event_name(tool_event)
|
||||||
output = str(tool_event.get("output") or "")
|
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 "")
|
command = str(tool_event.get("command") or "")
|
||||||
action = ""
|
action = ""
|
||||||
try:
|
try:
|
||||||
@@ -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
|
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_request_authority
|
||||||
@with_turn_contract
|
@with_turn_contract
|
||||||
@with_teacher_takeover
|
@with_teacher_takeover
|
||||||
@@ -21099,6 +21296,41 @@ async def stream_agent_loop(
|
|||||||
set(tool_policy.all_disabled_names()) if tool_policy else set()
|
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):
|
if is_compact_preview_contract(turn_contract):
|
||||||
from src.clean_agent_preview import stream_preview
|
from src.clean_agent_preview import stream_preview
|
||||||
async for chunk in stream_preview(
|
async for chunk in stream_preview(
|
||||||
@@ -23961,11 +24193,14 @@ async def stream_agent_loop(
|
|||||||
budget_is_explicit,
|
budget_is_explicit,
|
||||||
hard_max=hard_max,
|
hard_max=hard_max,
|
||||||
)
|
)
|
||||||
if candidate_context <= 8192:
|
if 0 < candidate_context <= 8192:
|
||||||
# Small local servers often tokenize chat wrappers and tool
|
# Small local servers often tokenize chat wrappers and tool
|
||||||
# results much more generously than our rough estimator. Keep
|
# results much more generously than our rough estimator. Keep
|
||||||
# substantial headroom for those wrappers and generation so a
|
# substantial headroom for those wrappers and generation so a
|
||||||
# follow-up tool round cannot exceed the server's n_ctx.
|
# 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 = min(
|
||||||
effective_budget,
|
effective_budget,
|
||||||
max(1200, int(candidate_context * 0.40)),
|
max(1200, int(candidate_context * 0.40)),
|
||||||
@@ -24126,6 +24361,15 @@ async def stream_agent_loop(
|
|||||||
headers=candidate_headers,
|
headers=candidate_headers,
|
||||||
endpoint_id=(route_descriptor or {}).get("endpoint_id"),
|
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 = (
|
textual_tools = (
|
||||||
force_textual_tool_transport
|
force_textual_tool_transport
|
||||||
or force_textual_tools
|
or force_textual_tools
|
||||||
@@ -24159,6 +24403,57 @@ async def stream_agent_loop(
|
|||||||
_last_user,
|
_last_user,
|
||||||
_intent_domains,
|
_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
|
# The compact router only receives user text, while native task
|
||||||
# inputs may be declared out-of-band by the runner. Reapply that
|
# inputs may be declared out-of-band by the runner. Reapply that
|
||||||
# concrete input contract after compaction so an implicit video
|
# 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")
|
_irrelevant_local_media_web_tools.add("private_browser")
|
||||||
route_tools.difference_update(_irrelevant_local_media_web_tools)
|
route_tools.difference_update(_irrelevant_local_media_web_tools)
|
||||||
if turn_contract is not None and tool_surface != "full":
|
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:
|
if prompt_compact:
|
||||||
route_tools.update(
|
route_tools.update(
|
||||||
_blocked_network_recovery_tools(tool_events)
|
_blocked_network_recovery_tools(tool_events)
|
||||||
@@ -24299,7 +24594,7 @@ async def stream_agent_loop(
|
|||||||
disabled_tools,
|
disabled_tools,
|
||||||
needs_admin=_needs_admin,
|
needs_admin=_needs_admin,
|
||||||
relevant_tools=prompt_route_tools,
|
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,
|
mcp_disabled_map=_mcp_disabled_map,
|
||||||
compact=prompt_compact,
|
compact=prompt_compact,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
@@ -24511,6 +24806,13 @@ async def stream_agent_loop(
|
|||||||
_prepend_agent_directive(route_messages, build_active_plan_note(approved_plan))
|
_prepend_agent_directive(route_messages, build_active_plan_note(approved_plan))
|
||||||
if guide_only:
|
if guide_only:
|
||||||
_prepend_agent_directive(route_messages, GUIDE_ONLY_DIRECTIVE)
|
_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 {
|
return {
|
||||||
"messages": route_messages,
|
"messages": route_messages,
|
||||||
"mcp_schemas": route_mcp_schemas,
|
"mcp_schemas": route_mcp_schemas,
|
||||||
@@ -24519,6 +24821,8 @@ async def stream_agent_loop(
|
|||||||
"is_ollama_native": is_native_ollama,
|
"is_ollama_native": is_native_ollama,
|
||||||
"ollama_openai_compat": is_ollama_compat,
|
"ollama_openai_compat": is_ollama_compat,
|
||||||
"tool_surface": tool_surface,
|
"tool_surface": tool_surface,
|
||||||
|
"spec_offered": _spec_offered,
|
||||||
|
"spec_surface": _spec_surface,
|
||||||
"ody_qwen_finetune_model": is_ody,
|
"ody_qwen_finetune_model": is_ody,
|
||||||
"qwen38_tool_router": _is_qwen38_tool_router(candidate_model) and not _full_inventory_mode,
|
"qwen38_tool_router": _is_qwen38_tool_router(candidate_model) and not _full_inventory_mode,
|
||||||
"ody_doc_finetune_mode": doc_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"))
|
if (schema.get("function", {}).get("name") or schema.get("name"))
|
||||||
not in disabled_tools
|
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))
|
return _filter_route_tool_schemas(_apply_tool_surface_to_schemas(contract_schemas, tool_surface))
|
||||||
if route_state["is_api_model"]:
|
if route_state["is_api_model"]:
|
||||||
if tool_surface == "full":
|
if tool_surface == "full":
|
||||||
@@ -25137,15 +25450,18 @@ async def stream_agent_loop(
|
|||||||
and schema.get("name") not in disabled_tools
|
and schema.get("name") not in disabled_tools
|
||||||
]
|
]
|
||||||
if _pure_web_turn and tool_surface != "full":
|
if _pure_web_turn and tool_surface != "full":
|
||||||
allowed = _web_only_route_tools(_last_user, disabled_tools)
|
strict_spec = (route_state.get("spec_surface") and
|
||||||
schemas = [
|
os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict")
|
||||||
schema for schema in schemas
|
if not strict_spec:
|
||||||
if (
|
allowed = _web_only_route_tools(_last_user, disabled_tools)
|
||||||
schema.get("function", {}).get("name")
|
schemas = [
|
||||||
or schema.get("name")
|
schema for schema in schemas
|
||||||
) in allowed
|
if (
|
||||||
or schema.get("function", {}).get("name") in external_names
|
schema.get("function", {}).get("name")
|
||||||
]
|
or schema.get("name")
|
||||||
|
) in allowed
|
||||||
|
or schema.get("function", {}).get("name") in external_names
|
||||||
|
]
|
||||||
schemas = _drop_legacy_email_alias_schemas_when_mcp_available(schemas)
|
schemas = _drop_legacy_email_alias_schemas_when_mcp_available(schemas)
|
||||||
schemas = _apply_tool_surface_to_schemas(schemas, tool_surface)
|
schemas = _apply_tool_surface_to_schemas(schemas, tool_surface)
|
||||||
if (
|
if (
|
||||||
@@ -25997,6 +26313,8 @@ async def stream_agent_loop(
|
|||||||
"is_ollama_native": _is_ollama_native,
|
"is_ollama_native": _is_ollama_native,
|
||||||
"ollama_openai_compat": _ollama_openai_compat,
|
"ollama_openai_compat": _ollama_openai_compat,
|
||||||
"tool_surface": _route_state.get("tool_surface", ""),
|
"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,
|
"ody_qwen_finetune_model": _ody_qwen_finetune_model,
|
||||||
"qwen38_tool_router": _qwen38_tool_router,
|
"qwen38_tool_router": _qwen38_tool_router,
|
||||||
"ody_doc_finetune_mode": _ody_doc_finetune_mode,
|
"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
|
_active_route_state["request_messages"] = _initial_route_request_messages
|
||||||
all_tool_schemas = _tool_schemas_for_route(_active_route_state)
|
all_tool_schemas = _tool_schemas_for_route(_active_route_state)
|
||||||
if turn_contract is not None:
|
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)
|
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")]
|
_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'}")
|
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)
|
_routing_intentional_exclusions = set(disabled_tools)
|
||||||
if (
|
if (
|
||||||
@@ -27509,16 +27836,25 @@ async def stream_agent_loop(
|
|||||||
if _streamed_tool_markup or _streamed_tool_markup_starts(_delta_text):
|
if _streamed_tool_markup or _streamed_tool_markup_starts(_delta_text):
|
||||||
_streamed_tool_markup += _delta_text
|
_streamed_tool_markup += _delta_text
|
||||||
if not _streamed_tool_markup_complete(_streamed_tool_markup):
|
if not _streamed_tool_markup_complete(_streamed_tool_markup):
|
||||||
continue
|
if _streamed_tool_markup_starts(_streamed_tool_markup):
|
||||||
_visible_markup_tail = strip_tool_blocks(
|
continue
|
||||||
_streamed_tool_markup,
|
# False alarm: a bare "<" (e.g. ``x < 2``) looked like
|
||||||
skip_fenced=True,
|
# a tag prefix, but the buffer can no longer become a
|
||||||
).strip()
|
# tool tag. Release it as prose instead of holding the
|
||||||
_streamed_tool_markup = ""
|
# rest of the answer forever.
|
||||||
if not _visible_markup_tail:
|
_delta_text = _streamed_tool_markup
|
||||||
continue
|
_streamed_tool_markup = ""
|
||||||
_delta_text = _visible_markup_tail
|
data["delta"] = _delta_text
|
||||||
data["delta"] = _delta_text
|
else:
|
||||||
|
_visible_markup_tail = strip_tool_blocks(
|
||||||
|
_streamed_tool_markup,
|
||||||
|
skip_fenced=True,
|
||||||
|
).strip()
|
||||||
|
_streamed_tool_markup = ""
|
||||||
|
if not _visible_markup_tail:
|
||||||
|
continue
|
||||||
|
_delta_text = _visible_markup_tail
|
||||||
|
data["delta"] = _delta_text
|
||||||
full_response += _delta_text
|
full_response += _delta_text
|
||||||
if not _suppress_unavailable_web_delta:
|
if not _suppress_unavailable_web_delta:
|
||||||
_qwen_buffered_route = bool(
|
_qwen_buffered_route = bool(
|
||||||
@@ -27605,7 +27941,11 @@ async def stream_agent_loop(
|
|||||||
),
|
),
|
||||||
active_document=active_document,
|
active_document=active_document,
|
||||||
last_user=_last_user,
|
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,
|
recover_unoffered_tool_names=_recover_unoffered_local_tools,
|
||||||
passthrough_tool_names={
|
passthrough_tool_names={
|
||||||
schema["function"]["name"]
|
schema["function"]["name"]
|
||||||
@@ -32169,6 +32509,29 @@ async def stream_agent_loop(
|
|||||||
budget_hit = False
|
budget_hit = False
|
||||||
local_network_budget_hit = False
|
local_network_budget_hit = False
|
||||||
local_inspection_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):
|
for i, block in enumerate(tool_blocks):
|
||||||
native_call = converted_calls[i] if i < len(converted_calls) else None
|
native_call = converted_calls[i] if i < len(converted_calls) else None
|
||||||
tool_call_id = _resolved_tool_call_id(
|
tool_call_id = _resolved_tool_call_id(
|
||||||
@@ -33463,7 +33826,8 @@ async def stream_agent_loop(
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
if (
|
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 not _artifact_creation_requested
|
||||||
and (block.tool_type == "web_search" or _web_execution_budget.searches)
|
and (block.tool_type == "web_search" or _web_execution_budget.searches)
|
||||||
and not _web_execution_budget.admit(
|
and not _web_execution_budget.admit(
|
||||||
|
|||||||
@@ -230,6 +230,18 @@ def classify(capture: DispatchCapture) -> dict[str, Any] | None:
|
|||||||
if capture.backend is not None:
|
if capture.backend is not None:
|
||||||
from src.agent_runtime.resources import ExternalResource
|
from src.agent_runtime.resources import ExternalResource
|
||||||
if isinstance(capture.backend.resource, 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
|
external = True
|
||||||
impact = (*impact, resource_ref(capture.backend.resource, "backend"))
|
impact = (*impact, resource_ref(capture.backend.resource, "backend"))
|
||||||
return {"impact_scope": impact, "dependencies": dependencies, "obligations": obligations, "external": external}
|
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 .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
|
||||||
from .bg_job_tools import ManageBgJobsTool
|
from .bg_job_tools import ManageBgJobsTool
|
||||||
from .session_tools import CreateSessionTool, ListSessionsTool, SendToSessionTool, ManageSessionTool
|
from .session_tools import CreateSessionTool, ListSessionsTool, SendToSessionTool, ManageSessionTool
|
||||||
|
from .editor_tools import PlanEditTool, RunEditorCommandTool, GetEditorScreenshotTool, AiEditImageTool
|
||||||
from .admin_tools import (
|
from .admin_tools import (
|
||||||
ADMIN_TOOL_HANDLERS,
|
ADMIN_TOOL_HANDLERS,
|
||||||
do_manage_endpoints, do_manage_mcp, do_manage_webhooks,
|
do_manage_endpoints, do_manage_mcp, do_manage_webhooks,
|
||||||
@@ -72,6 +73,10 @@ TOOL_HANDLERS = {
|
|||||||
"list_sessions": ListSessionsTool().execute,
|
"list_sessions": ListSessionsTool().execute,
|
||||||
"send_to_session": SendToSessionTool().execute,
|
"send_to_session": SendToSessionTool().execute,
|
||||||
"manage_session": ManageSessionTool().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).
|
# Config/integration admin tools (manage_endpoints/mcp/webhooks/tokens/settings).
|
||||||
TOOL_HANDLERS.update(ADMIN_TOOL_HANDLERS)
|
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())
|
return _ALIASES_SET.get(k2, (k or "").strip())
|
||||||
|
|
||||||
def _is_managed_key(key):
|
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 = {
|
_ENUMS = {
|
||||||
"image_quality": ["low", "medium", "high"],
|
"image_quality": ["low", "medium", "high"],
|
||||||
@@ -713,13 +715,35 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
save_settings(s)
|
save_settings(s)
|
||||||
return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0}
|
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
|
# Tool-toggle actions. These edit settings.json:disabled_tools
|
||||||
# (the global list read on every chat request) rather than
|
# (the global list read on every chat request) rather than
|
||||||
# prefs.json. Friendly aliases accepted: "shell" -> "bash",
|
# prefs.json. Friendly aliases accepted: "shell" -> "bash",
|
||||||
# "search" -> "web_search", "browser" -> browser tools,
|
# "search" -> "web_search", "browser" -> browser tools,
|
||||||
# "documents" -> the document tool set, "memory" ->
|
# "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
|
from src.settings import get_setting, save_settings, load_settings
|
||||||
_ALIASES = {
|
_ALIASES = {
|
||||||
"shell": ["bash"],
|
"shell": ["bash"],
|
||||||
@@ -753,7 +777,8 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
"response": (
|
"response": (
|
||||||
f"Currently disabled: {', '.join(current) if current else '(none)'}.\n"
|
f"Currently disabled: {', '.join(current) if current else '(none)'}.\n"
|
||||||
"Common toggles: shell (bash), search (web_search), browser, documents, "
|
"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),
|
"disabled": list(current),
|
||||||
"exit_code": 0,
|
"exit_code": 0,
|
||||||
@@ -767,22 +792,19 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
current = list(settings.get("disabled_tools") or [])
|
current = list(settings.get("disabled_tools") or [])
|
||||||
before = set(current)
|
before = set(current)
|
||||||
if action == "disable_tool":
|
for t in targets:
|
||||||
for t in targets:
|
if t not in current:
|
||||||
if t not in current:
|
current.append(t)
|
||||||
current.append(t)
|
|
||||||
else: # enable_tool
|
|
||||||
current = [t for t in current if t not in targets]
|
|
||||||
after = set(current)
|
after = set(current)
|
||||||
settings["disabled_tools"] = current
|
settings["disabled_tools"] = current
|
||||||
save_settings(settings)
|
save_settings(settings)
|
||||||
|
|
||||||
verb = "Disabled" if action == "disable_tool" else "Enabled"
|
|
||||||
changed = sorted(after.symmetric_difference(before))
|
changed = sorted(after.symmetric_difference(before))
|
||||||
return {
|
return {
|
||||||
"response": (
|
"response": (
|
||||||
f"{verb} {tool_name} ({', '.join(targets)}). "
|
f"Disabled {tool_name} ({', '.join(targets)}). "
|
||||||
f"Now disabled: {', '.join(current) if current else '(none)'}."
|
f"Now disabled: {', '.join(current) if current else '(none)'}. "
|
||||||
|
"Re-enabling is done by the user in Settings > Agent Tools."
|
||||||
),
|
),
|
||||||
"changed": changed,
|
"changed": changed,
|
||||||
"disabled": list(current),
|
"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
+206
-41
@@ -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
|
# served internal id. Include pinned/cached ids in the match set
|
||||||
# so chat sessions using the HF repo id still resolve. Do not use
|
# so chat sessions using the HF repo id still resolve. Do not use
|
||||||
# stale cached aliases when the endpoint itself is unreachable.
|
# stale cached aliases when the endpoint itself is unreachable.
|
||||||
if model_type == "image" and endpoint_reachable:
|
if endpoint_reachable:
|
||||||
for extra in _json_list(getattr(ep, "pinned_models", None)) + _json_list(getattr(ep, "cached_models", None)):
|
# 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:
|
if extra not in model_ids:
|
||||||
model_ids.append(extra)
|
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":
|
elif action == "open_panel":
|
||||||
# Open a top-level panel/modal: documents/library, gallery,
|
# 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 ""
|
panel = parts[1].lower() if len(parts) > 1 else ""
|
||||||
view = ""
|
view = ""
|
||||||
view_label = ""
|
view_label = ""
|
||||||
@@ -993,6 +998,8 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
|||||||
"note": "notes",
|
"note": "notes",
|
||||||
"todo": "notes",
|
"todo": "notes",
|
||||||
"todos": "notes",
|
"todos": "notes",
|
||||||
|
"tasks": "tasks",
|
||||||
|
"task": "tasks",
|
||||||
"memories": "memories",
|
"memories": "memories",
|
||||||
"memory": "memories",
|
"memory": "memories",
|
||||||
"brain": "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)
|
target = _panel_aliases.get(panel)
|
||||||
if not target:
|
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":
|
if target == "cookbook":
|
||||||
cookbook_views = {
|
cookbook_views = {
|
||||||
"models": ("Search", "models"), "model": ("Search", "models"),
|
"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
|
# 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).
|
"""Generate an image using an image-capable model (e.g. gpt-image-1).
|
||||||
|
|
||||||
Content format:
|
Content format:
|
||||||
Line 1: prompt describing the image
|
Line 1: prompt describing the image
|
||||||
Line 2: model name (optional, default auto-detects: prefers gpt-image-1.5 > gpt-image-1)
|
Line 2: model name (optional, default auto-detects: prefers gpt-image-1.5 > gpt-image-1)
|
||||||
Line 3: size (optional, defaults to 1024x1024)
|
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 base64
|
||||||
import httpx
|
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()
|
prompt = str(args.get('prompt') or '').strip()
|
||||||
model_spec = str(args.get('model') or '').strip()
|
model_spec = str(args.get('model') or '').strip()
|
||||||
size = str(args.get('size') or '1024x1024')
|
size = str(args.get('size') or '1024x1024')
|
||||||
quality = str(args.get('quality') or 'medium')
|
quality = str(args.get('quality') or '')
|
||||||
else:
|
else:
|
||||||
lines = content.strip().split("\n")
|
lines = content.strip().split("\n")
|
||||||
prompt = lines[0].strip() if lines else ""
|
prompt = lines[0].strip() if lines else ""
|
||||||
model_spec = lines[1].strip() if len(lines) > 1 and lines[1].strip() 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"
|
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:
|
if not prompt:
|
||||||
return {"error": "Image prompt is required (line 1)"}
|
return {"error": "Image prompt is required (line 1)"}
|
||||||
|
|
||||||
# Load admin settings for defaults
|
# Load settings for defaults
|
||||||
try:
|
try:
|
||||||
from src.settings import load_settings
|
from src.settings import get_user_setting, load_settings
|
||||||
_settings = load_settings()
|
_settings = load_settings()
|
||||||
except Exception:
|
except Exception:
|
||||||
_settings = {}
|
_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):
|
if not _settings.get("image_gen_enabled", True):
|
||||||
return {"error": "Image generation is disabled by the administrator."}
|
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:
|
if not model_spec:
|
||||||
model_spec = _settings.get("image_model", "")
|
model_spec = get_user_setting("image_model", owner=owner, default="") or ""
|
||||||
if quality == "medium" and _settings.get("image_quality"):
|
if not quality:
|
||||||
quality = _settings["image_quality"]
|
quality = get_user_setting("image_quality", owner=owner, default="medium") or "medium"
|
||||||
|
|
||||||
# Auto-detect best available image model if still not set
|
# Auto-detect best available image model if still not set
|
||||||
if not model_spec:
|
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):
|
async def _resolve_image_model(model_name: str):
|
||||||
def _call():
|
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:
|
try:
|
||||||
return _resolve_model(model_name, owner=owner, model_type="image")
|
return _resolve_model(model_name, owner=owner, model_type="image")
|
||||||
except TypeError as exc:
|
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("/")
|
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
|
||||||
images_url = base_url + "/images/generations"
|
images_url = base_url + "/images/generations"
|
||||||
from src.model_capability_readers.base import detect_vendor
|
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":
|
if detect_vendor(url) == "openrouter":
|
||||||
images_url = base_url + "/images"
|
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)
|
# Validate size for cloud image models (local diffusion accepts any WxH)
|
||||||
valid_gpt_sizes = {"1024x1024", "1024x1536", "1536x1024", "auto"}
|
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,
|
"n": 1,
|
||||||
"size": size,
|
"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
|
# GPT image models and local diffusion support quality; DALL-E does not
|
||||||
if is_gpt_image or is_local_diffusion:
|
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:
|
else:
|
||||||
payload["quality"] = "medium"
|
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:
|
try:
|
||||||
# GPT image models can take 30-120s+ depending on quality
|
# 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:
|
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:
|
if resp.status_code != 200:
|
||||||
error_text = resp.text[:500]
|
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()
|
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:
|
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]
|
img = images[0]
|
||||||
image_url = None
|
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}")
|
logger.warning(f"Failed to save gallery record: {_ge}")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# GPT image models always return b64_json; DALL-E may return url
|
def _store_image(image_bytes):
|
||||||
if img.get("b64_json"):
|
if incognito:
|
||||||
|
return 'data:image/png;base64,' + base64.b64encode(image_bytes).decode('ascii'), None
|
||||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||||
img_dir.mkdir(parents=True, exist_ok=True)
|
img_dir.mkdir(parents=True, exist_ok=True)
|
||||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||||
img_path = img_dir / filename
|
img_path = img_dir / filename
|
||||||
img_path.write_bytes(base64.b64decode(img.get("b64_json")))
|
img_path.write_bytes(image_bytes)
|
||||||
if owner:
|
if owner:
|
||||||
from src.generated_images import register_generated_image
|
from src.generated_images import register_generated_image
|
||||||
register_generated_image(filename, owner)
|
register_generated_image(filename, owner)
|
||||||
image_url = f"/api/generated-image/{filename}"
|
return f"/api/generated-image/{filename}", _save_to_gallery(filename)
|
||||||
image_id = _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"):
|
elif img.get("url"):
|
||||||
# Download external URL and save locally (DALL-E returns temp URLs)
|
# 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:
|
try:
|
||||||
dl_resp = httpx.get(result_url, timeout=60)
|
dl_resp = httpx.get(result_url, timeout=60)
|
||||||
if dl_resp.status_code == 200:
|
if dl_resp.status_code == 200:
|
||||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
image_url, image_id = _store_image(dl_resp.content)
|
||||||
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)
|
|
||||||
else:
|
else:
|
||||||
image_url = result_url # fallback to external URL
|
image_url = result_url # fallback to external URL
|
||||||
except Exception as _dl_e:
|
except Exception as _dl_e:
|
||||||
@@ -1434,8 +1555,9 @@ async def do_edit_image(
|
|||||||
session_id: Optional[str] = None,
|
session_id: Optional[str] = None,
|
||||||
owner: Optional[str] = None,
|
owner: Optional[str] = None,
|
||||||
size: str = "auto",
|
size: str = "auto",
|
||||||
quality: str = "medium",
|
quality: Optional[str] = None,
|
||||||
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
||||||
|
incognito: bool = False,
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""Edit an uploaded image using the configured image endpoint."""
|
"""Edit an uploaded image using the configured image endpoint."""
|
||||||
import base64
|
import base64
|
||||||
@@ -1453,21 +1575,27 @@ async def do_edit_image(
|
|||||||
return {"error": "Attached image file was not found"}
|
return {"error": "Attached image file was not found"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from src.settings import load_settings
|
from src.settings import get_user_setting, load_settings
|
||||||
_settings = load_settings()
|
_settings = load_settings()
|
||||||
except Exception:
|
except Exception:
|
||||||
_settings = {}
|
_settings = {}
|
||||||
|
def get_user_setting(key, owner="", default=None):
|
||||||
|
return default
|
||||||
|
|
||||||
if not model_spec:
|
if not model_spec:
|
||||||
model_spec = _settings.get("image_model", "")
|
model_spec = get_user_setting("image_edit_model", owner=owner) or get_user_setting("image_model", owner=owner, default="")
|
||||||
if quality == "medium" and _settings.get("image_quality"):
|
if quality is None:
|
||||||
quality = _settings["image_quality"]
|
quality = get_user_setting("image_quality", owner=owner, default="medium") or "medium"
|
||||||
if not model_spec:
|
if not model_spec:
|
||||||
return {"error": "No image model selected for image editing"}
|
return {"error": "No image model selected for image editing"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
def _call():
|
def _call():
|
||||||
|
try:
|
||||||
|
return _resolve_model(model_spec, owner=owner)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
return _resolve_model(model_spec, owner=owner, model_type="image")
|
return _resolve_model(model_spec, owner=owner, model_type="image")
|
||||||
except TypeError as exc:
|
except TypeError as exc:
|
||||||
@@ -1480,6 +1608,7 @@ async def do_edit_image(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return {"error": f"No endpoint found with image model '{model_spec}'."}
|
return {"error": f"No endpoint found with image model '{model_spec}'."}
|
||||||
|
|
||||||
|
requested_edit_size = size
|
||||||
if not size or size == "auto":
|
if not size or size == "auto":
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from src.image_model_ids import image_edit_size
|
from src.image_model_ids import image_edit_size
|
||||||
@@ -1509,7 +1638,7 @@ async def do_edit_image(
|
|||||||
request_id = uuid.uuid4().hex
|
request_id = uuid.uuid4().hex
|
||||||
payload["request_id"] = request_id
|
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:
|
def _save_edited_image_to_gallery(filename: str) -> str:
|
||||||
try:
|
try:
|
||||||
@@ -1539,6 +1668,9 @@ async def do_edit_image(
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
with Image.open(BytesIO(image_bytes)) as output:
|
with Image.open(BytesIO(image_bytes)) as output:
|
||||||
size = f"{output.width}x{output.height}"
|
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 = Path(GENERATED_IMAGES_DIR)
|
||||||
img_dir.mkdir(parents=True, exist_ok=True)
|
img_dir.mkdir(parents=True, exist_ok=True)
|
||||||
filename = f"{uuid.uuid4().hex[:12]}{suffix}"
|
filename = f"{uuid.uuid4().hex[:12]}{suffix}"
|
||||||
@@ -1652,7 +1784,35 @@ async def do_edit_image(
|
|||||||
"image_url": {"url": f"data:{mime};base64,{image_b64}"},
|
"image_url": {"url": f"data:{mime};base64,{image_b64}"},
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
resp = await client.post(base_url + "/images", json=edit_payload, headers=headers)
|
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:
|
else:
|
||||||
with path.open("rb") as f:
|
with path.open("rb") as f:
|
||||||
files = {"image": (path.name, f, mime)}
|
files = {"image": (path.name, f, mime)}
|
||||||
@@ -1695,7 +1855,12 @@ async def do_edit_image(
|
|||||||
}
|
}
|
||||||
|
|
||||||
data = resp.json()
|
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:
|
if not images:
|
||||||
return {"error": "No image returned from edit API"}
|
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.
|
# Snapshot text already contains labels and refs in DOM order.
|
||||||
# The refs mapping repeats them and buries menus in raw JSON.
|
# The refs mapping repeats them and buries menus in raw JSON.
|
||||||
lines = [line for line in snapshot.splitlines()
|
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))
|
pages.append(('URL: ' + str(url) + '\n' if url else '') + '\n'.join(lines))
|
||||||
elif url:
|
elif url:
|
||||||
notices.append('URL: ' + str(url))
|
notices.append('URL: ' + str(url))
|
||||||
@@ -37,7 +37,8 @@ def compact_browser_observation(value, budget=8000):
|
|||||||
parsed = json.loads(item)
|
parsed = json.loads(item)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
# CLI status text precedes the JSON post-interaction state.
|
# 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:
|
if len(parts) > 1:
|
||||||
for part in parts:
|
for part in parts:
|
||||||
visit(part)
|
visit(part)
|
||||||
|
|||||||
+141
-5
@@ -10,7 +10,7 @@ import os
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
from src.auth_helpers import owner_filter
|
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
|
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]:
|
async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||||
"""Build a short morning digest: today's calendar events, unread email count
|
"""Build a short morning digest: today's calendar events, unread email count
|
||||||
+ top-N senders/subjects, active todos."""
|
+ 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 core.database import SessionLocal, CalendarEvent, CalendarCal, Note
|
||||||
from routes.email_helpers import _imap_connect, _decode_header
|
from routes.email_helpers import _imap_connect, _decode_header
|
||||||
|
from sqlalchemy import and_, or_
|
||||||
|
|
||||||
# ----- Calendar: today's events -----
|
# ----- Calendar: today's events -----
|
||||||
today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
tomorrow = today + _td(days=1)
|
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
|
# v2 review HIGH-12: gate the OR-null branch on single-user
|
||||||
# (unconfigured) deploys only. In a multi-user deploy, one
|
# (unconfigured) deploys only. In a multi-user deploy, one
|
||||||
# user's daily brief must not include another user's notes or
|
# 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()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ev_q = db.query(CalendarEvent).join(CalendarCal).filter(
|
ev_q = db.query(CalendarEvent).join(CalendarCal).filter(
|
||||||
CalendarEvent.dtstart < tomorrow,
|
or_(
|
||||||
CalendarEvent.dtend > today,
|
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",
|
CalendarEvent.status != "cancelled",
|
||||||
)
|
)
|
||||||
if owner:
|
if owner:
|
||||||
ev_q = owner_filter(ev_q, CalendarCal, owner, include_shared=_allow_null)
|
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 -----
|
# ----- Notes: pinned + non-archived todos with at least one undone item -----
|
||||||
n_q = db.query(Note).filter(Note.archived == False) # noqa: E712
|
n_q = db.query(Note).filter(Note.archived == False) # noqa: E712
|
||||||
if owner:
|
if owner:
|
||||||
@@ -2058,7 +2090,7 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
if events:
|
if events:
|
||||||
plain.append("Calendar:")
|
plain.append("Calendar:")
|
||||||
for e in events:
|
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 ""
|
loc = f" @ {e.location}" if e.location else ""
|
||||||
plain.append(f" {t} {e.summary}{loc}")
|
plain.append(f" {t} {e.summary}{loc}")
|
||||||
plain.append("")
|
plain.append("")
|
||||||
@@ -2465,6 +2497,100 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
return str(e), False
|
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]:
|
async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||||
"""Scan unread emails across all accounts, LLM-triage new ones, cache
|
"""Scan unread emails across all accounts, LLM-triage new ones, cache
|
||||||
per-UID verdicts, tag the inbox, and fire a reminder when a previously
|
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
|
per_uid_scores[key] = verdict
|
||||||
saved_classifications += 1
|
saved_classifications += 1
|
||||||
continue
|
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-classify. JSON-only response; bullet-proof parse.
|
||||||
llm_attempts += 1
|
llm_attempts += 1
|
||||||
prompt = (
|
prompt = (
|
||||||
|
|||||||
+12
-1
@@ -211,10 +211,15 @@ class ChatHandler:
|
|||||||
from src.settings import get_setting
|
from src.settings import get_setting
|
||||||
vision_enabled = get_setting("vision_enabled", True)
|
vision_enabled = get_setting("vision_enabled", True)
|
||||||
if vision_enabled:
|
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(
|
main_is_vision = await asyncio.to_thread(
|
||||||
model_supports_vision,
|
model_supports_vision,
|
||||||
sess.model or "",
|
sess.model or "",
|
||||||
getattr(sess, "endpoint_url", "") or "",
|
getattr(sess, "endpoint_url", "") or "",
|
||||||
|
sess_owner,
|
||||||
)
|
)
|
||||||
|
|
||||||
if effective_att_ids and vision_enabled:
|
if effective_att_ids and vision_enabled:
|
||||||
@@ -277,7 +282,13 @@ class ChatHandler:
|
|||||||
_sync_upload_vision_to_gallery(file_info, owner, vl_desc)
|
_sync_upload_vision_to_gallery(file_info, owner, vl_desc)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
# Surface the description to the client live so it renders as a
|
||||||
# collapsible "image description" on the user bubble (not just
|
# collapsible "image description" on the user bubble (not just
|
||||||
# after a refresh that re-parses the stored message).
|
# after a refresh that re-parses the stored message).
|
||||||
|
|||||||
+328
-21
@@ -5,9 +5,14 @@ import re
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
import socket
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import httpx
|
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 urllib.parse import urlparse
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from fastapi import UploadFile
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_ORIGINAL_HTTPX_GET = httpx.get
|
||||||
|
_ORIGINAL_HTTPX_POST = httpx.post
|
||||||
|
|
||||||
|
|
||||||
def extract_urls(text: str) -> List[str]:
|
def extract_urls(text: str) -> List[str]:
|
||||||
"""Extract URLs from text using regex pattern."""
|
"""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.
|
drops the image entirely. See issue #124.
|
||||||
"""
|
"""
|
||||||
m = (model_name or "").lower()
|
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):
|
if any(kw in m for kw in _VISION_MODEL_KEYWORDS):
|
||||||
return True
|
return True
|
||||||
return bool(_VISION_VL_RE.search(m))
|
return bool(_VISION_VL_RE.search(m))
|
||||||
@@ -91,23 +109,167 @@ _PROVIDER_FINGERPRINT_TTL = 60.0
|
|||||||
_lmstudio_models_cache: dict = {}
|
_lmstudio_models_cache: dict = {}
|
||||||
|
|
||||||
|
|
||||||
def _is_local_host(host: Optional[str]) -> bool:
|
_BLOCKED_METADATA_IPS = {
|
||||||
"""True for loopback/LAN/Tailscale hosts (never public domains)."""
|
ipaddress.ip_address("169.254.169.254"),
|
||||||
host = (host or "").lower()
|
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:
|
if not host:
|
||||||
return False
|
return False
|
||||||
if host in {"localhost", "host.docker.internal"} or host.endswith(".local"):
|
# Strip optional IPv6 square brackets: [::1] -> ::1
|
||||||
return True
|
literal = host[1:-1] if host.startswith("[") and host.endswith("]") else host
|
||||||
try:
|
try:
|
||||||
ip = ipaddress.ip_address(host)
|
ip = ipaddress.ip_address(literal)
|
||||||
|
return _is_ip_safe_local(ip)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return "." not in host
|
pass
|
||||||
if ip.is_loopback or ip.is_private or ip.is_link_local:
|
|
||||||
return True
|
allowed_names = {"localhost", "host.docker.internal"}
|
||||||
return ip in ipaddress.ip_network("100.64.0.0/10")
|
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
|
"""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)."""
|
isn't LM Studio or is unreachable (short-TTL cached; transient errors uncached)."""
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
@@ -117,10 +279,10 @@ def _probe_lmstudio_models(url: str) -> Optional[list]:
|
|||||||
cached = _lmstudio_models_cache.get(key)
|
cached = _lmstudio_models_cache.get(key)
|
||||||
if cached is not None and cached[1] > now:
|
if cached is not None and cached[1] > now:
|
||||||
return cached[0]
|
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"
|
probe_url = f"{parsed.scheme or 'http'}://{authority}/api/v1/models"
|
||||||
try:
|
try:
|
||||||
r = httpx.get(probe_url, timeout=1.0)
|
r = _safe_probe_get(probe_url, headers=headers, timeout=1.0)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -138,15 +300,15 @@ def _probe_lmstudio_models(url: str) -> Optional[list]:
|
|||||||
return models
|
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
|
"""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)."""
|
endpoint isn't LM Studio or doesn't report it (so callers fall back)."""
|
||||||
if not model:
|
if not model:
|
||||||
return None
|
return None
|
||||||
# Never probe a remote provider; LM Studio is always a local/LAN host.
|
# Never probe a remote provider or disallowed host; LM Studio is always a local/LAN host.
|
||||||
if not _is_local_host(urlparse(url).hostname):
|
if not _is_safe_local_host(urlparse(url).hostname):
|
||||||
return None
|
return None
|
||||||
models = _probe_lmstudio_models(url)
|
models = _probe_lmstudio_models(url, headers=headers)
|
||||||
if not models:
|
if not models:
|
||||||
return None
|
return None
|
||||||
want = model.strip().lower()
|
want = model.strip().lower()
|
||||||
@@ -162,20 +324,165 @@ def lmstudio_supports_vision(url: str, model: str) -> Optional[bool]:
|
|||||||
return None
|
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
|
"""Whether a model accepts images, using the endpoint's reported
|
||||||
capability when available (LM Studio) and falling back to name-based
|
capability when available (LM Studio), a cached one-time probe for local
|
||||||
detection otherwise."""
|
OpenAI-compatible endpoints, and name-based detection otherwise."""
|
||||||
|
if is_vision_model(model_name):
|
||||||
|
return True
|
||||||
if endpoint_url:
|
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:
|
||||||
|
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:
|
try:
|
||||||
advertised = lmstudio_supports_vision(endpoint_url, model_name or "")
|
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:
|
except Exception:
|
||||||
advertised = None
|
advertised = None
|
||||||
if advertised is not None:
|
if advertised is not None:
|
||||||
return advertised
|
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)
|
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:
|
def validate_message(message: str) -> str:
|
||||||
"""Validate message input."""
|
"""Validate message input."""
|
||||||
if not message:
|
if not message:
|
||||||
|
|||||||
+689
-22
@@ -20,7 +20,11 @@ from pathlib import Path
|
|||||||
import httpx
|
import httpx
|
||||||
import jsonschema
|
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.agent_runtime.runtime_selection import COMPACT_PREVIEW_MODE
|
||||||
from src import agent_runs
|
from src import agent_runs
|
||||||
from src.agent_evidence import command_has_mutation_effect, workspace_artifact_is_usable
|
from src.agent_evidence import command_has_mutation_effect, workspace_artifact_is_usable
|
||||||
@@ -42,6 +46,7 @@ from src.turn_contract import (
|
|||||||
_REQUEST_PREFIX,
|
_REQUEST_PREFIX,
|
||||||
calendar_retiming_request,
|
calendar_retiming_request,
|
||||||
FAMILY_TOOLS, broad_web_briefing_request, required_read_operation_for_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,
|
targets_bound_editor_request, inline_text_transformation, editor_request_instructions,
|
||||||
_bound_editor_requests_web_verification, scheduled_automation_request, creation_container_tool,
|
_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
|
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 = (
|
PREVIEW_TOOLS = (
|
||||||
READ_TOOLS | SAFE_WRITE_TOOLS | EXPLICIT_EXECUTE_TOOLS | SAFE_UI_TOOLS
|
READ_TOOLS | SAFE_WRITE_TOOLS | EXPLICIT_EXECUTE_TOOLS | SAFE_UI_TOOLS
|
||||||
| BROKERED_JOB_TOOLS | CONTRACT_REQUIRED_TOOLS | CONTRACT_CORE_TOOLS
|
| BROKERED_JOB_TOOLS | CONTRACT_REQUIRED_TOOLS | CONTRACT_CORE_TOOLS
|
||||||
|
| EDITOR_AGENT_TOOLS
|
||||||
)
|
)
|
||||||
# Keep a small recovery-capable surface on every interactive compact agent
|
# Keep a small recovery-capable surface on every interactive compact agent
|
||||||
# turn. Routing still adds domain tools, while policy and action guards remain
|
# 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)
|
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):
|
def documents_terminal_response(raw, *, user_text='', max_items=8):
|
||||||
"""Render linked document rows under the user's explicit global cap."""
|
"""Render linked document rows under the user's explicit global cap."""
|
||||||
from src.agent_loop import _document_list_summary_from_tool_output
|
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()
|
text = str(payload or '').strip()
|
||||||
rows = []
|
rows = []
|
||||||
for line in text.splitlines():
|
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:
|
if match:
|
||||||
details = match[3].strip()
|
details = match[3].strip()
|
||||||
rows.append({
|
rows.append({
|
||||||
@@ -1317,7 +1346,7 @@ def skills_terminal_response(raw, *, user_text='', max_items=20):
|
|||||||
continue
|
continue
|
||||||
match = re.match(r'^\s*-\s+\*\*([^*]+)\*\*(?:\s+\(([^)]+)\))?', line)
|
match = re.match(r'^\s*-\s+\*\*([^*]+)\*\*(?:\s+\(([^)]+)\))?', line)
|
||||||
if not match:
|
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:
|
if match and status:
|
||||||
rows.append((status, match.group(1).strip(), (match.group(2) or '').strip()))
|
rows.append((status, match.group(1).strip(), (match.group(2) or '').strip()))
|
||||||
if not rows:
|
if not rows:
|
||||||
@@ -1368,17 +1397,26 @@ def contentless_final_response(content):
|
|||||||
|
|
||||||
|
|
||||||
def action_promise_response(content):
|
def action_promise_response(content):
|
||||||
"""Recognize a short promise to do work that contains no result yet."""
|
"""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:
|
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
|
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 False
|
||||||
return bool(re.match(
|
return bool(re.match(
|
||||||
r"^(?:okay[,.:]?\s*)?(?:let me|i(?:'ll| will)|next[,.:]?\s+i(?:'ll| will))\s+"
|
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"(?:now\s+)?(?:inspect|extract|read|check|review|analy[sz]e|verify|open|"
|
||||||
r"search|look|create|write|render|run|use|continue|finish|provide)\b",
|
r"search|look|create|write|render|run|use|continue|finish|provide|"
|
||||||
text,
|
r"browse|find|fetch|visit|navigate|go|get|scrape)\b",
|
||||||
|
without_code,
|
||||||
re.I,
|
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)
|
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):
|
def visible_content_after_qwen_thinking(content):
|
||||||
"""Remove private Qwen reasoning when the server lacks a reasoning parser."""
|
"""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()
|
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):
|
def prior_short_answer_for_no_tool_summary(user_text, history):
|
||||||
"""Reuse the immediately prior concise answer for an explicit no-tool recap."""
|
"""Reuse the immediately prior concise answer for an explicit no-tool recap."""
|
||||||
text = str(user_text or '')
|
text = str(user_text or '')
|
||||||
@@ -2714,6 +2998,18 @@ def normalize_preview_entity_anchor_args(name, args):
|
|||||||
value = args.get(field)
|
value = args.get(field)
|
||||||
if isinstance(value, str) and value.startswith(prefix):
|
if isinstance(value, str) and value.startswith(prefix):
|
||||||
args[field] = value[len(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
|
return args
|
||||||
|
|
||||||
|
|
||||||
@@ -2754,6 +3050,9 @@ def normalize_preview_function_args(name, args, *, user_text=''):
|
|||||||
args.pop('filter', None)
|
args.pop('filter', None)
|
||||||
if canonical(name) == 'manage_notes':
|
if canonical(name) == 'manage_notes':
|
||||||
action = str(args.get('action') or '').strip().replace('-', '_').casefold()
|
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':
|
if action == 'list':
|
||||||
request = str(user_text or '').casefold()
|
request = str(user_text or '').casefold()
|
||||||
# The notes backend interprets title/query/content on ``list`` as
|
# 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)
|
args.pop('archived', None)
|
||||||
if not re.search(r'\b(?:checklists?|freeform\s+notes?|notes?\s+only)\b', request):
|
if not re.search(r'\b(?:checklists?|freeform\s+notes?|notes?\s+only)\b', request):
|
||||||
args.pop('note_type', None)
|
args.pop('note_type', None)
|
||||||
|
query, _ = note_read_intent(user_text)
|
||||||
|
if query:
|
||||||
|
args['action'] = 'search'
|
||||||
|
args['query'] = query
|
||||||
if canonical(name) == 'manage_skills':
|
if canonical(name) == 'manage_skills':
|
||||||
action = str(args.get('action') or '').strip().replace('-', '_').casefold()
|
action = str(args.get('action') or '').strip().replace('-', '_').casefold()
|
||||||
if action in {'update', 'change', 'revise'}:
|
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))
|
requested_family_tools = set().union(*(FAMILY_TOOLS.get(f, ()) for f in active))
|
||||||
available_requested = routed_canonical & offered_canonical & requested_family_tools
|
available_requested = routed_canonical & offered_canonical & requested_family_tools
|
||||||
routed_canonical.update(canonical(name) for name in extra_tools)
|
routed_canonical.update(canonical(name) for name in extra_tools)
|
||||||
if 'image_generation' not in active:
|
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.
|
# 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')
|
routed_canonical.discard('generate_image')
|
||||||
missing_active = {
|
missing_active = {
|
||||||
f'capability:{family}' for family in active
|
f'capability:{family}' for family in active
|
||||||
@@ -3230,7 +3534,11 @@ def _successful_email_identifiers(history):
|
|||||||
for message in history or ():
|
for message in history or ():
|
||||||
if not isinstance(message, dict):
|
if not isinstance(message, dict):
|
||||||
continue
|
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'))
|
extracted = _email_identifiers_from_text(message.get('content'))
|
||||||
known['uid'].update(extracted['uid'])
|
known['uid'].update(extracted['uid'])
|
||||||
known['message_id'].update(extracted['message_id'])
|
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 | (
|
runtime_tools = PREVIEW_TOOLS | (
|
||||||
NATIVE_WORKSPACE_TOOLS if allow_native_workspace else frozenset()
|
NATIVE_WORKSPACE_TOOLS if allow_native_workspace else frozenset()
|
||||||
) | ({canonical(tool) for tool in external_runtime_tools} if allow_native_workspace else set())
|
) | ({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:
|
if bare not in runtime_tools:
|
||||||
return decision(False, 'tool_not_in_model_runtime')
|
return decision(False, 'tool_not_in_model_runtime')
|
||||||
if bare in {canonical(tool) for tool in external_runtime_tools}:
|
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 bare in BROKERED_JOB_TOOLS
|
||||||
or (contract_required and bare in {'edit_image', 'generate_image'})
|
or (contract_required and bare in {'edit_image', 'generate_image'})
|
||||||
or (contract_required and bare == 'create_document' and standalone_code_request(user_text))
|
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:
|
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.
|
# editor is offered only for an explicit image-editing turn.
|
||||||
blocked_effects.remove(ToolEffect.NETWORK_EGRESS)
|
blocked_effects.remove(ToolEffect.NETWORK_EGRESS)
|
||||||
allowed_effects.add(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 (
|
if (
|
||||||
contract_required
|
contract_required
|
||||||
and bare in {'download_model', 'serve_preset', 'stop_served_model'}
|
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
|
from src.turn_contract import result_reference_followup
|
||||||
source_context = (recent_source_reference_context(groups[-2])
|
source_context = (recent_source_reference_context(groups[-2])
|
||||||
if len(groups) > 1 and result_reference_followup(current_text) else None)
|
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.
|
# Drop whole turns only, never orphan tool results from their native calls.
|
||||||
groups = groups[-8:]
|
groups = groups[-8:]
|
||||||
while len(groups) > 1 and len(json.dumps(groups)) > 22000:
|
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.'
|
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):
|
def execution_has_write_effect(tool_name, content, capability, *, native_workspace_enabled):
|
||||||
"""Recognize successful native code that visibly mutates the workspace."""
|
"""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'\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'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'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',
|
r'\bgo\s+deeper\b',
|
||||||
re.I,
|
re.I,
|
||||||
)
|
)
|
||||||
@@ -4262,7 +4642,7 @@ def mutation_action_requested(user_text):
|
|||||||
actionable = re.sub(
|
actionable = re.sub(
|
||||||
r"\b(?:do\s+not|don't|never|without)\s+"
|
r"\b(?:do\s+not|don't|never|without)\s+"
|
||||||
r"(?:(?:chang(?:e|ing)|modif(?:y|ying)|edit(?:ing)?|delet(?:e|ing)|"
|
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|$))",
|
r"[^.;\n]{0,120}?(?=(?:[.;\n]|\bbut\b|\binstead\b|$))",
|
||||||
'', text, flags=re.I,
|
'', text, flags=re.I,
|
||||||
)
|
)
|
||||||
@@ -4685,7 +5065,7 @@ def email_search_result_empty(value):
|
|||||||
try:
|
try:
|
||||||
decoded = json.loads(value)
|
decoded = json.loads(value)
|
||||||
except (TypeError, ValueError):
|
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:
|
if decoded == value:
|
||||||
return False
|
return False
|
||||||
value = decoded
|
value = decoded
|
||||||
@@ -4959,6 +5339,16 @@ def private_browser_effective_url(result):
|
|||||||
return urls[-1] if urls else ''
|
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):
|
def browser_observation_page_missing(raw):
|
||||||
"""Recognize a rendered error page, rather than an article mentioning 404."""
|
"""Recognize a rendered error page, rather than an article mentioning 404."""
|
||||||
text = str(raw or '')
|
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,
|
external_tool_schemas=None, temperature=0.0,
|
||||||
context_resolution=None,
|
context_resolution=None,
|
||||||
request_authority=MISSING_AUTHORITY, **ignored):
|
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
|
from src.generation_sampling import validate_temperature
|
||||||
temperature = validate_temperature(temperature)
|
temperature = validate_temperature(temperature)
|
||||||
# This path sends requests directly with httpx and therefore bypasses
|
# 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,
|
'multimodal_image_count': image_context_count,
|
||||||
'attachment_reference_count': attachment_refs,
|
'attachment_reference_count': attachment_refs,
|
||||||
'image_rehydration': conversation_diagnostics.get('image_rehydration')})
|
'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:
|
try:
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
timeout=preview_http_timeout(
|
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_in += intent_accounting.get('input_tokens', 0)
|
||||||
usage_out += intent_accounting.get('output_tokens', 0)
|
usage_out += intent_accounting.get('output_tokens', 0)
|
||||||
has_real_usage = intent_accounting.get('usage_source') == 'real'
|
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
|
# One extra iteration is available only when a provider emits raw
|
||||||
# tool markup during the normal final no-tools round. Ordinary
|
# tool markup during the normal final no-tools round. Ordinary
|
||||||
# turns still obey ``round_limit`` exactly.
|
# 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,
|
'completion_reserve_seconds': artifact_completion_reserve_seconds,
|
||||||
})
|
})
|
||||||
request_messages = provider_request_messages(
|
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:
|
if prior_summary_answer or force_no_tools_next_round or reserve_final_synthesis:
|
||||||
round_offered = []
|
round_offered = []
|
||||||
@@ -6036,6 +6498,94 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
break
|
break
|
||||||
request['tools'] = source_tools
|
request['tools'] = source_tools
|
||||||
request['tool_choice'] = 'required'
|
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 = {}, '', ''
|
pending, content, round_reasoning = {}, '', ''
|
||||||
document_preview_index = None
|
document_preview_index = None
|
||||||
document_preview_content = ''
|
document_preview_content = ''
|
||||||
@@ -6056,6 +6606,49 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
request = search_tool_choice_request(request)
|
request = search_tool_choice_request(request)
|
||||||
tool_call_requested = request.get('tool_choice') not in (None, 'auto', 'none')
|
tool_call_requested = request.get('tool_choice') not in (None, 'auto', 'none')
|
||||||
request = provider_compatible_tool_choice_request(request, model)
|
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'):
|
if active_editor_target and request.get('tools'):
|
||||||
# Edits share mutable document state. Await a saved result
|
# Edits share mutable document state. Await a saved result
|
||||||
# before asking for another call; one call may still batch
|
# 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 progressive_thinking
|
||||||
and not source_lookup_pending
|
and not source_lookup_pending
|
||||||
and not tool_call_requested
|
and not tool_call_requested
|
||||||
|
and not _spec_direct_empty_menu
|
||||||
):
|
):
|
||||||
text_event = {'delta': text}
|
text_event = {'delta': text}
|
||||||
if replace_streamed_draft_on_finish and not streamed_round_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:
|
if content and not prior_summary_answer and not source_lookup_pending:
|
||||||
yield event({'delta': content})
|
yield event({'delta': content})
|
||||||
streamed_round_text = True
|
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)]
|
proposed = [pending[i] for i in sorted(pending)]
|
||||||
# A lead-in emitted before a tool call is live progress, not
|
# A lead-in emitted before a tool call is live progress, not
|
||||||
# part of the terminal answer. Replace that draft when the
|
# 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:
|
if proposed and streamed_round_text:
|
||||||
replace_streamed_draft_on_finish = True
|
replace_streamed_draft_on_finish = True
|
||||||
unexecutable_dsml_completion = False
|
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 = {
|
offered_by_canonical = {
|
||||||
canonical(schema['function']['name']): schema['function']['name']
|
canonical(schema['function']['name']): schema['function']['name']
|
||||||
for schema in round_offered
|
for schema in round_offered
|
||||||
@@ -6252,6 +6855,15 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
unexecutable_dsml_completion = True
|
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_lookup_pending and not proposed:
|
||||||
if source_answer_retries < 1 and round_number < round_limit:
|
if source_answer_retries < 1 and round_number < round_limit:
|
||||||
source_answer_retries += 1
|
source_answer_retries += 1
|
||||||
@@ -6276,6 +6888,37 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
)
|
)
|
||||||
if active_editor_target:
|
if active_editor_target:
|
||||||
proposed = drop_redundant_editor_noops(proposed)
|
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:
|
if model_choice_experiment:
|
||||||
for proposal in proposed:
|
for proposal in proposed:
|
||||||
yield event({'type': 'model_tool_proposal', 'round': round_number,
|
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.
|
# that draft and persisted history with the evidence-backed result.
|
||||||
history.pop()
|
history.pop()
|
||||||
refusal = denied_response()
|
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})
|
history.append({'role': 'assistant', 'content': refusal})
|
||||||
yield event({'type': 'final_response', 'content': refusal})
|
yield event({'type': 'final_response', 'content': refusal})
|
||||||
break
|
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
|
browser_search_url = requested_browser_url or effective_browser_url
|
||||||
is_search_engine_navigation = contains_search_engine_navigation(
|
is_search_engine_navigation = contains_search_engine_navigation(
|
||||||
browser_search_url
|
browser_search_url
|
||||||
) or bool(re.search(
|
) or is_google_sorry_url(effective_browser_url)
|
||||||
r'https?://(?:[^/]+\.)?google\.[^/]+/sorry/',
|
|
||||||
effective_browser_url,
|
|
||||||
re.I,
|
|
||||||
))
|
|
||||||
has_native_search = any(
|
has_native_search = any(
|
||||||
canonical(schema['function']['name']) == 'web_search'
|
canonical(schema['function']['name']) == 'web_search'
|
||||||
for schema in offered
|
for schema in offered
|
||||||
@@ -7948,6 +8606,15 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
output, user_text=latest_user,
|
output, user_text=latest_user,
|
||||||
max_items=contract_item_limit(turn_contract, 20),
|
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()
|
action = str(args.get('action') or '').replace('-', '_').casefold()
|
||||||
if (
|
if (
|
||||||
action in {'search', 'find'}
|
action in {'search', 'find'}
|
||||||
|
|||||||
@@ -92,6 +92,84 @@ def prune_multimodal_images(
|
|||||||
return pruned
|
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
|
COMPACT_THRESHOLD = 0.85 # Trigger compaction at 85% of context window
|
||||||
SUMMARY_MAX_TOKENS = 1024
|
SUMMARY_MAX_TOKENS = 1024
|
||||||
SMALL_CONTEXT_LIMIT = 8192 # Models with context <= this get aggressive trimming
|
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))
|
return max(50, min(95, value))
|
||||||
|
|
||||||
# Cursor-style self-summarization prompt — produces structured, dense summaries
|
# 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:
|
Use this format:
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"""Document processing: PDF/OCR extraction, text file handling, image VL analysis, user content building."""
|
"""Document processing: PDF/OCR extraction, text file handling, image VL analysis, user content building."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import base64
|
import base64
|
||||||
@@ -141,7 +142,10 @@ def _process_pdf(
|
|||||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||||
temp_img_path = tmp.name
|
temp_img_path = tmp.name
|
||||||
try:
|
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)
|
ocr_text = analyze_image_with_vl(temp_img_path, owner=owner)
|
||||||
if ocr_text and "unavailable" not in ocr_text.lower():
|
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}"
|
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", "")
|
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(
|
def build_user_content(
|
||||||
text: str,
|
text: str,
|
||||||
attachment_ids: list[str] | None,
|
attachment_ids: list[str] | None,
|
||||||
@@ -542,15 +589,14 @@ def build_user_content(
|
|||||||
|
|
||||||
if upload_handler.is_image_file(display_name, mime):
|
if upload_handler.is_image_file(display_name, mime):
|
||||||
try:
|
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,
|
# Extensionless uploads (e.g. a pasted screenshot) have no ext,
|
||||||
# so fall back to the resolved MIME subtype rather than emitting
|
# so fall back to the resolved MIME subtype rather than emitting
|
||||||
# an invalid "data:image/;base64," with an empty subtype.
|
# 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({
|
content.append({
|
||||||
"type": "image_url",
|
"type": "image_url",
|
||||||
"image_url": {"url": f"data:image/{image_format};base64,{encoded_string}"},
|
"image_url": {"url": data_url},
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to encode image {fid}: {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 json
|
||||||
import time
|
import time
|
||||||
import copy
|
import copy
|
||||||
from dataclasses import dataclass
|
import re
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -19,7 +20,7 @@ _DEPENDENCIES = {
|
|||||||
'web': {'web_search', 'web_fetch', 'private_browser'},
|
'web': {'web_search', 'web_fetch', 'private_browser'},
|
||||||
'email': {'list_email_accounts', 'list_emails', 'search_emails', 'read_email',
|
'email': {'list_email_accounts', 'list_emails', 'search_emails', 'read_email',
|
||||||
'download_attachment'},
|
'download_attachment'},
|
||||||
'contacts': {'resolve_contact'},
|
'contacts': {'resolve_contact', 'manage_contact'},
|
||||||
'documents': {'search_documents', 'read_document'},
|
'documents': {'search_documents', 'read_document'},
|
||||||
}
|
}
|
||||||
_DRAFT_TOOLS = {'ask_user', 'update_plan', 'draft_email', 'draft_email_reply',
|
_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):
|
def scope_email_tools(schemas, intent, *, active_editor=False):
|
||||||
if intent.operation == 'read' and intent.dependencies:
|
if intent.operation == 'read' and intent.dependencies:
|
||||||
allowed = set().union(*(_DEPENDENCIES[d] for d in intent.dependencies))
|
allowed = {tool for dependency in intent.dependencies
|
||||||
if active_editor:
|
for tool in _DEPENDENCIES[dependency]}
|
||||||
|
if active_editor and intent.dependencies:
|
||||||
allowed.update(_OPEN_EDITOR_TOOLS)
|
allowed.update(_OPEN_EDITOR_TOOLS)
|
||||||
if intent.needs_clarification:
|
if intent.needs_clarification:
|
||||||
allowed.add('ask_user')
|
allowed.add('ask_user')
|
||||||
@@ -126,6 +128,9 @@ def scope_email_tools(schemas, intent, *, active_editor=False):
|
|||||||
if intent.operation not in {'draft', 'revise'}:
|
if intent.operation not in {'draft', 'revise'}:
|
||||||
return list(schemas)
|
return list(schemas)
|
||||||
allowed = _DRAFT_TOOLS.union(*(_DEPENDENCIES[d] for d in intent.dependencies))
|
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:
|
if active_editor:
|
||||||
allowed.update(_OPEN_EDITOR_TOOLS)
|
allowed.update(_OPEN_EDITOR_TOOLS)
|
||||||
if not intent.needs_clarification:
|
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.
|
# as though it were the whole task. This byte budget is deliberately conservative.
|
||||||
CLASSIFIER_CONTEXT_BYTES = 24000
|
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,
|
async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
||||||
supplied_context=None, accounting=None):
|
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
|
# text from multimodal messages, so an attached image cannot hide the latest
|
||||||
# instruction and leave us classifying an earlier task instead.
|
# instruction and leave us classifying an earlier task instead.
|
||||||
dialogue = []
|
dialogue = []
|
||||||
|
latest_user_text = ''
|
||||||
|
prior_user_text = ''
|
||||||
for row in history:
|
for row in history:
|
||||||
if row.get('role') not in {'user', 'assistant'} or row.get('_harness_control'):
|
if row.get('role') not in {'user', 'assistant'} or row.get('_harness_control'):
|
||||||
continue
|
continue
|
||||||
@@ -166,6 +229,9 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
|||||||
and isinstance(block.get('text'), str))
|
and isinstance(block.get('text'), str))
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
dialogue.append({'role': row['role'], 'content': content})
|
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},
|
payload = json.dumps({'dialogue': dialogue, 'supplied_context': supplied_context},
|
||||||
ensure_ascii=False)
|
ensure_ascii=False)
|
||||||
if len(payload.encode('utf-8')) > CLASSIFIER_CONTEXT_BYTES:
|
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={
|
response = await client.post(endpoint_url, headers=headers, timeout=20, json={
|
||||||
'model': model, 'stream': False, 'temperature': 0, 'max_tokens': 500,
|
'model': model, 'stream': False, 'temperature': 0, 'max_tokens': 500,
|
||||||
'chat_template_kwargs': {'enable_thinking': False},
|
'chat_template_kwargs': {'enable_thinking': False},
|
||||||
'response_format': {'type': 'json_object'},
|
'response_format': EMAIL_TASK_RESPONSE_FORMAT,
|
||||||
'messages': [{'role': 'system', 'content': (
|
'messages': [{'role': 'system', 'content': (
|
||||||
'Classify the current conversational task. Return JSON only with operation '
|
'Classify the current conversational task. Return JSON only with operation '
|
||||||
'(draft, revise, read, send, other), requires_content (boolean), dependencies (array containing only web, '
|
'(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),
|
'response_time': round(time.monotonic() - started, 3),
|
||||||
})
|
})
|
||||||
try:
|
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:
|
except (KeyError, IndexError, TypeError) as exc:
|
||||||
raise ValueError('Invalid classifier response') from exc
|
raise ValueError('Invalid classifier response') from exc
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ def _row(index: int, message: dict[str, Any]) -> dict[str, Any]:
|
|||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
"uid": str(message.get("uid") or _uid(index)),
|
"uid": str(message.get("uid") or _uid(index)),
|
||||||
|
"message_id": str(message.get("message_id") or ""),
|
||||||
"account": str(message.get("account") or "fixture"),
|
"account": str(message.get("account") or "fixture"),
|
||||||
"account_email": str(message.get("account_email") or message.get("to") or ""),
|
"account_email": str(message.get("account_email") or message.get("to") or ""),
|
||||||
"account_id": str(message.get("account_id") or "fixture"),
|
"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"),
|
"folder": str(message.get("folder") or "INBOX"),
|
||||||
"attachments": attachments,
|
"attachments": attachments,
|
||||||
"has_attachments": bool(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":
|
if bare == "read_email":
|
||||||
uid = str(args.get("uid") or "")
|
uid = str(args.get("uid") or "")
|
||||||
|
message_id = str(args.get("message_id") or "")
|
||||||
folder = str(args.get("folder") or "INBOX")
|
folder = str(args.get("folder") or "INBOX")
|
||||||
try:
|
try:
|
||||||
index = int(uid) - 1
|
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()
|
all_messages = _messages()
|
||||||
owner_value = str(owner or "").strip()
|
owner_value = str(owner or "").strip()
|
||||||
visible_index = -1
|
visible_index = -1
|
||||||
|
matches = []
|
||||||
for original in all_messages:
|
for original in all_messages:
|
||||||
if owner_value and original.get("owner") and original.get("owner") != owner_value:
|
if owner_value and original.get("owner") and original.get("owner") != owner_value:
|
||||||
continue
|
continue
|
||||||
@@ -193,6 +206,13 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
|||||||
continue
|
continue
|
||||||
if not _folder_matches(original.get("folder"), folder):
|
if not _folder_matches(original.get("folder"), folder):
|
||||||
continue
|
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":
|
if bare == "archive_email":
|
||||||
original["folder"] = "Archive"
|
original["folder"] = "Archive"
|
||||||
action = "Archived"
|
action = "Archived"
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ def image_edit_size(model_id: str, width: int, height: int) -> str:
|
|||||||
return f"{width}x{height}"
|
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:
|
def looks_like_image_generation_model(model_id: str) -> bool:
|
||||||
"""Return True when a model id should use image generation routes.
|
"""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"
|
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:
|
class _DegenerateStreamGuard:
|
||||||
@@ -432,32 +443,50 @@ class _DegenerateStreamGuard:
|
|||||||
"Summer Summer ..."). This is not a useful response and can burn context,
|
"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
|
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.
|
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):
|
def __init__(self, model: str):
|
||||||
self.model = model or "model"
|
self.model = model or "model"
|
||||||
self.last_token = ""
|
self.last_token = ""
|
||||||
self.same_run = 0
|
self.same_run = 0
|
||||||
|
self.numeric_run = 0
|
||||||
self.recent_tokens: List[str] = []
|
self.recent_tokens: List[str] = []
|
||||||
self.periodic_tokens: List[str] = []
|
self.periodic_tokens: List[str] = []
|
||||||
self.total_chars = 0
|
self.total_chars = 0
|
||||||
|
self._buffer = ""
|
||||||
|
|
||||||
def check(self, text: str) -> Optional[str]:
|
def check(self, text: str) -> Optional[str]:
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
self.total_chars += len(text)
|
self.total_chars += len(text)
|
||||||
# Keep one-character tokens. Structured output often varies through
|
self._buffer += text
|
||||||
# numeric selectors or coordinates; dropping those tokens can make
|
|
||||||
# valid CSS/SVG rows look like an exact repeated phrase.
|
m_delims = list(_DELIM_RE.finditer(self._buffer))
|
||||||
tokens = [t.lower() for t in _DEGENERATE_WORD_RE.findall(text)]
|
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:
|
if not tokens:
|
||||||
return None
|
return None
|
||||||
for token in tokens:
|
|
||||||
|
for token, is_num in tokens:
|
||||||
if token == self.last_token:
|
if token == self.last_token:
|
||||||
self.same_run += 1
|
self.same_run += 1
|
||||||
else:
|
else:
|
||||||
self.last_token = token
|
self.last_token = token
|
||||||
self.same_run = 1
|
self.same_run = 1
|
||||||
|
self.numeric_run = self.numeric_run + 1 if is_num else 0
|
||||||
self.recent_tokens.append(token)
|
self.recent_tokens.append(token)
|
||||||
self.periodic_tokens.append(token)
|
self.periodic_tokens.append(token)
|
||||||
if len(self.recent_tokens) > 96:
|
if len(self.recent_tokens) > 96:
|
||||||
@@ -466,14 +495,20 @@ class _DegenerateStreamGuard:
|
|||||||
self.periodic_tokens = self.periodic_tokens[-2048:]
|
self.periodic_tokens = self.periodic_tokens[-2048:]
|
||||||
|
|
||||||
reason = None
|
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"
|
reason = f"repeated '{self.last_token}' {self.same_run} times"
|
||||||
elif len(self.recent_tokens) >= 72:
|
elif len(self.recent_tokens) >= 72:
|
||||||
top = max(set(self.recent_tokens), key=self.recent_tokens.count)
|
top = max(set(self.recent_tokens), key=self.recent_tokens.count)
|
||||||
count = self.recent_tokens.count(top)
|
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"
|
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.
|
# Detect a periodic suffix instead of counting repeated n-grams.
|
||||||
# Reused property names are normal in CSS/SVG/JSON; a collapsed
|
# Reused property names are normal in CSS/SVG/JSON; a collapsed
|
||||||
# generation repeats nearly every token at a short fixed period.
|
# generation repeats nearly every token at a short fixed period.
|
||||||
@@ -508,12 +543,15 @@ class _DegenerateStreamGuard:
|
|||||||
if history[previous:previous + anchor_size] != anchor:
|
if history[previous:previous + anchor_size] != anchor:
|
||||||
continue
|
continue
|
||||||
period = current_anchor - previous
|
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
|
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"
|
reason = f"repeated an exact {period}-token block twice"
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if not reason and self.numeric_run >= 2048:
|
||||||
|
reason = f"emitted {self.numeric_run} numbers in a row"
|
||||||
|
|
||||||
if not reason:
|
if not reason:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -1556,8 +1594,9 @@ def _build_chatgpt_responses_payload(
|
|||||||
"stream": stream,
|
"stream": stream,
|
||||||
"store": False,
|
"store": False,
|
||||||
}
|
}
|
||||||
if not _restricts_temperature(model):
|
# ChatGPT plan Responses requests reject temperature for every model.
|
||||||
payload["temperature"] = temperature
|
# 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 —
|
# ChatGPT Subscription Codex API does not support max_output_tokens —
|
||||||
# passing it returns HTTP 400 "Unsupported parameter: max_output_tokens".
|
# passing it returns HTTP 400 "Unsupported parameter: max_output_tokens".
|
||||||
# Do not include it in the payload.
|
# Do not include it in the payload.
|
||||||
|
|||||||
+15
-5
@@ -10,6 +10,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||||
from src.database import McpServer, SessionLocal
|
from src.database import McpServer, SessionLocal
|
||||||
|
|
||||||
@@ -383,12 +384,21 @@ class McpManager:
|
|||||||
|
|
||||||
provider = build_provider(server_id, url, on_redirect=_on_redirect)
|
provider = build_provider(server_id, url, on_redirect=_on_redirect)
|
||||||
stack = AsyncExitStack()
|
stack = AsyncExitStack()
|
||||||
transport = await stack.enter_async_context(streamablehttp_client(url, auth=provider))
|
try:
|
||||||
read_stream, write_stream, _get_session_id = transport
|
transport = await stack.enter_async_context(streamablehttp_client(url, auth=provider))
|
||||||
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
|
read_stream, write_stream, _get_session_id = transport
|
||||||
await session.initialize()
|
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
|
||||||
|
await session.initialize()
|
||||||
|
|
||||||
tools_result = await session.list_tools()
|
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 = []
|
tools = []
|
||||||
for tool in tools_result.tools:
|
for tool in tools_result.tools:
|
||||||
tools.append({
|
tools.append({
|
||||||
|
|||||||
@@ -193,7 +193,10 @@ class ModelDiscovery:
|
|||||||
data = r.json()
|
data = r.json()
|
||||||
# Some OpenAI-compatible servers return a bare list, not {"data": [...]}.
|
# Some OpenAI-compatible servers return a bare list, not {"data": [...]}.
|
||||||
items = data if isinstance(data, list) else ((data or {}).get("data") or [])
|
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:
|
if ids:
|
||||||
return {
|
return {
|
||||||
"host": host,
|
"host": host,
|
||||||
|
|||||||
+4
-2
@@ -102,8 +102,10 @@ _PLAIN_FRONT_MATTER_RE = re.compile(
|
|||||||
# - **label:** value <!-- field=NAME-ENC type=text -->
|
# - **label:** value <!-- field=NAME-ENC type=text -->
|
||||||
# - **label** [opts]: value <!-- field=NAME-ENC type=choice -->
|
# - **label** [opts]: value <!-- field=NAME-ENC type=choice -->
|
||||||
# - [x] **label** <!-- field=NAME-ENC type=checkbox -->
|
# - [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(
|
_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 = []
|
out = []
|
||||||
for ch in name or "":
|
for ch in name or "":
|
||||||
if ch.isalnum() or ch in ("_", ".", "-"):
|
if (ch.isascii() and ch.isalnum()) or ch in ("_", ".", "-"):
|
||||||
out.append(ch)
|
out.append(ch)
|
||||||
else:
|
else:
|
||||||
for b in ch.encode("utf-8"):
|
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",
|
"auth_or_protocol_error": "authentication or protocol error",
|
||||||
"no_models": "endpoint returned no models",
|
"no_models": "endpoint returned no models",
|
||||||
"no_host": "no host configured",
|
"no_host": "no host configured",
|
||||||
|
"config_source_error": "configuration source could not be read",
|
||||||
"error": "probe failed",
|
"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]:
|
def _svc(name: str, status: str, detail: str, **meta: Any) -> Dict[str, Any]:
|
||||||
return {"name": name, "status": status, "detail": detail, "meta": dict(meta)}
|
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.
|
"""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
|
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] = {}
|
settings: Dict[str, Any] = {}
|
||||||
integrations: List[Dict[str, Any]] = []
|
integrations: List[Dict[str, Any]] = []
|
||||||
accounts: List[Dict[str, Any]] = []
|
accounts: List[Dict[str, Any]] = []
|
||||||
endpoints: List[Dict[str, Any]] = []
|
endpoints: List[Dict[str, Any]] = []
|
||||||
|
failed: Dict[str, str] = {}
|
||||||
try:
|
try:
|
||||||
from src.settings import load_settings
|
from src.settings import load_settings
|
||||||
settings = load_settings() or {}
|
settings = load_settings() or {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
failed["settings"] = _classify_error(e)
|
||||||
logger.debug(f"service_health: settings load failed: {e}")
|
logger.debug(f"service_health: settings load failed: {e}")
|
||||||
try:
|
try:
|
||||||
from src.integrations import load_integrations
|
from src.integrations import load_integrations
|
||||||
integrations = load_integrations() or []
|
integrations = load_integrations() or []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
failed["integrations"] = _classify_error(e)
|
||||||
logger.debug(f"service_health: integrations load failed: {e}")
|
logger.debug(f"service_health: integrations load failed: {e}")
|
||||||
try:
|
try:
|
||||||
from routes.email_helpers import _list_email_accounts
|
from routes.email_helpers import _list_email_accounts
|
||||||
accounts = _list_email_accounts() or []
|
accounts = _list_email_accounts() or []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
failed["accounts"] = _classify_error(e)
|
||||||
logger.debug(f"service_health: email accounts load failed: {e}")
|
logger.debug(f"service_health: email accounts load failed: {e}")
|
||||||
try:
|
try:
|
||||||
from core.database import SessionLocal, ModelEndpoint
|
from core.database import SessionLocal, ModelEndpoint
|
||||||
@@ -443,9 +464,10 @@ def _gather_inputs() -> Dict[str, Any]:
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
failed["endpoints"] = _classify_error(e)
|
||||||
logger.debug(f"service_health: endpoint load failed: {e}")
|
logger.debug(f"service_health: endpoint load failed: {e}")
|
||||||
return {"settings": settings, "integrations": integrations,
|
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]:
|
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)
|
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,
|
async def collect_service_health(rag_manager: Any = None,
|
||||||
memory_vector: Any = None) -> Dict[str, Any]:
|
memory_vector: Any = None) -> Dict[str, Any]:
|
||||||
"""Run every probe and return {overall, services, timestamp}.
|
"""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`,
|
four network subsystems run concurrently, each under `_SUBSYSTEM_DEADLINE`,
|
||||||
with an overall `_AGGREGATE_DEADLINE` backstop. Per-item probes inside
|
with an overall `_AGGREGATE_DEADLINE` backstop. Per-item probes inside
|
||||||
providers/email are themselves bounded by `_FANOUT_BUDGET`.
|
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
|
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)
|
chroma = chromadb_health(rag_manager, memory_vector)
|
||||||
|
|
||||||
names = ["searxng", "ntfy", "email", "providers"]
|
names = ["searxng", "ntfy", "email", "providers"]
|
||||||
coros = [
|
probes = {
|
||||||
_run_subsystem("searxng", searxng_health, settings),
|
"searxng": (searxng_health, (settings,)),
|
||||||
_run_subsystem("ntfy", ntfy_health, inputs["integrations"], settings),
|
"ntfy": (ntfy_health, (inputs["integrations"], settings)),
|
||||||
_run_subsystem("email", email_health, inputs["accounts"]),
|
"email": (email_health, (inputs["accounts"],)),
|
||||||
_run_subsystem("providers", providers_health, inputs["endpoints"]),
|
"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:
|
try:
|
||||||
results = await asyncio.wait_for(asyncio.gather(*coros),
|
results = await asyncio.wait_for(asyncio.gather(*coros),
|
||||||
timeout=_AGGREGATE_DEADLINE)
|
timeout=_AGGREGATE_DEADLINE)
|
||||||
|
|||||||
+15
-56
@@ -12,16 +12,6 @@ from datetime import datetime, timedelta, timezone
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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_EMPTY_SESSION_GRACE = timedelta(minutes=10)
|
||||||
_FRESH_SESSION_GRACE = _FRESH_EMPTY_SESSION_GRACE
|
_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:
|
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:
|
Args:
|
||||||
owner: user whose sessions to process
|
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
|
skip Phase 2 (AI folder assignment). Used by the built-in daily
|
||||||
background sweep so it never burns LLM tokens.
|
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.
|
Returns a human-readable summary of what was done.
|
||||||
"""
|
"""
|
||||||
from core.database import SessionLocal, Session as DbSession, ChatMessage as DbMsg
|
from core.database import SessionLocal, Session as DbSession, ChatMessage as DbMsg
|
||||||
from src.llm_core import llm_call_async
|
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()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
# ── Phase 1: Delete empty/throwaway sessions ──
|
# ── Phase 1: Delete only empty and explicitly Incognito sessions ──
|
||||||
deleted_empty = 0
|
deleted_empty = 0
|
||||||
deleted_throwaway = 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):
|
if is_session_recently_active(row, now=cleanup_now):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
msg_count = db.query(DbMsg.id).filter(
|
has_messages = db.query(DbMsg.id).filter(
|
||||||
DbMsg.session_id == row.id
|
DbMsg.session_id == row.id
|
||||||
).limit(_THROWAWAY_MAX_MESSAGES + 1).count()
|
).first() is not None
|
||||||
should_delete = False
|
if not has_messages:
|
||||||
|
|
||||||
if msg_count == 0:
|
|
||||||
if is_fresh:
|
if is_fresh:
|
||||||
continue
|
continue
|
||||||
should_delete = True
|
|
||||||
deleted_empty += 1
|
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)
|
db.delete(row)
|
||||||
|
|
||||||
if deleted_empty or deleted_throwaway:
|
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)."
|
return f"Cleaned {deleted_empty + deleted_throwaway} sessions (folder sort skipped)."
|
||||||
|
|
||||||
url, model, headers = resolve_task_endpoint(owner=owner or None)
|
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)
|
names_text = "\n".join(f' "{s["id"][:8]}": "{s["name"]}"' for s in session_list)
|
||||||
prompt = (
|
prompt = (
|
||||||
@@ -183,8 +138,12 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
|
|||||||
try:
|
try:
|
||||||
# 16384 (was 4096): large folder JSON + reasoning-model thinking
|
# 16384 (was 4096): large folder JSON + reasoning-model thinking
|
||||||
# overflowed 4096 and truncated the JSON, so it never parsed.
|
# overflowed 4096 and truncated the JSON, so it never parsed.
|
||||||
raw = await llm_call_async(url, model, [{"role": "user", "content": prompt}],
|
raw = await task_llm_call_async(
|
||||||
temperature=0.3, max_tokens=16384, headers=headers, timeout=120)
|
[{"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:
|
except Exception as e:
|
||||||
logger.warning(f"Auto-sort LLM call failed: {e}")
|
logger.warning(f"Auto-sort LLM call failed: {e}")
|
||||||
return f"Cleaned {deleted_empty + deleted_throwaway} sessions. Folder sort skipped (model unreachable)."
|
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("_", "\\_")
|
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:
|
def _snippet(content: str, query: str, radius: int = 60) -> str:
|
||||||
content = content or ""
|
content = content or ""
|
||||||
query = query or ""
|
query = query or ""
|
||||||
if not query:
|
if not query:
|
||||||
return content[: radius * 2]
|
return content[: radius * 2]
|
||||||
|
|
||||||
idx = content.lower().find(query.lower())
|
match_start = -1
|
||||||
if idx == -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]
|
return content[: radius * 2]
|
||||||
|
|
||||||
start = max(0, idx - radius)
|
start = max(0, match_start - radius)
|
||||||
end = min(len(content), idx + len(query) + radius)
|
end = min(len(content), match_start + match_length + radius)
|
||||||
return ("..." if start > 0 else "") + content[start:end] + ("..." if end < len(content) else "")
|
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:
|
def _sanitize_fts_query(query: str) -> str | None:
|
||||||
"""Convert free text into a conservative FTS5 MATCH query.
|
"""Convert free text into a conservative FTS5 MATCH query.
|
||||||
|
|
||||||
|
|||||||
+22
-1
@@ -46,7 +46,14 @@ DEFAULT_SETTINGS = {
|
|||||||
"agent_email_confirm": True,
|
"agent_email_confirm": True,
|
||||||
"image_gen_enabled": False,
|
"image_gen_enabled": False,
|
||||||
"image_model": "",
|
"image_model": "",
|
||||||
|
"image_edit_model": "",
|
||||||
"image_quality": "medium",
|
"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_model": "",
|
||||||
"vision_enabled": True,
|
"vision_enabled": True,
|
||||||
# Ordered fallback chain for the Vision model (image analysis, OCR, tagging).
|
# 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.
|
# resolved by FastAPI deps; an empty/None owner falls through to the global.
|
||||||
_PER_USER_KEYS = {
|
_PER_USER_KEYS = {
|
||||||
"vision_model", "vision_enabled", "vision_model_fallbacks",
|
"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
|
# Default chat endpoint / model — without per-user resolution every new
|
||||||
# account inherited whatever the most-recent admin picked, which then
|
# account inherited whatever the most-recent admin picked, which then
|
||||||
# got injected into the chat composer on first open.
|
# 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
|
Falls back gracefully if the prefs module can't be imported (cycle/early
|
||||||
boot) — admin-global settings keep working.
|
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:
|
if owner and key in _PER_USER_KEYS:
|
||||||
try:
|
try:
|
||||||
from routes.prefs_routes import _load_for_user
|
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
|
||||||
+10
-12
@@ -47,25 +47,23 @@ def resolve_task_candidates(
|
|||||||
candidates.append((url, model, headers or {}))
|
candidates.append((url, model, headers or {}))
|
||||||
|
|
||||||
if override_url and override_model:
|
if override_url and override_model:
|
||||||
headers = override_headers or {}
|
|
||||||
try:
|
try:
|
||||||
from src.database import ModelEndpoint, SessionLocal
|
from src.database import SessionLocal
|
||||||
from src.endpoint_resolver import normalize_base, resolve_endpoint_runtime, build_headers
|
from src.endpoint_resolver import (
|
||||||
|
build_chat_url, build_headers, resolve_endpoint_runtime,
|
||||||
|
resolve_owner_registered_endpoint,
|
||||||
|
)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
from src.auth_helpers import owner_filter
|
ep = resolve_owner_registered_endpoint(db, override_url, owner)
|
||||||
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
runtime_base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||||
for ep in owner_filter(query, ModelEndpoint, owner).all():
|
_append(build_chat_url(runtime_base), override_model, build_headers(api_key, runtime_base))
|
||||||
base = normalize_base(getattr(ep, "base_url", "") or "")
|
|
||||||
if _same_endpoint_base(override_url, base):
|
|
||||||
runtime_base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
|
||||||
headers = build_headers(api_key, runtime_base or base)
|
|
||||||
break
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
# A missing, foreign, disabled or unreadable registration is not
|
||||||
|
# authority to contact a caller-provided endpoint or use its key.
|
||||||
pass
|
pass
|
||||||
_append(override_url, override_model, headers)
|
|
||||||
_append(*resolve_task_endpoint(fallback_url, fallback_model, fallback_headers, owner=owner))
|
_append(*resolve_task_endpoint(fallback_url, fallback_model, fallback_headers, owner=owner))
|
||||||
_append(*resolve_endpoint("utility", owner=owner))
|
_append(*resolve_endpoint("utility", owner=owner))
|
||||||
_append(*resolve_endpoint("default", owner=owner))
|
_append(*resolve_endpoint("default", owner=owner))
|
||||||
|
|||||||
+72
-27
@@ -12,7 +12,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from typing import Any, Awaitable, Callable, Dict, Tuple
|
from typing import Any, Awaitable, Callable, Dict, Tuple
|
||||||
|
|
||||||
from core.auth import RESERVED_USERNAMES
|
from core.auth import RESERVED_USERNAMES
|
||||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
from src.owner_identity import REQUEST_SENTINEL_OWNERS, auth_disabled
|
||||||
from src.task_action_policy import (
|
from src.task_action_policy import (
|
||||||
is_admin_only_task_action,
|
is_admin_only_task_action,
|
||||||
owner_has_admin_task_privileges,
|
owner_has_admin_task_privileges,
|
||||||
@@ -21,6 +21,15 @@ from src.task_action_policy import (
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskEndpointAuthorityError(RuntimeError):
|
||||||
|
"""A scheduled task's endpoint or its credentials cannot be authorized."""
|
||||||
|
|
||||||
|
|
||||||
|
def _require_task_owner(owner):
|
||||||
|
if not str(owner or "").strip() and not auth_disabled():
|
||||||
|
raise TaskEndpointAuthorityError("Scheduled task has no owner in authenticated mode")
|
||||||
|
|
||||||
|
|
||||||
def _is_sft_fixture_owner(owner: str | None) -> bool:
|
def _is_sft_fixture_owner(owner: str | None) -> bool:
|
||||||
"""Synthetic SFT accounts may manage tasks but must never auto-fire them."""
|
"""Synthetic SFT accounts may manage tasks but must never auto-fire them."""
|
||||||
return str(owner or "").strip().lower().startswith("sft_")
|
return str(owner or "").strip().lower().startswith("sft_")
|
||||||
@@ -123,6 +132,23 @@ async def _cached(key: Tuple, ttl: float, fetch: Callable[[], Awaitable[Any]]) -
|
|||||||
_shared_cache_pending.pop(key, None)
|
_shared_cache_pending.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_cron(cron_expression: str) -> bool:
|
||||||
|
"""True for a standard 5-field cron expression that croniter can parse.
|
||||||
|
|
||||||
|
croniter also accepts 6- and 7-field expressions with a seconds field, but
|
||||||
|
the scheduler works at one-minute resolution and would run such a task
|
||||||
|
every second.
|
||||||
|
"""
|
||||||
|
if len((cron_expression or "").split()) != 5:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
from croniter import croniter
|
||||||
|
croniter(cron_expression)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def compute_next_run(schedule: str, scheduled_time: str,
|
def compute_next_run(schedule: str, scheduled_time: str,
|
||||||
scheduled_day: int = None,
|
scheduled_day: int = None,
|
||||||
scheduled_date: datetime = None,
|
scheduled_date: datetime = None,
|
||||||
@@ -166,6 +192,9 @@ def compute_next_run(schedule: str, scheduled_time: str,
|
|||||||
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
if schedule == "cron" and cron_expression:
|
if schedule == "cron" and cron_expression:
|
||||||
|
if not is_valid_cron(cron_expression):
|
||||||
|
logger.warning(f"Invalid cron expression '{cron_expression}'")
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
from croniter import croniter
|
from croniter import croniter
|
||||||
cron = croniter(cron_expression, now)
|
cron = croniter(cron_expression, now)
|
||||||
@@ -297,7 +326,7 @@ def _digest_windows(now):
|
|||||||
|
|
||||||
|
|
||||||
def _checkin_calendar_events(db, owner, start, end):
|
def _checkin_calendar_events(db, owner, start, end):
|
||||||
"""Calendar events in [start, end] for ONE owner, for the check-in digest.
|
"""Calendar events in [start, end) for ONE owner, for the check-in digest.
|
||||||
|
|
||||||
Ownership lives on CalendarCal.owner; events inherit it via calendar_id.
|
Ownership lives on CalendarCal.owner; events inherit it via calendar_id.
|
||||||
The digest query had no owner scope, so it pulled EVERY user's events into
|
The digest query had no owner scope, so it pulled EVERY user's events into
|
||||||
@@ -311,7 +340,7 @@ def _checkin_calendar_events(db, owner, start, end):
|
|||||||
.filter(
|
.filter(
|
||||||
_CC.owner == owner,
|
_CC.owner == owner,
|
||||||
_CE.dtstart >= start,
|
_CE.dtstart >= start,
|
||||||
_CE.dtstart <= end,
|
_CE.dtstart < end,
|
||||||
_CE.status != "cancelled",
|
_CE.status != "cancelled",
|
||||||
)
|
)
|
||||||
.order_by(_CE.dtstart)
|
.order_by(_CE.dtstart)
|
||||||
@@ -1345,6 +1374,7 @@ class TaskScheduler:
|
|||||||
|
|
||||||
async def _execute_action(self, task, run_id: str | None = None) -> tuple:
|
async def _execute_action(self, task, run_id: str | None = None) -> tuple:
|
||||||
"""Execute a built-in action (no LLM needed)."""
|
"""Execute a built-in action (no LLM needed)."""
|
||||||
|
_require_task_owner(task.owner)
|
||||||
from src.builtin_actions import BUILTIN_ACTIONS
|
from src.builtin_actions import BUILTIN_ACTIONS
|
||||||
from src.agent_runtime.authority import (
|
from src.agent_runtime.authority import (
|
||||||
bind_request_authority, restore_task_authority, task_operation,
|
bind_request_authority, restore_task_authority, task_operation,
|
||||||
@@ -1634,6 +1664,7 @@ class TaskScheduler:
|
|||||||
async def _execute_llm_task(self, task, db) -> str:
|
async def _execute_llm_task(self, task, db) -> str:
|
||||||
"""Execute an LLM task with full tool access via the agent loop."""
|
"""Execute an LLM task with full tool access via the agent loop."""
|
||||||
from core.database import Session as DbSession, ChatMessage, CrewMember
|
from core.database import Session as DbSession, ChatMessage, CrewMember
|
||||||
|
_require_task_owner(task.owner)
|
||||||
|
|
||||||
# If this task is wired to a CrewMember (personal assistant, custom
|
# If this task is wired to a CrewMember (personal assistant, custom
|
||||||
# crew), prefer the crew member's persona/model/endpoint as overrides.
|
# crew), prefer the crew member's persona/model/endpoint as overrides.
|
||||||
@@ -1654,6 +1685,11 @@ class TaskScheduler:
|
|||||||
endpoint_url, model = self._resolve_defaults(db, task.owner)
|
endpoint_url, model = self._resolve_defaults(db, task.owner)
|
||||||
if not endpoint_url or not model:
|
if not endpoint_url or not model:
|
||||||
raise RuntimeError("No model/endpoint configured")
|
raise RuntimeError("No model/endpoint configured")
|
||||||
|
# Authorize the original URL before normalization can erase a query,
|
||||||
|
# fragment, or caller-selected path. Stored tasks do not themselves
|
||||||
|
# grant permission to contact an endpoint.
|
||||||
|
from src.endpoint_resolver import resolve_owner_registered_endpoint
|
||||||
|
resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None)
|
||||||
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
||||||
# Record the resolved model so _execute_task_locked can persist it on
|
# Record the resolved model so _execute_task_locked can persist it on
|
||||||
# the run (tasks rarely pin a model, so this is the only record of
|
# the run (tasks rarely pin a model, so this is the only record of
|
||||||
@@ -1772,6 +1808,8 @@ class TaskScheduler:
|
|||||||
relevant_tools=relevant_tools,
|
relevant_tools=relevant_tools,
|
||||||
datetime_context_msg=_dt_msg,
|
datetime_context_msg=_dt_msg,
|
||||||
)
|
)
|
||||||
|
except TaskEndpointAuthorityError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Agent loop failed for task '{task.name}', falling back to simple call: {e}")
|
logger.warning(f"Agent loop failed for task '{task.name}', falling back to simple call: {e}")
|
||||||
from src.task_endpoint import task_llm_call_async
|
from src.task_endpoint import task_llm_call_async
|
||||||
@@ -1986,6 +2024,7 @@ class TaskScheduler:
|
|||||||
override_user_message: str | None = None,
|
override_user_message: str | None = None,
|
||||||
datetime_context_msg: dict | None = None) -> str:
|
datetime_context_msg: dict | None = None) -> str:
|
||||||
"""Run the full agent loop with tool access, collecting the final text."""
|
"""Run the full agent loop with tool access, collecting the final text."""
|
||||||
|
_require_task_owner(task.owner)
|
||||||
from src.agent_loop import stream_agent_loop
|
from src.agent_loop import stream_agent_loop
|
||||||
from src.agent_runtime.authority import restore_task_authority
|
from src.agent_runtime.authority import restore_task_authority
|
||||||
|
|
||||||
@@ -2015,8 +2054,8 @@ class TaskScheduler:
|
|||||||
headers = build_headers(api_key, base)
|
headers = build_headers(api_key, base)
|
||||||
finally:
|
finally:
|
||||||
db2.close()
|
db2.close()
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
raise TaskEndpointAuthorityError("Scheduled task endpoint is unavailable or unauthorized") from exc
|
||||||
full_text = ""
|
full_text = ""
|
||||||
final_text_replaced = False
|
final_text_replaced = False
|
||||||
tool_results = []
|
tool_results = []
|
||||||
@@ -2172,11 +2211,16 @@ class TaskScheduler:
|
|||||||
from src.settings import get_setting
|
from src.settings import get_setting
|
||||||
|
|
||||||
# Resolve endpoint/model: research settings > task settings > session defaults
|
# Resolve endpoint/model: research settings > task settings > session defaults
|
||||||
|
_require_task_owner(task.owner)
|
||||||
endpoint_url = task.endpoint_url
|
endpoint_url = task.endpoint_url
|
||||||
model = task.model
|
model = task.model
|
||||||
headers = {}
|
headers = {}
|
||||||
headers_from_resolver = False
|
headers_from_resolver = False
|
||||||
|
|
||||||
|
if endpoint_url:
|
||||||
|
from src.endpoint_resolver import resolve_owner_registered_endpoint
|
||||||
|
resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None)
|
||||||
|
|
||||||
if not endpoint_url or not model:
|
if not endpoint_url or not model:
|
||||||
try:
|
try:
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
@@ -2213,8 +2257,8 @@ class TaskScheduler:
|
|||||||
base, api_key = resolve_endpoint_runtime(ep, owner=task.owner or None)
|
base, api_key = resolve_endpoint_runtime(ep, owner=task.owner or None)
|
||||||
endpoint_url = build_chat_url(base)
|
endpoint_url = build_chat_url(base)
|
||||||
headers = build_headers(api_key, base)
|
headers = build_headers(api_key, base)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
raise TaskEndpointAuthorityError("Scheduled research endpoint is unavailable or unauthorized") from exc
|
||||||
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
||||||
|
|
||||||
max_tokens, extraction_timeout, extraction_concurrency = (
|
max_tokens, extraction_timeout, extraction_concurrency = (
|
||||||
@@ -2348,11 +2392,11 @@ class TaskScheduler:
|
|||||||
have to special-case each tool's schema; the MCP tool ignores keys it
|
have to special-case each tool's schema; the MCP tool ignores keys it
|
||||||
doesn't recognise.
|
doesn't recognise.
|
||||||
"""
|
"""
|
||||||
|
_require_task_owner(task.owner)
|
||||||
from src.tool_utils import get_mcp_manager
|
from src.tool_utils import get_mcp_manager
|
||||||
mcp = get_mcp_manager()
|
mcp = get_mcp_manager()
|
||||||
if not mcp:
|
if not mcp:
|
||||||
logger.warning(f"Task {task.id}: MCP manager not available for delivery")
|
raise RuntimeError("MCP manager not available for task delivery")
|
||||||
return
|
|
||||||
|
|
||||||
# Resolve recipient — prefer the configured email From (the established
|
# Resolve recipient — prefer the configured email From (the established
|
||||||
# "email yourself" pattern from daily_brief), fall back to task.owner.
|
# "email yourself" pattern from daily_brief), fall back to task.owner.
|
||||||
@@ -2361,7 +2405,7 @@ class TaskScheduler:
|
|||||||
recipient = None
|
recipient = None
|
||||||
try:
|
try:
|
||||||
from routes.email_helpers import _get_email_config
|
from routes.email_helpers import _get_email_config
|
||||||
cfg = _get_email_config() or {}
|
cfg = _get_email_config(owner=task.owner or "") or {}
|
||||||
recipient = cfg.get("from_address") or None
|
recipient = cfg.get("from_address") or None
|
||||||
except Exception as _e:
|
except Exception as _e:
|
||||||
logger.debug(f"_deliver_via_mcp: email config lookup failed: {_e}")
|
logger.debug(f"_deliver_via_mcp: email config lookup failed: {_e}")
|
||||||
@@ -2377,6 +2421,14 @@ class TaskScheduler:
|
|||||||
"X-Odysseus-Ref": str(task.id),
|
"X-Odysseus-Ref": str(task.id),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if tool_name.startswith("mcp__email__"):
|
||||||
|
# The email server uses this reserved field for its account and
|
||||||
|
# approval scope; it must come from the task owner, never a model.
|
||||||
|
from src.tool_execution import _EMAIL_MCP_OWNER_ARG, _EMAIL_MCP_SESSION_ARG
|
||||||
|
if task.owner:
|
||||||
|
args[_EMAIL_MCP_OWNER_ARG] = task.owner
|
||||||
|
if getattr(task, "session_id", None):
|
||||||
|
args[_EMAIL_MCP_SESSION_ARG] = task.session_id
|
||||||
if recipient:
|
if recipient:
|
||||||
# Cover the common field names so we work across MCP servers (Gmail,
|
# Cover the common field names so we work across MCP servers (Gmail,
|
||||||
# generic SMTP, Slack DMs, etc.) without having to hard-code each.
|
# generic SMTP, Slack DMs, etc.) without having to hard-code each.
|
||||||
@@ -2391,25 +2443,18 @@ class TaskScheduler:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
mcp_result = await mcp.call_tool(tool_name, args)
|
mcp_result = await mcp.call_tool(tool_name, args)
|
||||||
stderr = mcp_result.get("stderr", "")
|
if isinstance(mcp_result, dict):
|
||||||
stdout = mcp_result.get("stdout", "")
|
from src.tool_execution import _normalize_mcp_text_error
|
||||||
body_len = len(result or "")
|
mcp_result = _normalize_mcp_text_error(dict(mcp_result))
|
||||||
exit_code = mcp_result.get("exit_code", 0)
|
if not isinstance(mcp_result, dict) or mcp_result.get("error") or mcp_result.get("exit_code") != 0:
|
||||||
if exit_code != 0:
|
raise RuntimeError("MCP task delivery failed or returned an invalid result")
|
||||||
logger.warning(
|
if tool_name == "mcp__email__send_email" and not str(mcp_result.get("stdout") or "").strip().startswith("Sent email to "):
|
||||||
f"Task {task.id} MCP delivery FAILED via {tool_name}: "
|
raise RuntimeError("MCP email delivery did not report a completed send")
|
||||||
f"exit={exit_code} stderr={stderr[:400]!r} stdout={stdout[:400]!r}"
|
logger.info("Task %s delivered via MCP tool %s (recipient_set=%s, body=%sb)",
|
||||||
)
|
task.id, tool_name, bool(recipient), len(result or ""))
|
||||||
else:
|
|
||||||
# Include the MCP tool's own stdout (e.g. email_server returns
|
|
||||||
# "Sent email to ... with subject ...") + the body size so a
|
|
||||||
# silent SMTP failure is easier to spot in the logs.
|
|
||||||
logger.info(
|
|
||||||
f"Task {task.id} delivered via MCP tool {tool_name} "
|
|
||||||
f"(recipient_set={bool(recipient)}, body={body_len}b, reply={stdout[:200]!r})"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Task {task.id} MCP delivery failed: {e}")
|
logger.error(f"Task {task.id} MCP delivery failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
async def run_task_now(self, task_id: str, *, force: bool = False):
|
async def run_task_now(self, task_id: str, *, force: bool = False):
|
||||||
"""Manually trigger a task execution."""
|
"""Manually trigger a task execution."""
|
||||||
|
|||||||
@@ -216,6 +216,28 @@ _register(
|
|||||||
ToolEffect.WRITE_PRIVATE,
|
ToolEffect.WRITE_PRIVATE,
|
||||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
)
|
)
|
||||||
|
_register(
|
||||||
|
{"ai_edit_image"},
|
||||||
|
ToolEffect.NETWORK_EGRESS,
|
||||||
|
ToolEffect.WRITE_PRIVATE,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"get_editor_screenshot"},
|
||||||
|
ToolEffect.READ_PRIVATE,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"plan_edit"},
|
||||||
|
ToolEffect.NETWORK_EGRESS,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"run_editor_command"},
|
||||||
|
ToolEffect.READ_PRIVATE,
|
||||||
|
ToolEffect.WRITE_PRIVATE,
|
||||||
|
ToolEffect.UI_SIDE_EFFECT,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
_register(
|
_register(
|
||||||
{
|
{
|
||||||
"archive_email",
|
"archive_email",
|
||||||
@@ -388,7 +410,7 @@ _PRIVATE_ACTION_WRITES: Mapping[str, frozenset[str]] = MappingProxyType(
|
|||||||
"manage_endpoints": frozenset({"add", "delete", "enable", "disable"}),
|
"manage_endpoints": frozenset({"add", "delete", "enable", "disable"}),
|
||||||
"manage_mcp": frozenset({"add", "delete", "enable", "disable", "reconnect"}),
|
"manage_mcp": frozenset({"add", "delete", "enable", "disable", "reconnect"}),
|
||||||
"manage_settings": frozenset(
|
"manage_settings": frozenset(
|
||||||
{"set", "delete", "reset", "disable_tool", "enable_tool"}
|
{"set", "delete", "reset", "disable_tool"}
|
||||||
),
|
),
|
||||||
"manage_tokens": frozenset({"create", "delete"}),
|
"manage_tokens": frozenset({"create", "delete"}),
|
||||||
"manage_webhooks": frozenset({"add", "delete", "enable", "disable"}),
|
"manage_webhooks": frozenset({"add", "delete", "enable", "disable"}),
|
||||||
|
|||||||
+37
-2
@@ -1558,6 +1558,31 @@ def _agent_subprocess_env() -> dict:
|
|||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_private_browser_enabled() -> bool:
|
||||||
|
return os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"", "0", "false", "no", "off"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _legacy_private_browser(operation, transport, *, progress_cb=None, session_id=None, owner=None):
|
||||||
|
from src.agent_tools.web_tools_prewave3 import PrivateBrowserTool
|
||||||
|
ctx = {
|
||||||
|
"progress_cb": progress_cb,
|
||||||
|
"subproc_env": _agent_subprocess_env(),
|
||||||
|
"session_id": session_id,
|
||||||
|
"owner": owner,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
result = await PrivateBrowserTool().execute(operation.input, ctx)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as error:
|
||||||
|
logger.warning("legacy private_browser failed: %s", error)
|
||||||
|
return f"{transport}: ERROR", {"error": f"private_browser: {error}", "exit_code": 1}
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
result = {"output": str(result), "exit_code": 0}
|
||||||
|
label = "ERROR" if result.get("error") or result.get("exit_code") not in (None, 0) else "EXECUTED"
|
||||||
|
return f"{transport}: {label}", result
|
||||||
|
|
||||||
|
|
||||||
async def _direct_fallback(
|
async def _direct_fallback(
|
||||||
tool: str,
|
tool: str,
|
||||||
content: str,
|
content: str,
|
||||||
@@ -1731,6 +1756,14 @@ async def execute_tool_block(
|
|||||||
}
|
}
|
||||||
|
|
||||||
transport = operation.transport_tool
|
transport = operation.transport_tool
|
||||||
|
if operation.tool == "private_browser" and _legacy_private_browser_enabled():
|
||||||
|
# The legacy process cannot establish the sealed browser/page identity
|
||||||
|
# required by the current runtime. Never execute it ahead of resource
|
||||||
|
# binding and the untrusted-context approval gate, even when opted in.
|
||||||
|
return f"{transport}: UNSUPPORTED", {
|
||||||
|
**page_unavailable(),
|
||||||
|
"error": "Legacy browser transport cannot establish the required browser authority.",
|
||||||
|
}
|
||||||
if operation.tool == "private_browser":
|
if operation.tool == "private_browser":
|
||||||
try:
|
try:
|
||||||
_, browser_args = parse_browser_operation(operation.input)
|
_, browser_args = parse_browser_operation(operation.input)
|
||||||
@@ -2193,7 +2226,8 @@ async def _execute_tool_block_impl(
|
|||||||
if tool == "generate_image":
|
if tool == "generate_image":
|
||||||
from src.ai_interaction import do_generate_image
|
from src.ai_interaction import do_generate_image
|
||||||
desc = "generate_image"
|
desc = "generate_image"
|
||||||
result = await dispatched(do_generate_image(content, session_id=session_id, owner=owner))
|
image_privacy = (client_runtime_context or {}).get('web_ui_state', {}).get('incognito') is True
|
||||||
|
result = await dispatched(do_generate_image(content, session_id=session_id, owner=owner, **({'incognito': True} if image_privacy else {})))
|
||||||
elif (tool in NATIVE_FILESYSTEM_TOOLS
|
elif (tool in NATIVE_FILESYSTEM_TOOLS
|
||||||
and (active_resource_operation() is not None or tool != "apply_patch"
|
and (active_resource_operation() is not None or tool != "apply_patch"
|
||||||
or not _tui_host_bridge_patch_url(client_runtime_context))):
|
or not _tui_host_bridge_patch_url(client_runtime_context))):
|
||||||
@@ -2353,7 +2387,8 @@ async def _execute_tool_block_impl(
|
|||||||
result = await dispatched(do_list_cookbook_servers(content, owner=owner))
|
result = await dispatched(do_list_cookbook_servers(content, owner=owner))
|
||||||
elif tool == "edit_image":
|
elif tool == "edit_image":
|
||||||
desc = "edit_image"
|
desc = "edit_image"
|
||||||
result = await dispatched(do_edit_image(content, owner=owner))
|
image_privacy = (client_runtime_context or {}).get('web_ui_state', {}).get('incognito') is True
|
||||||
|
result = await dispatched(do_edit_image(content, owner=owner, **({'incognito': True} if image_privacy else {})))
|
||||||
elif tool == "edit_file":
|
elif tool == "edit_file":
|
||||||
result = await dispatched(_direct_fallback(tool, content)) or {"error": "edit failed", "exit_code": 1}
|
result = await dispatched(_direct_fallback(tool, content)) or {"error": "edit failed", "exit_code": 1}
|
||||||
desc = result.get("output") or result.get("error") or "edit_file"
|
desc = result.get("output") or result.get("error") or "edit_file"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user